Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ jobs:
- name: Test with tox
run: tox

e2e-windows:
# Flow Launcher only runs on Windows; exercise the real process boundary there.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox
- name: Run e2e tests
run: tox -e py311 -- tests/e2e

concurrency:
group: tests
cancel-in-progress: true
120 changes: 120 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Helpers for spawning fixture plugins as real subprocesses.

These tests exercise the exact process boundary Flow Launcher uses:
V1 plugins get one JSON-RPC request as argv[1] and answer on stdout in a
fresh process per request; V2 plugins are a single long-lived process
speaking newline-delimited JSON-RPC over stdin/stdout.
"""
from __future__ import annotations

import json
import os
import queue
import subprocess
import sys
import threading
from pathlib import Path
from typing import Any, Optional

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
V1_PLUGIN = FIXTURES / 'v1_plugin' / 'main.py'
V2_PLUGIN = FIXTURES / 'v2_plugin' / 'main.py'

READ_TIMEOUT = 15.0


def plugin_env() -> dict:
"""Subprocess environment with the repo importable even without install."""
env = os.environ.copy()
env['PYTHONPATH'] = str(REPO_ROOT) + os.pathsep + env.get('PYTHONPATH', '')
return env


@pytest.fixture
def run_v1(tmp_path):
"""Spawn the V1 fixture exactly as Flow Launcher does: fresh process,
request JSON in argv[1], response read from stdout."""
def _run(request: dict) -> str:
completed = subprocess.run(
[sys.executable, str(V1_PLUGIN), json.dumps(request)],
capture_output=True, text=True, encoding='utf-8',
cwd=tmp_path, env=plugin_env(), timeout=READ_TIMEOUT,
)
assert completed.returncode == 0, completed.stderr
return completed.stdout
return _run


class V2PluginProcess:
"""A persistent V2 plugin subprocess with a line-reader thread so tests
never block forever on a plugin that stops responding."""

def __init__(self, tmp_path: Path) -> None:
self.proc = subprocess.Popen(
[sys.executable, str(V2_PLUGIN)],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding='utf-8', bufsize=1,
cwd=tmp_path, env=plugin_env(),
)
self._lines: queue.Queue = queue.Queue()
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()

def _pump(self) -> None:
for line in self.proc.stdout:
if line.strip():
self._lines.put(line)

def send(self, message: dict) -> None:
self.proc.stdin.write(json.dumps(message) + '\n')
self.proc.stdin.flush()

def read_message(self, timeout: float = READ_TIMEOUT) -> dict:
try:
return json.loads(self._lines.get(timeout=timeout))
except queue.Empty:
raise AssertionError(
f"No response from V2 plugin within {timeout}s; "
f"stderr: {self._drain_stderr()}"
)

def request(self, request_id: int, method: str, params: Optional[list] = None,
**extra: Any) -> dict:
"""Send a request and return the response bearing the same id."""
message = {'jsonrpc': '2.0', 'id': request_id, 'method': method,
'params': params or [], **extra}
self.send(message)
response = self.read_message()
assert response.get('id') == request_id, (
f"Expected response to id={request_id}, got: {response}")
return response

def assert_no_output(self, wait: float = 0.5) -> None:
try:
line = self._lines.get(timeout=wait)
except queue.Empty:
return
raise AssertionError(f"Expected silence, but plugin wrote: {line!r}")

def _drain_stderr(self) -> str:
if self.proc.poll() is None:
return '<process still running>'
return self.proc.stderr.read()

def close(self) -> None:
if self.proc.poll() is None:
self.proc.kill()
self.proc.wait(timeout=5)
for stream in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if stream:
stream.close()


@pytest.fixture
def v2_plugin(tmp_path):
process = V2PluginProcess(tmp_path)
yield process
process.close()
24 changes: 24 additions & 0 deletions tests/e2e/fixtures/v1_plugin/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Fixture plugin spawned as a real subprocess by the e2e tests (V1 protocol)."""
import json

from pyflowlauncher import Plugin, Result

plugin = Plugin()


@plugin.on_method
def query(q: str):
yield Result(
title=f"echo: {q}",
subtitle=json.dumps(plugin.settings),
icon="icon.png",
)


@plugin.on_method
def context_menu(data):
yield Result(title=f"context: {json.dumps(data)}")


