| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { LineDecoder, OversizeFrameError, RpcClient, RpcError } from "./rpc.js"; |
| 4 | |
| 5 | function harness(limit?: number) { |
| 6 | const written: string[] = []; |
| 7 | const requests: Array<{ method: string; params: unknown }> = []; |
| 8 | const notifications: Array<{ method: string; params: unknown }> = []; |
| 9 | const errors: string[] = []; |
| 10 | const client = new RpcClient( |
| 11 | { write: (line) => written.push(line) }, |
| 12 | { |
| 13 | onRequest: async (method, params) => { |
| 14 | requests.push({ method, params }); |
| 15 | if (method === "host/fail") throw new RpcError(-32601, "nope"); |
| 16 | return { echoed: params }; |
| 17 | }, |
| 18 | onNotification: (method, params) => notifications.push({ method, params }), |
| 19 | onProtocolError: (kind) => errors.push(kind), |
| 20 | }, |
| 21 | limit, |
| 22 | ); |
| 23 | const feed = (text: string) => client.feed(Buffer.from(text)); |
| 24 | const lastFrame = () => JSON.parse(written[written.length - 1] as string) as Record<string, unknown>; |
| 25 | return { client, written, requests, notifications, errors, feed, lastFrame }; |
| 26 | } |
| 27 | |
| 28 | test("LineDecoder reassembles partial lines and strips CR", () => { |
| 29 | const decoder = new LineDecoder(); |
| 30 | assert.deepEqual(decoder.push(Buffer.from('{"a":')), []); |
| 31 | assert.deepEqual(decoder.push(Buffer.from('1}\r\n{"b":2}\n{"c"')), ['{"a":1}', '{"b":2}']); |
| 32 | assert.deepEqual(decoder.push(Buffer.from(":3}\n")), ['{"c":3}']); |
| 33 | }); |
| 34 | |
| 35 | test("LineDecoder rejects a line above the limit, with and without a newline", () => { |
| 36 | const decoder = new LineDecoder(8); |
| 37 | assert.throws(() => decoder.push(Buffer.from("0123456789\n")), OversizeFrameError); |
| 38 | assert.deepEqual(decoder.push(Buffer.from("ok\n")), ["ok"], "the decoder resynchronises after the failure"); |
| 39 | assert.throws(() => decoder.push(Buffer.from("0123456789")), OversizeFrameError); |
| 40 | }); |
| 41 | |
| 42 | test("responses resolve their own request regardless of arrival order", async () => { |
| 43 | const h = harness(); |
| 44 | const first = h.client.request("desktop/invoke", { method: "A" }); |
| 45 | const second = h.client.request("desktop/invoke", { method: "B" }); |
| 46 | assert.equal(h.written.length, 2); |
| 47 | h.feed('{"jsonrpc":"2.0","id":2,"result":"b"}\n{"jsonrpc":"2.0","id":1,"result":"a"}\n'); |
| 48 | assert.equal(await second, "b"); |
| 49 | assert.equal(await first, "a"); |
| 50 | assert.equal(h.client.pendingCount, 0); |
| 51 | }); |
| 52 | |
| 53 | test("non-JSON and non-2.0 lines are counted and ignored", () => { |
| 54 | const h = harness(); |
| 55 | h.feed("plain log line\n{\"id\":1,\"result\":true}\n\n"); |
| 56 | assert.equal(h.client.stats.ignoredLines, 2); |
| 57 | assert.deepEqual(h.errors, ["non-json", "invalid"]); |
| 58 | }); |
| 59 | |
| 60 | test("a response arriving after the timeout is ignored", async () => { |
| 61 | const h = harness(); |
| 62 | await assert.rejects(h.client.request("desktop/start", {}, 5), (error: unknown) => error instanceof RpcError && /timed out/.test(error.message)); |
| 63 | h.feed('{"jsonrpc":"2.0","id":1,"result":{}}\n'); |
| 64 | assert.equal(h.client.stats.orphanResponses, 1); |
| 65 | assert.deepEqual(h.errors, ["orphan-response"]); |
| 66 | }); |
| 67 | |
| 68 | test("error responses reject with the service's code and message", async () => { |
| 69 | const h = harness(); |
| 70 | const pending = h.client.request("desktop/hello", {}); |
| 71 | h.feed('{"jsonrpc":"2.0","id":1,"error":{"code":-32003,"message":"contract digest differs","data":{"x":1}}}\n'); |
| 72 | await assert.rejects(pending, (error: unknown) => error instanceof RpcError && error.code === -32003 && error.message === "contract digest differs"); |
| 73 | }); |
| 74 | |
| 75 | test("reverse requests are answered and notifications dispatched", async () => { |
| 76 | const h = harness(); |
| 77 | h.feed('{"jsonrpc":"2.0","id":"r1","method":"host/window.show","params":{"reason":"tray"}}\n'); |
| 78 | h.feed('{"jsonrpc":"2.0","method":"desktop/event","params":{"seq":1}}\n'); |
| 79 | await new Promise((resolve) => setImmediate(resolve)); |
| 80 | assert.deepEqual(h.requests, [{ method: "host/window.show", params: { reason: "tray" } }]); |
| 81 | assert.deepEqual(h.lastFrame(), { jsonrpc: "2.0", id: "r1", result: { echoed: { reason: "tray" } } }); |
| 82 | assert.deepEqual(h.notifications, [{ method: "desktop/event", params: { seq: 1 } }]); |
| 83 | h.feed('{"jsonrpc":"2.0","id":7,"method":"host/fail","params":{}}\n'); |
| 84 | await new Promise((resolve) => setImmediate(resolve)); |
| 85 | assert.deepEqual(h.lastFrame(), { jsonrpc: "2.0", id: 7, error: { code: -32601, message: "nope" } }); |
| 86 | }); |
| 87 | |
| 88 | test("an oversize frame closes the client and rejects everything in flight", async () => { |
| 89 | const h = harness(32); |
| 90 | const pending = h.client.request("desktop/invoke", {}); |
| 91 | assert.throws(() => h.feed("x".repeat(64) + "\n"), OversizeFrameError); |
| 92 | await assert.rejects(pending, OversizeFrameError); |
| 93 | assert.equal(h.client.closed, true); |
| 94 | await assert.rejects(h.client.request("desktop/invoke", {}), OversizeFrameError); |
| 95 | }); |
| 96 |