Skip to content

Commit 11a27fe

Browse files
authored
test: util — Rpc client protocol and abortAfter/abortAfterAny coverage (#382)
Add 11 new tests covering two previously untested utility modules that power TUI communication and tool timeout enforcement.
1 parent 9ddf7c6 commit 11a27fe

File tree

2 files changed

+166
-0
lines changed

2 files changed

+166
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { abortAfter, abortAfterAny } from "../../src/util/abort"
3+
4+
describe("abortAfter: timeout-based abort", () => {
5+
test("signal is not aborted immediately", () => {
6+
const { signal, clearTimeout } = abortAfter(5000)
7+
expect(signal.aborted).toBe(false)
8+
clearTimeout()
9+
})
10+
11+
test("signal aborts after timeout", async () => {
12+
const { signal } = abortAfter(10)
13+
// Event-driven: wait for the abort event instead of guessing a wall-clock delay
14+
await Promise.race([
15+
new Promise<void>((r) => signal.addEventListener("abort", () => r())),
16+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("abort did not fire within 500ms")), 500)),
17+
])
18+
expect(signal.aborted).toBe(true)
19+
})
20+
21+
test("clearTimeout prevents abort", async () => {
22+
// Use a very short timeout so waiting longer than it proves clearTimeout worked
23+
const { signal, clearTimeout: clear } = abortAfter(10)
24+
clear()
25+
// Wait well beyond the original timeout
26+
await new Promise((r) => setTimeout(r, 200))
27+
expect(signal.aborted).toBe(false)
28+
})
29+
})
30+
31+
describe("abortAfterAny: composite signal", () => {
32+
test("aborts when external signal fires before timeout", () => {
33+
const ext = new AbortController()
34+
const { signal, clearTimeout } = abortAfterAny(5000, ext.signal)
35+
expect(signal.aborted).toBe(false)
36+
ext.abort()
37+
expect(signal.aborted).toBe(true)
38+
clearTimeout()
39+
})
40+
41+
test("aborts on timeout when external signal is silent", async () => {
42+
const ext = new AbortController()
43+
const { signal } = abortAfterAny(10, ext.signal)
44+
// Event-driven: wait for the abort event
45+
await Promise.race([
46+
new Promise<void>((r) => signal.addEventListener("abort", () => r())),
47+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("abort did not fire within 500ms")), 500)),
48+
])
49+
expect(signal.aborted).toBe(true)
50+
})
51+
52+
test("combines multiple external signals", () => {
53+
const a = new AbortController()
54+
const b = new AbortController()
55+
const { signal, clearTimeout } = abortAfterAny(5000, a.signal, b.signal)
56+
b.abort()
57+
expect(signal.aborted).toBe(true)
58+
clearTimeout()
59+
})
60+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, test, expect, afterEach } from "bun:test"
2+
import { Rpc } from "../../src/util/rpc"
3+
4+
describe("Rpc: client protocol", () => {
5+
test("single call returns correct result via mock channel", async () => {
6+
// Create a target that simulates a server: when a request arrives,
7+
// compute the result and send it back through onmessage.
8+
const target: any = {
9+
onmessage: null,
10+
postMessage: (data: string) => {
11+
const parsed = JSON.parse(data)
12+
if (parsed.type === "rpc.request" && parsed.method === "add") {
13+
const result = parsed.input.a + parsed.input.b
14+
// Respond asynchronously (microtask) to mirror real Worker timing
15+
Promise.resolve().then(() => {
16+
target.onmessage!({
17+
data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }),
18+
} as MessageEvent)
19+
})
20+
}
21+
},
22+
}
23+
24+
const rpc = Rpc.client<{ add: (input: { a: number; b: number }) => number }>(target)
25+
const result = await rpc.call("add", { a: 3, b: 4 })
26+
expect(result).toBe(7)
27+
})
28+
29+
test("concurrent calls resolve to their own results (synchronous out-of-order)", async () => {
30+
// Accumulate requests and respond in reverse order synchronously
31+
const pending: Array<{ method: string; input: any; id: number }> = []
32+
const target: any = {
33+
onmessage: null,
34+
postMessage: (data: string) => {
35+
const parsed = JSON.parse(data)
36+
if (parsed.type === "rpc.request") {
37+
pending.push({ method: parsed.method, input: parsed.input, id: parsed.id })
38+
// After both requests arrive, respond in reverse order
39+
if (pending.length === 2) {
40+
// Second request resolves first, then first — no timers
41+
for (const req of [...pending].reverse()) {
42+
target.onmessage!({
43+
data: JSON.stringify({ type: "rpc.result", result: req.input, id: req.id }),
44+
} as MessageEvent)
45+
}
46+
}
47+
}
48+
},
49+
}
50+
51+
const rpc = Rpc.client<{ echo: (input: string) => string }>(target)
52+
const [r1, r2] = await Promise.all([rpc.call("echo", "first"), rpc.call("echo", "second")])
53+
expect(r1).toBe("first")
54+
expect(r2).toBe("second")
55+
})
56+
57+
test("event subscription delivers data and unsubscribe works", () => {
58+
const target: any = { onmessage: null, postMessage: () => {} }
59+
const rpc = Rpc.client(target)
60+
61+
const received: string[] = []
62+
const unsub = rpc.on<string>("status", (data) => received.push(data))
63+
64+
// Simulate two events
65+
target.onmessage!({ data: JSON.stringify({ type: "rpc.event", event: "status", data: "ok" }) } as MessageEvent)
66+
target.onmessage!({ data: JSON.stringify({ type: "rpc.event", event: "status", data: "done" }) } as MessageEvent)
67+
68+
expect(received).toEqual(["ok", "done"])
69+
70+
// Unsubscribe and send another event — should be ignored
71+
unsub()
72+
target.onmessage!({
73+
data: JSON.stringify({ type: "rpc.event", event: "status", data: "ignored" }),
74+
} as MessageEvent)
75+
expect(received).toEqual(["ok", "done"])
76+
})
77+
78+
test("unmatched result id is silently ignored", () => {
79+
const target: any = { onmessage: null, postMessage: () => {} }
80+
Rpc.client(target)
81+
82+
// Stale/duplicate result IDs must not throw
83+
expect(() => {
84+
target.onmessage!({
85+
data: JSON.stringify({ type: "rpc.result", result: "stale", id: 99999 }),
86+
} as MessageEvent)
87+
}).not.toThrow()
88+
})
89+
90+
test("multiple event listeners on the same event", () => {
91+
const target: any = { onmessage: null, postMessage: () => {} }
92+
const rpc = Rpc.client(target)
93+
94+
const a: number[] = []
95+
const b: number[] = []
96+
rpc.on<number>("tick", (v) => a.push(v))
97+
const unsub = rpc.on<number>("tick", (v) => b.push(v))
98+
99+
target.onmessage!({ data: JSON.stringify({ type: "rpc.event", event: "tick", data: 1 }) } as MessageEvent)
100+
unsub()
101+
target.onmessage!({ data: JSON.stringify({ type: "rpc.event", event: "tick", data: 2 }) } as MessageEvent)
102+
103+
expect(a).toEqual([1, 2])
104+
expect(b).toEqual([1])
105+
})
106+
})

0 commit comments

Comments
 (0)