Skip to content

Commit ca29f56

Browse files
committed
test: add subprocess e2e suite driving plugins as Flow Launcher does
Spawn fixture plugins across a real process boundary using the exact mechanics of the host: V1 gets one JSON-RPC request in argv[1] in a fresh process per query; V2 is a persistent process speaking newline-delimited JSON-RPC over stdin/stdout. Covers query/context_menu roundtrips, settings delivery, unicode, lifecycle (initialize/close), $/cancelRequest notifications, malformed input resilience, stdout stream purity, and process persistence - buffering/encoding/stream-pollution bugs that in-process tests with patched stdio cannot catch. Also adds a windows-latest CI job running the e2e tests, since Flow Launcher plugins only ever run on Windows.
1 parent 3898450 commit ca29f56

8 files changed

Lines changed: 319 additions & 0 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ jobs:
3636
- name: Test with tox
3737
run: tox
3838

39+
e2e-windows:
40+
# Flow Launcher only runs on Windows; exercise the real process boundary there.
41+
runs-on: windows-latest
42+
steps:
43+
- uses: actions/checkout@v4
44+
- name: Set up Python
45+
uses: actions/setup-python@v4
46+
with:
47+
python-version: '3.11'
48+
- name: Install dependencies
49+
run: |
50+
python -m pip install --upgrade pip
51+
python -m pip install tox
52+
- name: Run e2e tests
53+
run: tox -e py311 -- tests/e2e
54+
3955
concurrency:
4056
group: tests
4157
cancel-in-progress: true

