|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +End-to-end test: flyto-core Python ↔ Node.js plugin via JSON-RPC stdio. |
| 4 | +
|
| 5 | +Spawns the echo test plugin as a subprocess (same as flyto-core runtime would), |
| 6 | +sends handshake + invoke + ping + shutdown, verifies responses. |
| 7 | +""" |
| 8 | + |
| 9 | +import asyncio |
| 10 | +import json |
| 11 | +import os |
| 12 | +import sys |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +PLUGIN_JS = Path(__file__).parent.parent / "packages" / "sdk" / "dist" / "echo-test-plugin.js" |
| 16 | + |
| 17 | +async def send_recv(proc, method, params=None, msg_id=1): |
| 18 | + """Send a JSON-RPC request and read the response.""" |
| 19 | + request = {"jsonrpc": "2.0", "method": method, "id": msg_id} |
| 20 | + if params: |
| 21 | + request["params"] = params |
| 22 | + |
| 23 | + line = json.dumps(request) + "\n" |
| 24 | + proc.stdin.write(line.encode()) |
| 25 | + await proc.stdin.drain() |
| 26 | + |
| 27 | + raw = await asyncio.wait_for(proc.stdout.readline(), timeout=5.0) |
| 28 | + return json.loads(raw.decode().strip()) |
| 29 | + |
| 30 | + |
| 31 | +async def run_tests(): |
| 32 | + assert PLUGIN_JS.exists(), f"Build first: {PLUGIN_JS}" |
| 33 | + |
| 34 | + # Spawn plugin — same as flyto-core runtime/process.py does |
| 35 | + proc = await asyncio.create_subprocess_exec( |
| 36 | + "node", str(PLUGIN_JS), |
| 37 | + stdin=asyncio.subprocess.PIPE, |
| 38 | + stdout=asyncio.subprocess.PIPE, |
| 39 | + stderr=asyncio.subprocess.PIPE, |
| 40 | + ) |
| 41 | + |
| 42 | + passed = 0 |
| 43 | + failed = 0 |
| 44 | + |
| 45 | + try: |
| 46 | + # ── Test 1: Handshake ──────────────────────────── |
| 47 | + res = await send_recv(proc, "handshake", { |
| 48 | + "protocolVersion": "0.1.0", |
| 49 | + "pluginId": "test/echo", |
| 50 | + "executionId": "e2e-test", |
| 51 | + }, msg_id=1) |
| 52 | + |
| 53 | + assert res["id"] == 1, f"Wrong id: {res}" |
| 54 | + result = res["result"] |
| 55 | + assert result["pluginVersion"] == "0.1.0", f"Wrong version: {result}" |
| 56 | + assert "echo" in result["steps"], f"Missing echo step: {result}" |
| 57 | + assert "add" in result["steps"], f"Missing add step: {result}" |
| 58 | + print(" PASS: handshake") |
| 59 | + passed += 1 |
| 60 | + |
| 61 | + # ── Test 2: Invoke echo ────────────────────────── |
| 62 | + res = await send_recv(proc, "invoke", { |
| 63 | + "step": "echo", |
| 64 | + "input": {"message": "hello flyto"}, |
| 65 | + }, msg_id=2) |
| 66 | + |
| 67 | + result = res["result"] |
| 68 | + assert result["ok"] is True, f"Not ok: {result}" |
| 69 | + assert result["data"]["echo"] == "hello flyto", f"Wrong echo: {result}" |
| 70 | + assert result["data"]["reversed"] == "otylf olleh", f"Wrong reverse: {result}" |
| 71 | + print(" PASS: invoke echo") |
| 72 | + passed += 1 |
| 73 | + |
| 74 | + # ── Test 3: Invoke add ─────────────────────────── |
| 75 | + res = await send_recv(proc, "invoke", { |
| 76 | + "step": "add", |
| 77 | + "input": {"a": 17, "b": 25}, |
| 78 | + }, msg_id=3) |
| 79 | + |
| 80 | + result = res["result"] |
| 81 | + assert result["ok"] is True, f"Not ok: {result}" |
| 82 | + assert result["data"]["result"] == 42, f"Wrong sum: {result}" |
| 83 | + print(" PASS: invoke add") |
| 84 | + passed += 1 |
| 85 | + |
| 86 | + # ── Test 4: Invoke with context ────────────────── |
| 87 | + res = await send_recv(proc, "invoke", { |
| 88 | + "step": "echo", |
| 89 | + "input": {"message": "ctx-test"}, |
| 90 | + "context": { |
| 91 | + "execution_id": "exec-999", |
| 92 | + "browser_ws_endpoint": "ws://localhost:9222", |
| 93 | + }, |
| 94 | + }, msg_id=4) |
| 95 | + |
| 96 | + result = res["result"] |
| 97 | + assert result["ok"] is True, f"Not ok: {result}" |
| 98 | + assert result["data"]["echo"] == "ctx-test", f"Wrong echo: {result}" |
| 99 | + print(" PASS: invoke with context") |
| 100 | + passed += 1 |
| 101 | + |
| 102 | + # ── Test 5: Invoke unknown step ────────────────── |
| 103 | + res = await send_recv(proc, "invoke", { |
| 104 | + "step": "nonexistent", |
| 105 | + "input": {}, |
| 106 | + }, msg_id=5) |
| 107 | + |
| 108 | + result = res["result"] |
| 109 | + assert result["ok"] is False, f"Should fail: {result}" |
| 110 | + assert result["error"]["code"] == "STEP_NOT_FOUND", f"Wrong error: {result}" |
| 111 | + print(" PASS: invoke unknown step returns error") |
| 112 | + passed += 1 |
| 113 | + |
| 114 | + # ── Test 6: Invoke step that throws ────────────── |
| 115 | + res = await send_recv(proc, "invoke", { |
| 116 | + "step": "fail", |
| 117 | + "input": {}, |
| 118 | + }, msg_id=6) |
| 119 | + |
| 120 | + result = res["result"] |
| 121 | + assert result["ok"] is False, f"Should fail: {result}" |
| 122 | + assert "intentional" in result["error"]["message"], f"Wrong error msg: {result}" |
| 123 | + print(" PASS: invoke throwing step returns error") |
| 124 | + passed += 1 |
| 125 | + |
| 126 | + # ── Test 7: Ping ───────────────────────────────── |
| 127 | + res = await send_recv(proc, "ping", msg_id=7) |
| 128 | + |
| 129 | + result = res["result"] |
| 130 | + assert result["status"] == "ok", f"Ping failed: {result}" |
| 131 | + print(" PASS: ping") |
| 132 | + passed += 1 |
| 133 | + |
| 134 | + # ── Test 8: Shutdown ───────────────────────────── |
| 135 | + res = await send_recv(proc, "shutdown", msg_id=8) |
| 136 | + |
| 137 | + result = res["result"] |
| 138 | + assert result["status"] == "shutdown", f"Shutdown failed: {result}" |
| 139 | + print(" PASS: shutdown") |
| 140 | + passed += 1 |
| 141 | + |
| 142 | + # Wait for process to exit |
| 143 | + await asyncio.wait_for(proc.wait(), timeout=3.0) |
| 144 | + assert proc.returncode == 0, f"Non-zero exit: {proc.returncode}" |
| 145 | + print(" PASS: clean exit") |
| 146 | + passed += 1 |
| 147 | + |
| 148 | + except Exception as e: |
| 149 | + print(f" FAIL: {e}") |
| 150 | + failed += 1 |
| 151 | + # Kill if still running |
| 152 | + if proc.returncode is None: |
| 153 | + proc.kill() |
| 154 | + await proc.wait() |
| 155 | + |
| 156 | + print(f"\n{'='*50}") |
| 157 | + print(f"E2E Results: {passed} passed, {failed} failed") |
| 158 | + print(f"{'='*50}") |
| 159 | + |
| 160 | + return 1 if failed > 0 else 0 |
| 161 | + |
| 162 | + |
| 163 | +if __name__ == "__main__": |
| 164 | + print("E2E Test: Python (flyto-core) <-> Node.js (@flyto/plugin-sdk)") |
| 165 | + print("="*50) |
| 166 | + exit_code = asyncio.run(run_tests()) |
| 167 | + sys.exit(exit_code) |
0 commit comments