test(harness): wsListener helper for capturing real WS events

This commit is contained in:
Jannis Braun
2026-05-03 23:33:29 +02:00
parent e06b5fec3c
commit a2fadf7e93
3 changed files with 132 additions and 5 deletions
+2 -1
View File
@@ -41,6 +41,7 @@
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.24.0",
"typescript": "^5.4.0",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"ws": "^8.20.0"
}
}
+123
View File
@@ -0,0 +1,123 @@
import WebSocket from 'ws';
export type WsEvent = Record<string, unknown> & { type: string };
export interface WsCapture {
events: WsEvent[];
waitForEvent(type: string, timeoutMs?: number): Promise<WsEvent>;
send(event: WsEvent): void;
close(): void;
}
/**
* Connect to an instance's WS, authenticate with the given JWT, capture all
* incoming events into an array. waitForEvent resolves with the first matching
* event already in the buffer or arriving within `timeoutMs`.
*
* Auth handshake: server expects `{ type: 'auth', token }` as the first message
* (see packages/server/src/ws/handler.ts:1617). On success the server emits
* `{ type: 'ready', ...readyData }` (handler.ts:1661-1664).
*
* Events are flat objects — `{ type, ...fields }` — with no nested payload envelope.
* E.g. `member_left` arrives as `{ type: 'member_left', spaceId, userId }`
* (see routes/spaces.ts:977-981, routes/federation.ts:2127-2131).
*/
export async function connectWs(origin: string, token: string): Promise<WsCapture> {
const wsUrl = origin.replace(/^http/, 'ws') + '/ws';
const ws = new WebSocket(wsUrl);
const events: WsEvent[] = [];
let closed = false;
const waiters: {
type: string;
resolve: (e: WsEvent) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
}[] = [];
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);
ws.once('open', () => {
ws.off('error', onError);
ws.send(JSON.stringify({ type: 'auth', token }));
resolve();
});
ws.once('error', onError);
});
ws.on('message', (raw) => {
let msg: WsEvent | null = null;
try {
msg = JSON.parse(raw.toString()) as WsEvent;
} catch {
return;
}
if (!msg || typeof msg.type !== 'string') return;
events.push(msg);
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i].type === msg.type) {
clearTimeout(waiters[i].timer);
waiters[i].resolve(msg);
waiters.splice(i, 1);
}
}
});
// Wait for `ready` before returning so subsequent test code can be sure auth landed.
// The 'message' listener above already pushes into `events`, so we just need to
// observe the same stream here without double-processing.
await new Promise<void>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Timed out waiting for ws ready')), 5_000);
const checkExisting = events.find((e) => e.type === 'ready');
if (checkExisting) {
clearTimeout(t);
resolve();
return;
}
const onMessage = (raw: WebSocket.RawData) => {
try {
const msg = JSON.parse(raw.toString()) as WsEvent;
if (msg?.type === 'ready') {
clearTimeout(t);
ws.off('message', onMessage);
resolve();
} else if (msg?.type === 'error') {
clearTimeout(t);
ws.off('message', onMessage);
reject(new Error(`WS auth error: ${JSON.stringify(msg)}`));
}
} catch {
/* ignore */
}
};
ws.on('message', onMessage);
});
return {
events,
waitForEvent(type, timeoutMs = 5_000) {
if (closed) return Promise.reject(new Error('WS closed'));
const existing = events.find((e) => e.type === type);
if (existing) return Promise.resolve(existing);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const i = waiters.findIndex((w) => w.timer === timer);
if (i >= 0) waiters.splice(i, 1);
reject(new Error(`Timed out waiting for ws event "${type}" after ${timeoutMs}ms`));
}, timeoutMs);
waiters.push({ type, resolve, reject, timer });
});
},
send(event) {
ws.send(JSON.stringify(event));
},
close() {
closed = true;
for (const w of waiters) {
clearTimeout(w.timer);
w.reject(new Error('WS closed'));
}
waiters.length = 0;
ws.close();
},
};
}
+7 -4
View File
@@ -129,6 +129,9 @@ importers:
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@20.19.33)(jiti@1.21.7)(jsdom@28.1.0)(terser@5.46.0)(tsx@4.21.0)
ws:
specifier: ^8.20.0
version: 8.20.0
packages/shared:
devDependencies:
@@ -5491,8 +5494,8 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -6785,7 +6788,7 @@ snapshots:
dependencies:
duplexify: 4.1.3
fastify-plugin: 4.5.1
ws: 8.19.0
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@@ -11548,7 +11551,7 @@ snapshots:
wrappy@1.0.2: {}
ws@8.19.0: {}
ws@8.20.0: {}
xml-name-validator@5.0.0: {}