// Test doubles for the transport suite: a real loopback JSON-RPC server for the // WebSocket transport, and a scriptable fake port for the native transport. import { WebSocketServer, type WebSocket } from 'ws'; import { PROTOCOL_VERSION } from '../../src/shared/protocol/index.js'; import type { NativePort } from '../../src/background/transport/native.js'; // --- a loopback veloxd, just enough of one ------------------------------------------ export interface FakeDaemonOptions { port: number; /** Token the daemon will accept on session.hello. null = accept none (forces pairing). */ acceptToken?: string | null; /** session.hello answers -32001 before anything else. */ versionMismatch?: boolean; /** session.pair answers this error instead of issuing a token. */ pairError?: { code: number; message: string; data?: unknown }; daemonVersion?: string; capabilities?: string[]; /** Don't answer these methods at all, to exercise client timeouts. */ blackhole?: string[]; } export class FakeDaemon { readonly wss: WebSocketServer; readonly sockets = new Set(); helloCount = 0; pairCount = 0; readonly seen: Array<{ method: string; params: unknown; token?: unknown }> = []; private issued = new Set(); private nextToken = 'tok-issued-1'; constructor(private readonly opts: FakeDaemonOptions) { if (opts.acceptToken) this.issued.add(opts.acceptToken); this.wss = new WebSocketServer({ host: '127.0.0.1', port: opts.port }); this.wss.on('connection', (ws) => { this.sockets.add(ws); ws.on('close', () => this.sockets.delete(ws)); ws.on('message', (raw) => this.onMessage(ws, String(raw))); }); } static async start(opts: FakeDaemonOptions): Promise { const d = new FakeDaemon(opts); await new Promise((r) => d.wss.once('listening', () => r())); return d; } private send(ws: WebSocket, obj: unknown): void { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj)); } private onMessage(ws: WebSocket, raw: string): void { let req: { id?: number; method?: string; params?: Record }; try { req = JSON.parse(raw); } catch { return; } const { id, method, params = {} } = req; if (typeof method !== 'string') return; this.seen.push({ method, params, token: params['token'] }); if (this.opts.blackhole?.includes(method)) return; const ok = (result: unknown): void => this.send(ws, { jsonrpc: '2.0', id, result }); const err = (code: number, message: string, data?: unknown): void => this.send(ws, { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } }); switch (method) { case 'session.hello': { this.helloCount += 1; if (this.opts.versionMismatch) { err(-32001, 'protocol major version mismatch', { expected: PROTOCOL_VERSION, actual: '9.0.0' }); return; } const token = params['token']; if (typeof token !== 'string' || !this.issued.has(token)) { err(-32002, 'not paired: call session.pair first'); return; } ok({ daemonVersion: this.opts.daemonVersion ?? '1.2.3-fake', protocolVersion: PROTOCOL_VERSION, capabilities: this.opts.capabilities ?? ['media', 'grabber'], sessionId: 'sess-1', transport: 'ws', }); return; } case 'session.pair': { this.pairCount += 1; if (this.opts.pairError) { err(this.opts.pairError.code, this.opts.pairError.message, this.opts.pairError.data); return; } const t = this.nextToken; this.issued.add(t); ok({ token: t, expiresAt: null }); return; } case 'session.subscribe': ok({ ok: true, events: (params['events'] as string[]) ?? [] }); return; case 'download.list': ok({ total: 0, items: [] }); return; default: err(-32601, 'no such method'); } } /** Push a server-to-client notification to every open socket. */ notify(method: string, paramsObj: unknown): void { for (const ws of this.sockets) this.send(ws, { jsonrpc: '2.0', method, params: paramsObj }); } /** Hard-drop every current connection, to exercise reconnect. */ dropAll(): void { for (const ws of this.sockets) ws.terminate(); } async stop(): Promise { for (const ws of this.sockets) ws.terminate(); await new Promise((r) => this.wss.close(() => r())); } } /** A free-ish port inside a private band; each call is distinct within a run. */ let portCursor = 53100 + Math.floor(Math.random() * 300); export function nextPort(): number { portCursor += 1; return portCursor; } // --- scriptable native port ------------------------------------------------------- type Listener = (arg: unknown) => void; export class FakeNativePort implements NativePort { error: { message: string } | null = null; readonly sent: unknown[] = []; private msgListeners = new Set(); private discListeners = new Set(); disconnected = false; onMessage = { addListener: (cb: (m: unknown) => void) => this.msgListeners.add(cb as Listener), removeListener: (cb: (m: unknown) => void) => this.msgListeners.delete(cb as Listener), }; onDisconnect = { addListener: (cb: (p?: unknown) => void) => this.discListeners.add(cb as Listener), removeListener: (cb: (p?: unknown) => void) => this.discListeners.delete(cb as Listener), }; /** Auto-answers session.hello unless `autoHello` is false. */ constructor(private readonly opts: { autoHello?: boolean; helloError?: { code: number; message: string }; helloResult?: unknown } = {}) {} postMessage(message: unknown): void { this.sent.push(message); const req = message as { id?: number; method?: string }; if (req.method === 'session.hello' && this.opts.autoHello !== false && !this.disconnected) { queueMicrotask(() => { if (this.disconnected) return; if (this.opts.helloError) { this.emitMessage({ jsonrpc: '2.0', id: req.id, error: this.opts.helloError }); } else { this.emitMessage({ jsonrpc: '2.0', id: req.id, result: this.opts.helloResult ?? { daemonVersion: '1.2.3-nm', protocolVersion: PROTOCOL_VERSION, capabilities: ['media'], sessionId: 'nm-sess', transport: 'uds', }, }); } }); } } disconnect(): void { this.disconnected = true; } emitMessage(msg: unknown): void { for (const cb of this.msgListeners) cb(msg); } fireDisconnect(errorMessage?: string): void { this.disconnected = true; if (errorMessage) this.error = { message: errorMessage }; for (const cb of this.discListeners) cb(this); } } export const tick = (ms = 0): Promise => new Promise((r) => setTimeout(r, ms));