返回 DeepSeek-Reasonix
markdown-worker-client.test.ts
根目录 / desktop / frontend / src / __tests__ / markdown-worker-client.test.ts
1 // Run: tsx src/__tests__/markdown-worker-client.test.ts
2 //
3 // MarkdownWorkerClient protocol behavior: request ids, cancellation (stale
4 // responses dropped), in-process fallback when Worker is unavailable, dispose
5 // semantics, and pending-map hygiene across many cycles. Runs without a DOM:
6 // Node has no Worker global, which also exercises the fallback branch.
7
8 import {
9 MarkdownWorkerClient,
10 type MarkdownParseResponse,
11 type MarkdownWorkerLike,
12 } from "../lib/markdownWorkerClient";
13 import type { MarkdownBlock, MarkdownParseResult } from "../lib/markdownPipeline";
14 import type { MarkdownWorkerRequest } from "../lib/markdownWorkerProtocol";
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: unknown, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function eq(actual: unknown, expected: unknown, label: string) {
30 if (actual === expected) ok(true, label);
31 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
32 }
33
34 const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
35
36 // Node has no Worker global: the client only attempts worker creation when one
37 // exists. Stub it so worker-path tests reach the injected fake; the fallback
38 // test below deletes it again.
39 (globalThis as { Worker?: unknown }).Worker = class {};
40
41 /** Manual-response fake worker: tests decide when (or whether) it answers. */
42 class FakeWorker implements MarkdownWorkerLike {
43 onmessage: ((event: MessageEvent<MarkdownParseResponse>) => void) | null = null;
44 onerror: ((event: ErrorEvent) => void) | null = null;
45 sent: MarkdownWorkerRequest[] = [];
46 terminated = 0;
47 postMessage(request: MarkdownWorkerRequest): void {
48 this.sent.push(request);
49 }
50 respond(id: number, result: MarkdownParseResult): void {
51 this.onmessage?.({ data: { id, result } } as MessageEvent<MarkdownParseResponse>);
52 }
53 fail(id: number, error: string): void {
54 this.onmessage?.({ data: { id, error } } as MessageEvent<MarkdownParseResponse>);
55 }
56 crash(): void {
57 // Node 24 does not expose the browser ErrorEvent constructor. The client
58 // intentionally treats worker.onerror as a signal and does not inspect it.
59 this.onerror?.({ type: "error" } as ErrorEvent);
60 }
61 terminate(): void {
62 this.terminated += 1;
63 }
64 }
65
66 function sentAt(worker: FakeWorker, index: number): MarkdownWorkerRequest {
67 const request = worker.sent[index];
68 if (!request) throw new Error(`missing worker request ${index}`);
69 return request;
70 }
71
72 const BLOCKS: MarkdownBlock[] = [{ key: "b0", fingerprint: 1, children: [{ type: "text", value: "hi" }] }];
73 const RESULT: MarkdownParseResult = { blocks: BLOCKS, selectionText: "hi", selectionRevision: 1 };
74
75 console.log("\nmarkdown worker client");
76
77 // ── happy path over a fake worker ────────────────────────────────────────────
78 {
79 const worker = new FakeWorker();
80 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
81 const handle = client.parse("hello");
82 await tick();
83 eq(worker.sent.length, 1, "request posted to the worker");
84 worker.respond(worker.sent[0].id, RESULT);
85 const result = await handle.promise;
86 eq(result, RESULT, "response resolves with the full parse result");
87 eq(client.pendingCount, 0, "pending map drains after a response");
88 }
89
90 // ── retained document protocol avoids full-source transfer and worker churn ─
91 {
92 const worker = new FakeWorker();
93 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
94 const open = client.parseDocument("doc-a", "hello", { priority: "interactive" });
95 await tick();
96 eq(sentAt(worker, 0).op, "open", "first document parse opens worker state");
97 worker.respond(sentAt(worker, 0).id, RESULT);
98 await open.promise;
99
100 const append = client.parseDocument("doc-a", "hello world", { priority: "interactive" });
101 await tick();
102 const appendRequest = sentAt(worker, 1);
103 eq(appendRequest.op, "append", "prefix growth uses append protocol");
104 eq("text" in appendRequest ? appendRequest.text : undefined, " world", "append transfers only the new suffix");
105 worker.respond(appendRequest.id, RESULT);
106 await append.promise;
107
108 const replace = client.parseDocument("doc-a", "reset", { priority: "visible" });
109 await tick();
110 eq(sentAt(worker, 2).op, "replace", "non-prefix edits replace worker state");
111 worker.respond(sentAt(worker, 2).id, RESULT);
112 await replace.promise;
113
114 const finalize = client.parseDocument("doc-a", "reset!", { final: true });
115 await tick();
116 eq(sentAt(worker, 3).op, "finalize", "settled content finalizes with an authoritative snapshot");
117 worker.respond(sentAt(worker, 3).id, RESULT);
118 await finalize.promise;
119 client.releaseDocument("doc-a");
120 eq(sentAt(worker, 4).op, "release", "unmounted document releases worker-owned state");
121 }
122
123 // ── a superseded live document drops stale output without respawning worker ─
124 {
125 const worker = new FakeWorker();
126 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
127 const stale = client.parseDocument("doc-live", "one", { priority: "interactive" });
128 await tick();
129 const first = sentAt(worker, 0);
130 stale.cancel();
131 eq(await stale.promise, undefined, "superseded document parse settles quietly");
132 const current = client.parseDocument("doc-live", "one two", { priority: "interactive" });
133 eq(worker.terminated, 0, "superseding a live document keeps the worker alive");
134 worker.respond(first.id, RESULT);
135 await tick();
136 const second = sentAt(worker, 1);
137 eq(second.op, "append", "newest snapshot resumes from worker-owned prefix");
138 worker.respond(second.id, RESULT);
139 eq(await current.promise, RESULT, "newest live parse publishes normally");
140 }
141
142 // ── interactive work overtakes queued background history ───────────────────
143 {
144 const worker = new FakeWorker();
145 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
146 const active = client.parse("active");
147 await tick();
148 const background = client.parseDocument("doc-bg", "background", { priority: "background" });
149 const interactive = client.parseDocument("doc-live", "interactive", { priority: "interactive" });
150 worker.respond(sentAt(worker, 0).id, RESULT);
151 await active.promise;
152 await tick();
153 const prioritized = sentAt(worker, 1);
154 eq("documentId" in prioritized ? prioritized.documentId : undefined, "doc-live", "interactive document overtakes background parse");
155 worker.respond(prioritized.id, RESULT);
156 await interactive.promise;
157 await tick();
158 worker.respond(sentAt(worker, 2).id, RESULT);
159 await background.promise;
160 }
161
162 // ── cancellation drops the stale response ────────────────────────────────────
163 {
164 const worker = new FakeWorker();
165 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
166 const handle = client.parse("stale");
167 await tick();
168 handle.cancel();
169 const cancelled = await handle.promise;
170 eq(cancelled, undefined, "cancelled parse resolves undefined");
171 eq(worker.terminated, 1, "cancelling active work terminates the stale parser");
172 worker.respond(worker.sent[0].id, RESULT); // late response for a dead id
173 await tick();
174 eq(client.pendingCount, 0, "late response for a cancelled id is dropped");
175 }
176
177 // ── cancel while the worker chunk is still loading ───────────────────────────
178 {
179 let releaseWorker: (worker: FakeWorker) => void = () => {};
180 const workerPromise = new Promise<FakeWorker>((resolve) => {
181 releaseWorker = resolve;
182 });
183 const client = new MarkdownWorkerClient({ createWorker: () => workerPromise });
184 const handle = client.parse("early cancel");
185 handle.cancel();
186 releaseWorker(new FakeWorker());
187 eq(await handle.promise, undefined, "parse cancelled during worker startup resolves undefined");
188 await tick();
189 eq(client.pendingCount, 0, "no pending entry survives an early cancel");
190 }
191
192 // ── worker error rejects (callers fall back main-thread) ─────────────────────
193 {
194 const worker = new FakeWorker();
195 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
196 const handle = client.parse("boom");
197 await tick();
198 worker.fail(worker.sent[0].id, "parse exploded");
199 const message = await handle.promise.then(
200 () => "resolved",
201 (error: Error) => error.message,
202 );
203 eq(message, "parse exploded", "worker parse error rejects with its message");
204 eq(client.pendingCount, 0, "pending map drains after an error");
205 }
206
207 // ── worker crash rejects stranded requests, next parse retries ───────────────
208 {
209 const worker = new FakeWorker();
210 let creations = 0;
211 const client = new MarkdownWorkerClient({
212 createWorker: () => {
213 creations += 1;
214 return Promise.resolve(worker);
215 },
216 });
217 const handle = client.parse("stranded");
218 await tick();
219 worker.crash();
220 const message = await handle.promise.then(
221 () => "resolved",
222 (error: Error) => error.message,
223 );
224 eq(message, "markdown worker failed", "crashed worker rejects stranded requests");
225 const retry = client.parse("again");
226 await tick();
227 eq(creations, 2, "a fresh parse recreates the worker after a crash");
228 worker.respond(worker.sent[worker.sent.length - 1].id, RESULT);
229 eq(await retry.promise, RESULT, "recreated worker serves the retry");
230 }
231
232 // ── in-process fallback when Worker is unavailable ───────────────────────────
233 {
234 delete (globalThis as { Worker?: unknown }).Worker;
235 const seen: string[] = [];
236 const client = new MarkdownWorkerClient({
237 parseInProcess: (text) => {
238 seen.push(text);
239 return RESULT;
240 },
241 });
242 const blocks = await client.parse("fallback text").promise;
243 eq(blocks, RESULT, "fallback resolves the parse result without a Worker");
244 eq(seen.join(","), "fallback text", "fallback receives the exact source text");
245 (globalThis as { Worker?: unknown }).Worker = class {};
246 }
247
248 // ── dispose settles pending work and terminates the worker ───────────────────
249 {
250 const worker = new FakeWorker();
251 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
252 const first = client.parse("one");
253 const second = client.parse("two");
254 await tick();
255 client.dispose();
256 eq(await first.promise, undefined, "dispose settles a pending request (1)");
257 eq(await second.promise, undefined, "dispose settles a pending request (2)");
258 eq(worker.terminated, 1, "dispose terminates the worker");
259 eq(client.pendingCount, 0, "dispose drains the pending map");
260 const after = await client.parse("post-dispose").promise;
261 eq(after, undefined, "parse after dispose resolves undefined immediately");
262 }
263
264 // ── pending-map hygiene across 100 cycles ────────────────────────────────────
265 {
266 const worker = new FakeWorker();
267 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
268 const unhandled: unknown[] = [];
269 const onUnhandled = (reason: unknown) => unhandled.push(reason);
270 process.on("unhandledRejection", onUnhandled);
271 try {
272 for (let cycle = 0; cycle < 100; cycle += 1) {
273 const handle = client.parse(`cycle ${cycle}`);
274 await tick();
275 const id = worker.sent[worker.sent.length - 1].id;
276 if (cycle % 3 === 0) {
277 handle.cancel();
278 worker.respond(id, RESULT); // dropped
279 } else if (cycle % 3 === 1) {
280 worker.respond(id, RESULT);
281 } else {
282 worker.fail(id, `error ${cycle}`);
283 }
284 await handle.promise.catch(() => {});
285 }
286 await tick();
287 eq(client.pendingCount, 0, "no pending-request growth across 100 mixed cycles");
288 eq(unhandled.length, 0, "no unhandled rejections across 100 mixed cycles");
289 eq(worker.sent.length, 100, "every cycle issued exactly one request");
290 } finally {
291 process.off("unhandledRejection", onUnhandled);
292 }
293 }
294
295 // ── request ids are monotonic ────────────────────────────────────────────────
296 {
297 const worker = new FakeWorker();
298 const client = new MarkdownWorkerClient({ createWorker: () => Promise.resolve(worker) });
299 const a = client.parse("a");
300 const b = client.parse("b");
301 await tick();
302 eq(worker.sent.length, 1, "worker queue runs only one parse at a time");
303 worker.respond(worker.sent[0].id, RESULT);
304 await a.promise;
305 await tick();
306 ok(worker.sent[1].id > worker.sent[0].id, "request ids increase monotonically");
307 client.dispose();
308 await b.promise;
309 }
310
311 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
312 if (failed > 0) process.exit(1);
313
313 lines TYPESCRIPT