📄 src/downloader/test/worker-client.test.ts
D-OPEN SOVEREIGN
1import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
2import { DedicatedThread, rpcRequestKey, createFileThread, createMuThread } from '../client/worker-client';
3import type { WorkerMessage } from '../worker-protocol';
4
5class FakeWorker {
6 static instances: FakeWorker[] = [];
7 onmessage: ((e: MessageEvent) => void) | null = null;
8 onerror: ((e: unknown) => void) | null = null;
9 posted: unknown[] = [];
10 scriptUrl: string;
11
12 constructor(scriptUrl: string, _opts?: unknown) {
13 this.scriptUrl = scriptUrl;
14 FakeWorker.instances.push(this);
15 }
16
17 postMessage(msg: unknown): void {
18 this.posted.push(msg);
19 }
20
21 // Test helper — simulate the worker posting a message back to the main thread.
22 emit(msg: WorkerMessage): void {
23 this.onmessage?.({ data: msg } as MessageEvent);
24 }
25}
26
27beforeEach(() => {
28 FakeWorker.instances = [];
29 // @ts-expect-error test stub replacing the Worker global
30 globalThis.Worker = FakeWorker;
31});
32
33afterEach(() => {
34 vi.useRealTimers();
35});
36
37describe('rpcRequestKey', () => {
38 it('keys on txId:mime for GetFile-style commands', () => {
39 expect(rpcRequestKey({ type: 'GetFile', txId: 'tx1', mime: 'pdf' } as any)).toBe('tx1:pdf');
40 });
41
42 it('keys on txId alone for commands without a mime', () => {
43 expect(rpcRequestKey({ type: 'CancelDownload', txId: 'tx1' } as any)).toBe('tx1');
44 });
45});
46
47describe('DedicatedThread', () => {
48 it('constructs a module Worker at the given script URL', () => {
49 new DedicatedThread('/file-worker-v2.js');
50 expect(FakeWorker.instances[0].scriptUrl).toBe('/file-worker-v2.js');
51 });
52
53 it('send() posts the command to the underlying worker', () => {
54 const thread = new DedicatedThread('/w.js');
55 thread.send({ type: 'CancelDownload', txId: 'tx1' });
56 expect(FakeWorker.instances[0].posted).toEqual([{ type: 'CancelDownload', txId: 'tx1' }]);
57 });
58
59 it('listen() delivers non-rpc messages to registered handlers', () => {
60 const thread = new DedicatedThread('/w.js');
61 const received: WorkerMessage[] = [];
62 thread.listen(msg => received.push(msg));
63
64 const progressMsg: WorkerMessage = { type: 'DownloadProgress', txId: 'tx1', loaded: 5, total: 10 };
65 FakeWorker.instances[0].emit(progressMsg);
66
67 expect(received).toEqual([progressMsg]);
68 });
69
70 it('unlisten() removes a previously registered handler', () => {
71 const thread = new DedicatedThread('/w.js');
72 const cb = vi.fn();
73 thread.listen(cb);
74 thread.unlisten(cb);
75 FakeWorker.instances[0].emit({ type: 'DownloadProgress', txId: 'tx1', loaded: 1, total: 2 });
76 expect(cb).not.toHaveBeenCalled();
77 });
78
79 it('rpc() resolves when a matching FileRpcResponse arrives', async () => {
80 const thread = new DedicatedThread('/w.js');
81 const pending = thread.rpc({ type: 'GetFile', txId: 'tx1', mime: 'pdf' });
82
83 FakeWorker.instances[0].emit({ type: 'FileRpcResponse', ok: true, key: 'tx1:pdf' });
84
85 await expect(pending).resolves.toEqual({ ok: true, key: 'tx1:pdf' });
86 });
87
88 it('rpc() does not resolve on an unrelated key, and resolves once the matching one arrives', async () => {
89 const thread = new DedicatedThread('/w.js');
90 const pending = thread.rpc({ type: 'GetFile', txId: 'tx1', mime: 'pdf' });
91
92 FakeWorker.instances[0].emit({ type: 'FileRpcResponse', ok: true, key: 'tx1:preview' });
93 FakeWorker.instances[0].emit({ type: 'FileRpcResponse', ok: false, key: 'tx1:pdf', error: 'boom' });
94
95 await expect(pending).resolves.toEqual({ ok: false, key: 'tx1:pdf', error: 'boom' });
96 });
97
98 it('rpc() rejects after the timeout if no FileRpcResponse ever arrives', async () => {
99 vi.useFakeTimers();
100 const thread = new DedicatedThread('/w.js');
101 const pending = thread.rpc({ type: 'GetFile', txId: 'tx1', mime: 'pdf' });
102 const assertion = expect(pending).rejects.toThrow(/rpc timeout/);
103 await vi.advanceTimersByTimeAsync(90_000);
104 await assertion;
105 });
106
107 it('two concurrent rpc()s for the same key resolve independently, FIFO', async () => {
108 const thread = new DedicatedThread('/w.js');
109 const first = thread.rpc({ type: 'GetFile', txId: 'tx1', mime: 'pdf' });
110 const second = thread.rpc({ type: 'GetFile', txId: 'tx1', mime: 'pdf' });
111
112 FakeWorker.instances[0].emit({ type: 'FileRpcResponse', ok: true, key: 'tx1:pdf' });
113 await expect(first).resolves.toEqual({ ok: true, key: 'tx1:pdf' });
114
115 FakeWorker.instances[0].emit({ type: 'FileRpcResponse', ok: false, key: 'tx1:pdf', error: 'later' });
116 await expect(second).resolves.toEqual({ ok: false, key: 'tx1:pdf', error: 'later' });
117 });
118});
119
120describe('createFileThread / createMuThread', () => {
121 it('createFileThread defaults to /file-worker-v2.js', () => {
122 createFileThread();
123 expect(FakeWorker.instances[0].scriptUrl).toBe('/file-worker-v2.js');
124 });
125
126 it('createMuThread defaults to /mu-worker-v2.js', () => {
127 createMuThread();
128 expect(FakeWorker.instances[0].scriptUrl).toBe('/mu-worker-v2.js');
129 });
130
131 it('both accept a scriptUrl override', () => {
132 createFileThread('/custom-file.js');
133 createMuThread('/custom-mu.js');
134 expect(FakeWorker.instances[0].scriptUrl).toBe('/custom-file.js');
135 expect(FakeWorker.instances[1].scriptUrl).toBe('/custom-mu.js');
136 });
137});
138