From a2fadf7e93c0b83c50294c05fa1684a0f84d428b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 23:30:39 +0200 Subject: [PATCH] test(harness): wsListener helper for capturing real WS events --- packages/server/package.json | 3 +- packages/server/test/helpers/wsListener.ts | 123 +++++++++++++++++++++ pnpm-lock.yaml | 11 +- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 packages/server/test/helpers/wsListener.ts diff --git a/packages/server/package.json b/packages/server/package.json index 6c4abf91..2f05295c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" } } diff --git a/packages/server/test/helpers/wsListener.ts b/packages/server/test/helpers/wsListener.ts new file mode 100644 index 00000000..d204fd47 --- /dev/null +++ b/packages/server/test/helpers/wsListener.ts @@ -0,0 +1,123 @@ +import WebSocket from 'ws'; + +export type WsEvent = Record & { type: string }; + +export interface WsCapture { + events: WsEvent[]; + waitForEvent(type: string, timeoutMs?: number): Promise; + 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 { + 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((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((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(); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4ed362..51804221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}