if __name__ == '__main__':
plugin.run()
12 changes: 12 additions & 0 deletions tests/e2e/fixtures/v1_plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ID": "e2e-fixture-v1",
"Name": "E2E Fixture V1",
"Author": "pyFlowLauncher tests",
"Version": "0.0.1",
"Language": "python",
"Description": "Fixture plugin for subprocess e2e tests (V1 protocol).",
"Website": "https://github.com/Garulf/pyFlowLauncher",
"ExecuteFileName": "main.py",
"IcoPath": "icon.png",
"ActionKeyword": "e2e"
}
24 changes: 24 additions & 0 deletions tests/e2e/fixtures/v2_plugin/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Fixture plugin spawned as a real subprocess by the e2e tests (V2 protocol)."""
import json

from pyflowlauncher import Plugin, Result

plugin = Plugin()


@plugin.on_method
def query(q: str):
yield Result(
title=f"echo: {q}",
subtitle=json.dumps(plugin.settings),
icon="icon.png",
)


@plugin.on_method
def context_menu(data):
yield Result(title=f"context: {json.dumps(data)}")


if __name__ == '__main__':
plugin.run()
12 changes: 12 additions & 0 deletions tests/e2e/fixtures/v2_plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ID": "e2e-fixture-v2",
"Name": "E2E Fixture V2",
"Author": "pyFlowLauncher tests",
"Version": "0.0.1",
"Language": "python_v2",
"Description": "Fixture plugin for subprocess e2e tests (V2 protocol).",
"Website": "https://github.com/Garulf/pyFlowLauncher",
"ExecuteFileName": "main.py",
"IcoPath": "icon.png",
"ActionKeyword": "e2e"
}
45 changes: 45 additions & 0 deletions tests/e2e/test_v1_subprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""End-to-end tests for the V1 protocol across a real process boundary.

Flow Launcher spawns a fresh process per request with the JSON-RPC request
serialized into argv[1] and deserializes whatever the process writes to
stdout. These tests do exactly that — they catch buffering, encoding, and
stream-pollution bugs that in-process tests with patched stdio cannot.
"""
import json


def test_query_roundtrip(run_v1):
stdout = run_v1({'method': 'query', 'parameters': ['hello'], 'settings': {}})
response = json.loads(stdout)
assert response['Result'][0]['Title'] == 'echo: hello'


def test_stdout_is_pure_json(run_v1):
"""Nothing (logging, warnings, prints) may pollute the response stream."""
stdout = run_v1({'method': 'query', 'parameters': ['x'], 'settings': {}})
json.loads(stdout)


def test_settings_reach_the_plugin(run_v1):
settings = {'api_key': 'abc123', 'max_results': 5}
stdout = run_v1({'method': 'query', 'parameters': ['q'], 'settings': settings})
response = json.loads(stdout)
assert json.loads(response['Result'][0]['SubTitle']) == settings


def test_unicode_query(run_v1):
stdout = run_v1({'method': 'query', 'parameters': ['héllo ☃'], 'settings': {}})
response = json.loads(stdout)
assert response['Result'][0]['Title'] == 'echo: héllo ☃'


def test_context_menu(run_v1):
stdout = run_v1({'method': 'context_menu', 'parameters': [['ctx-data']], 'settings': {}})
response = json.loads(stdout)
assert response['Result'][0]['Title'] == 'context: ["ctx-data"]'


def test_empty_query(run_v1):
stdout = run_v1({'method': 'query', 'parameters': [''], 'settings': {}})
response = json.loads(stdout)
assert response['Result'][0]['Title'] == 'echo: '
66 changes: 66 additions & 0 deletions tests/e2e/test_v2_subprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""End-to-end tests for the V2 protocol across a real process boundary.

Flow Launcher keeps one plugin process alive and speaks newline-delimited
JSON-RPC over its stdin/stdout. These tests drive a real subprocess with the
exact message shapes the host sends (query params as objects, lifecycle
methods, $/ notifications) and assert on the raw response stream.
"""
import json


def query_titles(response: dict) -> list:
return [r['Title'] for r in response['result']['result']]


def test_initialize_then_query(v2_plugin):
response = v2_plugin.request(1, 'initialize', [{}])
assert response['result'] == {}
assert response['error'] is None

# The host sends the query as an object: [Query, Settings.Inner]
response = v2_plugin.request(2, 'query', [{'search': 'hello', 'rawQuery': 'e2e hello'}, {}])
assert query_titles(response) == ['echo: hello']


def test_persistent_process_serves_many_queries(v2_plugin):
for i, term in enumerate(('one', 'two', 'three'), start=1):
response = v2_plugin.request(i, 'query', [{'search': term}, {}])
assert query_titles(response) == [f'echo: {term}']
assert v2_plugin.proc.poll() is None


def test_settings_from_query_params(v2_plugin):
settings = {'token': 'xyz', 'limit': 3}
response = v2_plugin.request(1, 'query', [{'search': 'q'}, settings])
subtitle = response['result']['result'][0]['SubTitle']
assert json.loads(subtitle) == settings


def test_cancel_request_notification_is_ignored(v2_plugin):
v2_plugin.send({'jsonrpc': '2.0', 'method': '$/cancelRequest', 'params': {'id': 99}})
v2_plugin.assert_no_output()
response = v2_plugin.request(2, 'query', [{'search': 'after-cancel'}, {}])
assert query_titles(response) == ['echo: after-cancel']


def test_context_menu(v2_plugin):
response = v2_plugin.request(1, 'context_menu', [['ctx-data']])
assert query_titles(response) == ['context: ["ctx-data"]']


def test_unicode_roundtrip(v2_plugin):
response = v2_plugin.request(1, 'query', [{'search': 'héllo ☃'}, {}])
assert query_titles(response) == ['echo: héllo ☃']


def test_close_shuts_down_cleanly(v2_plugin):
response = v2_plugin.request(1, 'close', [])
assert response['result'] == {}
assert v2_plugin.proc.wait(timeout=10) == 0


def test_malformed_line_does_not_kill_the_process(v2_plugin):
v2_plugin.proc.stdin.write('this is not json\n')
v2_plugin.proc.stdin.flush()
response = v2_plugin.request(1, 'query', [{'search': 'still-alive'}, {}])
assert query_titles(response) == ['echo: still-alive']