tests/e2e/conftest.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Helpers for spawning fixture plugins as real subprocesses.
2+
3+
These tests exercise the exact process boundary Flow Launcher uses:
4+
V1 plugins get one JSON-RPC request as argv[1] and answer on stdout in a
5+
fresh process per request; V2 plugins are a single long-lived process
6+
speaking newline-delimited JSON-RPC over stdin/stdout.
7+
"""
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
import queue
13+
import subprocess
14+
import sys
15+
import threading
16+
from pathlib import Path
17+
from typing import Any, Optional
18+
19+
import pytest
20+
21+
REPO_ROOT = Path(__file__).resolve().parents[2]
22+
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
23+
V1_PLUGIN = FIXTURES / 'v1_plugin' / 'main.py'
24+
V2_PLUGIN = FIXTURES / 'v2_plugin' / 'main.py'
25+
26+
READ_TIMEOUT = 15.0
27+
28+
29+
def plugin_env() -> dict:
30+
"""Subprocess environment with the repo importable even without install."""
31+
env = os.environ.copy()
32+
env['PYTHONPATH'] = str(REPO_ROOT) + os.pathsep + env.get('PYTHONPATH', '')
33+
return env
34+
35+
36+
@pytest.fixture
37+
def run_v1(tmp_path):
38+
"""Spawn the V1 fixture exactly as Flow Launcher does: fresh process,
39+
request JSON in argv[1], response read from stdout."""
40+
def _run(request: dict) -> str:
41+
completed = subprocess.run(
42+
[sys.executable, str(V1_PLUGIN), json.dumps(request)],
43+
capture_output=True, text=True, encoding='utf-8',
44+
cwd=tmp_path, env=plugin_env(), timeout=READ_TIMEOUT,
45+
)
46+
assert completed.returncode == 0, completed.stderr
47+
return completed.stdout
48+
return _run
49+
50+
51+
class V2PluginProcess:
52+
"""A persistent V2 plugin subprocess with a line-reader thread so tests
53+
never block forever on a plugin that stops responding."""
54+
55+
def __init__(self, tmp_path: Path) -> None:
56+
self.proc = subprocess.Popen(
57+
[sys.executable, str(V2_PLUGIN)],
58+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
59+
text=True, encoding='utf-8', bufsize=1,
60+
cwd=tmp_path, env=plugin_env(),
61+
)
62+
self._lines: queue.Queue = queue.Queue()
63+
self._reader = threading.Thread(target=self._pump, daemon=True)
64+
self._reader.start()
65+
66+
def _pump(self) -> None:
67+
for line in self.proc.stdout:
68+
if line.strip():
69+
self._lines.put(line)
70+
71+
def send(self, message: dict) -> None:
72+
self.proc.stdin.write(json.dumps(message) + '\n')
73+
self.proc.stdin.flush()
74+
75+
def read_message(self, timeout: float = READ_TIMEOUT) -> dict:
76+
try:
77+
return json.loads(self._lines.get(timeout=timeout))
78+
except queue.Empty:
79+
raise AssertionError(
80+
f"No response from V2 plugin within {timeout}s; "
81+
f"stderr: {self._drain_stderr()}"
82+
)
83+
84+
def request(self, request_id: int, method: str, params: Optional[list] = None,
85+
**extra: Any) -> dict:
86+
"""Send a request and return the response bearing the same id."""
87+
message = {'jsonrpc': '2.0', 'id': request_id, 'method': method,
88+
'params': params or [], **extra}
89+
self.send(message)
90+
response = self.read_message()
91+
assert response.get('id') == request_id, (
92+
f"Expected response to id={request_id}, got: {response}")
93+
return response
94+
95+
def assert_no_output(self, wait: float = 0.5) -> None:
96+
try:
97+
line = self._lines.get(timeout=wait)
98+
except queue.Empty:
99+
return
100+
raise AssertionError(f"Expected silence, but plugin wrote: {line!r}")
101+
102+
def _drain_stderr(self) -> str:
103+
if self.proc.poll() is None:
104+
return '<process still running>'
105+
return self.proc.stderr.read()
106+
107+
def close(self) -> None:
108+
if self.proc.poll() is None:
109+
self.proc.kill()
110+
self.proc.wait(timeout=5)
111+
for stream in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
112+
if stream:
113+
stream.close()
114+
115+
116+
@pytest.fixture
117+
def v2_plugin(tmp_path):
118+
process = V2PluginProcess(tmp_path)
119+
yield process
120+
process.close()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Fixture plugin spawned as a real subprocess by the e2e tests (V1 protocol)."""
2+
import json
3+
4+
from pyflowlauncher import Plugin, Result
5+
6+
plugin = Plugin()
7+
8+
9+
@plugin.on_method
10+
def query(q: str):
11+
yield Result(
12+
title=f"echo: {q}",
13+
subtitle=json.dumps(plugin.settings),
14+
icon="icon.png",
15+
)
16+
17+
18+
@plugin.on_method
19+
def context_menu(data):
20+
yield Result(title=f"context: {json.dumps(data)}")
21+
22+
23+
if __name__ == '__main__':
24+
plugin.run()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"ID": "e2e-fixture-v1",
3+
"Name": "E2E Fixture V1",
4+
"Author": "pyFlowLauncher tests",
5+
"Version": "0.0.1",
6+
"Language": "python",
7+
"Description": "Fixture plugin for subprocess e2e tests (V1 protocol).",
8+
"Website": "https://github.com/Garulf/pyFlowLauncher",
9+
"ExecuteFileName": "main.py",
10+
"IcoPath": "icon.png",
11+
"ActionKeyword": "e2e"
12+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Fixture plugin spawned as a real subprocess by the e2e tests (V2 protocol)."""
2+
import json
3+
4+
from pyflowlauncher import Plugin, Result
5+
6+
plugin = Plugin()
7+
8+
9+
@plugin.on_method
10+
def query(q: str):
11+
yield Result(
12+
title=f"echo: {q}",
13+
subtitle=json.dumps(plugin.settings),
14+
icon="icon.png",
15+
)
16+
17+
18+
@plugin.on_method
19+
def context_menu(data):
20+
yield Result(title=f"context: {json.dumps(data)}")
21+
22+
23+
if __name__ == '__main__':
24+
plugin.run()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"ID": "e2e-fixture-v2",
3+
"Name": "E2E Fixture V2",
4+
"Author": "pyFlowLauncher tests",
5+
"Version": "0.0.1",
6+
"Language": "python_v2",
7+
"Description": "Fixture plugin for subprocess e2e tests (V2 protocol).",
8+
"Website": "https://github.com/Garulf/pyFlowLauncher",
9+
"ExecuteFileName": "main.py",
10+
"IcoPath": "icon.png",
11+
"ActionKeyword": "e2e"
12+
}

tests/e2e/test_v1_subprocess.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""End-to-end tests for the V1 protocol across a real process boundary.
2+
3+
Flow Launcher spawns a fresh process per request with the JSON-RPC request
4+
serialized into argv[1] and deserializes whatever the process writes to
5+
stdout. These tests do exactly that — they catch buffering, encoding, and
6+
stream-pollution bugs that in-process tests with patched stdio cannot.
7+
"""
8+
import json
9+
10+
11+
def test_query_roundtrip(run_v1):
12+
stdout = run_v1({'method': 'query', 'parameters': ['hello'], 'settings': {}})
13+
response = json.loads(stdout)
14+
assert response['Result'][0]['Title'] == 'echo: hello'
15+
16+
17+
def test_stdout_is_pure_json(run_v1):
18+
"""Nothing (logging, warnings, prints) may pollute the response stream."""
19+
stdout = run_v1({'method': 'query', 'parameters': ['x'], 'settings': {}})
20+
json.loads(stdout)
21+
22+
23+
def test_settings_reach_the_plugin(run_v1):
24+
settings = {'api_key': 'abc123', 'max_results': 5}
25+
stdout = run_v1({'method': 'query', 'parameters': ['q'], 'settings': settings})
26+
response = json.loads(stdout)
27+
assert json.loads(response['Result'][0]['SubTitle']) == settings
28+
29+
30+
def test_unicode_query(run_v1):
31+
stdout = run_v1({'method': 'query', 'parameters': ['héllo ☃'], 'settings': {}})
32+
response = json.loads(stdout)
33+
assert response['Result'][0]['Title'] == 'echo: héllo ☃'
34+
35+
36+
def test_context_menu(run_v1):
37+
stdout = run_v1({'method': 'context_menu', 'parameters': [['ctx-data']], 'settings': {}})
38+
response = json.loads(stdout)
39+
assert response['Result'][0]['Title'] == 'context: ["ctx-data"]'
40+
41+
42+
def test_empty_query(run_v1):
43+
stdout = run_v1({'method': 'query', 'parameters': [''], 'settings': {}})
44+
response = json.loads(stdout)
45+
assert response['Result'][0]['Title'] == 'echo: '

