Skip to content

Commit 2619963

Browse files
committed
test(replay): Add WorkerHandler unit tests
1 parent 08a0812 commit 2619963

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { describe, expect, it } from 'vitest';
6+
import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler';
7+
import type { WorkerResponse } from '../../../src/types';
8+
9+
/**
10+
* Minimal Worker stub that lets tests control when responses dispatch and
11+
* track how many 'message' listeners are attached at any time. Real workers
12+
* are async; we model that with a queue we drain manually so the test can
13+
* assert on the listener count while requests are in flight.
14+
*/
15+
class MockWorker implements Pick<Worker, 'addEventListener' | 'removeEventListener' | 'postMessage' | 'terminate'> {
16+
public listenerCount = 0;
17+
public terminated = false;
18+
19+
private _listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
20+
private _pendingRequests: Array<{ id: number; method: string }> = [];
21+
22+
public addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
23+
if (!this._listeners.has(type)) this._listeners.set(type, new Set());
24+
this._listeners.get(type)!.add(listener);
25+
if (type === 'message') this.listenerCount++;
26+
}
27+
28+
public removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
29+
const set = this._listeners.get(type);
30+
if (set?.delete(listener) && type === 'message') this.listenerCount--;
31+
}
32+
33+
public postMessage(data: unknown): void {
34+
const { id, method } = data as { id: number; method: string };
35+
this._pendingRequests.push({ id, method });
36+
}
37+
38+
public terminate(): void {
39+
this.terminated = true;
40+
}
41+
42+
/** Dispatch the queued response for a given id (FIFO order otherwise). */
43+
public flushOne(overrides?: Partial<WorkerResponse>): void {
44+
const next = this._pendingRequests.shift();
45+
if (!next) return;
46+
const response: WorkerResponse = {
47+
id: next.id,
48+
method: next.method,
49+
success: true,
50+
response: `result-${next.id}`,
51+
...overrides,
52+
};
53+
this._dispatch('message', { data: response } as MessageEvent);
54+
}
55+
56+
public flushAll(): void {
57+
while (this._pendingRequests.length > 0) this.flushOne();
58+
}
59+
60+
public get pendingCount(): number {
61+
return this._pendingRequests.length;
62+
}
63+
64+
private _dispatch(type: string, event: MessageEvent): void {
65+
const set = this._listeners.get(type);
66+
if (!set) return;
67+
for (const listener of set) {
68+
if (typeof listener === 'function') listener(event);
69+
else listener.handleEvent(event);
70+
}
71+
}
72+
}
73+
74+
const makeHandler = () => {
75+
const worker = new MockWorker();
76+
const handler = new WorkerHandler(worker as unknown as Worker);
77+
return { worker, handler };
78+
};
79+
80+
describe('Unit | eventBuffer | WorkerHandler', () => {
81+
it('does not attach a new message listener per postMessage call (regression: #20547)', async () => {
82+
const { worker, handler } = makeHandler();
83+
84+
// One listener is attached at construction time.
85+
expect(worker.listenerCount).toBe(1);
86+
87+
// Fire a burst of in-flight requests. The pre-fix implementation attached
88+
// one listener per call, growing linearly; this would dispatch every
89+
// response to all attached listeners (O(n^2) main-thread work).
90+
const promises = Array.from({ length: 100 }, (_, i) => handler.postMessage('addEvent', `arg-${i}`));
91+
92+
expect(worker.listenerCount).toBe(1);
93+
expect(worker.pendingCount).toBe(100);
94+
95+
worker.flushAll();
96+
await Promise.all(promises);
97+
98+
// Listener count is still 1 after the burst drains.
99+
expect(worker.listenerCount).toBe(1);
100+
});
101+
102+
it('resolves concurrent postMessage calls with the correct response per id', async () => {
103+
const { worker, handler } = makeHandler();
104+
105+
const p0 = handler.postMessage<string>('addEvent', 'a');
106+
const p1 = handler.postMessage<string>('addEvent', 'b');
107+
const p2 = handler.postMessage<string>('addEvent', 'c');
108+
109+
worker.flushAll();
110+
111+
await expect(p0).resolves.toBe('result-0');
112+
await expect(p1).resolves.toBe('result-1');
113+
await expect(p2).resolves.toBe('result-2');
114+
});
115+
116+
it('rejects when the worker reports success: false', async () => {
117+
const { worker, handler } = makeHandler();
118+
119+
const promise = handler.postMessage('addEvent', 'a');
120+
worker.flushOne({ success: false, response: 'boom' });
121+
122+
await expect(promise).rejects.toThrow('Error in compression worker');
123+
});
124+
125+
it('destroy() rejects pending requests and detaches the listener', async () => {
126+
const { worker, handler } = makeHandler();
127+
128+
const p1 = handler.postMessage('addEvent', 'a');
129+
const p2 = handler.postMessage('addEvent', 'b');
130+
131+
handler.destroy();
132+
133+
await expect(p1).rejects.toThrow('Worker destroyed');
134+
await expect(p2).rejects.toThrow('Worker destroyed');
135+
expect(worker.terminated).toBe(true);
136+
expect(worker.listenerCount).toBe(0);
137+
});
138+
});

0 commit comments

Comments
 (0)