📄 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