tests/e2e/test_v2_subprocess.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""End-to-end tests for the V2 protocol across a real process boundary.
2+
3+
Flow Launcher keeps one plugin process alive and speaks newline-delimited
4+
JSON-RPC over its stdin/stdout. These tests drive a real subprocess with the
5+
exact message shapes the host sends (query params as objects, lifecycle
6+
methods, $/ notifications) and assert on the raw response stream.
7+
"""
8+
import json
9+
10+
11+
def query_titles(response: dict) -> list:
12+
return [r['Title'] for r in response['result']['result']]
13+
14+
15+
def test_initialize_then_query(v2_plugin):
16+
response = v2_plugin.request(1, 'initialize', [{}])
17+
assert response['result'] == {}
18+
assert response['error'] is None
19+
20+
# The host sends the query as an object: [Query, Settings.Inner]
21+
response = v2_plugin.request(2, 'query', [{'search': 'hello', 'rawQuery': 'e2e hello'}, {}])
22+
assert query_titles(response) == ['echo: hello']
23+
24+
25+
def test_persistent_process_serves_many_queries(v2_plugin):
26+
for i, term in enumerate(('one', 'two', 'three'), start=1):
27+
response = v2_plugin.request(i, 'query', [{'search': term}, {}])
28+
assert query_titles(response) == [f'echo: {term}']
29+
assert v2_plugin.proc.poll() is None
30+
31+
32+
def test_settings_from_query_params(v2_plugin):
33+
settings = {'token': 'xyz', 'limit': 3}
34+
response = v2_plugin.request(1, 'query', [{'search': 'q'}, settings])
35+
subtitle = response['result']['result'][0]['SubTitle']
36+
assert json.loads(subtitle) == settings
37+
38+
39+
def test_cancel_request_notification_is_ignored(v2_plugin):
40+
v2_plugin.send({'jsonrpc': '2.0', 'method': '$/cancelRequest', 'params': {'id': 99}})
41+
v2_plugin.assert_no_output()
42+
response = v2_plugin.request(2, 'query', [{'search': 'after-cancel'}, {}])
43+
assert query_titles(response) == ['echo: after-cancel']
44+
45+
46+
def test_context_menu(v2_plugin):
47+
response = v2_plugin.request(1, 'context_menu', [['ctx-data']])
48+
assert query_titles(response) == ['context: ["ctx-data"]']
49+
50+
51+
def test_unicode_roundtrip(v2_plugin):
52+
response = v2_plugin.request(1, 'query', [{'search': 'héllo ☃'}, {}])
53+
assert query_titles(response) == ['echo: héllo ☃']
54+
55+
56+
def test_close_shuts_down_cleanly(v2_plugin):
57+
response = v2_plugin.request(1, 'close', [])
58+
assert response['result'] == {}
59+
assert v2_plugin.proc.wait(timeout=10) == 0
60+
61+
62+
def test_malformed_line_does_not_kill_the_process(v2_plugin):
63+
v2_plugin.proc.stdin.write('this is not json\n')
64+
v2_plugin.proc.stdin.flush()
65+
response = v2_plugin.request(1, 'query', [{'search': 'still-alive'}, {}])
66+
assert query_titles(response) == ['echo: still-alive']

0 commit comments

Comments
 (0)