// Runs the transport, the capture hook, and the popup event path against a REAL veloxd // — not FakeDaemon. Everything else in this suite is faithful to the documented wire // protocol, but "faithful" isn't "real"; this is what actually proves it. // // Requires VELOXD_BIN (path to a built veloxd) in the environment. Skips itself with a // clear message otherwise, so `npm test` and CI (no daemon binary lying around) are // unaffected. Run it like: // // VELOXD_BIN=/path/to/build/dev/bin/veloxd npx vitest run tests/live // // Each veloxd instance gets its own scratch XDG_RUNTIME_DIR/XDG_DATA_HOME/ // XDG_CONFIG_HOME/HOME (main.cpp's single-instance lock is keyed to the runtime dir, so // this can run alongside another developer's or CI's own veloxd on the same machine). // VELOX_PAIR_AUTO=1 stands in for the GUI's Allow-prompt approver during dev/test // (rpc/pairing.hpp's EnvAutoApprover) — pairing itself is exercised for real, only the // human click is stubbed. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WebSocket as WsClient } from 'ws'; import { RpcError, TransportClosedError } from '../../src/background/transport/types.js'; import { WebSocketTransport, type WebSocketCtor, type WebSocketTransportDeps } from '../../src/background/transport/websocket.js'; import { CaptureHook } from '../../src/background/capture/index.js'; import type { OnHeadersReceivedDetails } from '../../src/background/capture/index.js'; import type { CaptureRules, DownloadSpec, TaskProgressEvent, TaskStateEvent } from '../../src/shared/protocol/index.js'; const VELOXD_BIN = process.env.VELOXD_BIN; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const TESTSERVER_PY = join(REPO_ROOT, 'tools/testserver/testserver.py'); // A fixed moz-extension origin, used both as session.pair's extensionId and as the WS // upgrade's Origin header — real Firefox sets the latter itself; ws's client needs it // spelled out (docs/05 §4: the daemon refuses the upgrade without a moz-extension:// Origin). const EXTENSION_ID = '11111111-2222-3333-4444-555555555555'; const ORIGIN = `moz-extension://${EXTENSION_ID}`; class OriginWebSocket extends WsClient { constructor(url: string) { super(url, { origin: ORIGIN }); } } const CTOR = OriginWebSocket as unknown as WebSocketCtor; function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } async function waitFor(cond: () => Promise | boolean, timeoutMs: number, what: string): Promise { const deadline = Date.now() + timeoutMs; for (;;) { if (await cond()) return; if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); await sleep(50); } } interface VeloxdInstance { proc: ChildProcessWithoutNullStreams; scratch: string; wsPort: number; /** True once the process has actually exited, by signal or otherwise. Node only sets * `proc.exitCode` for a normal exit — a signal-killed process reports its death via * `signalCode` and an `exit` event instead, never a non-null `exitCode`. */ hasExited(): boolean; kill(signal?: NodeJS.Signals): void; } async function startVeloxd(bin: string): Promise { const scratch = mkdtempSync(join(tmpdir(), 'velox-live-')); const runtime = join(scratch, 'rt'); const data = join(scratch, 'data'); const config = join(scratch, 'cfg'); const home = join(scratch, 'home'); mkdirSync(runtime, { mode: 0o700 }); mkdirSync(data, { recursive: true }); mkdirSync(config, { recursive: true }); mkdirSync(join(home, 'Downloads'), { recursive: true }); const proc = spawn(bin, [], { env: { ...process.env, VELOX_PAIR_AUTO: '1', XDG_RUNTIME_DIR: runtime, XDG_DATA_HOME: data, XDG_CONFIG_HOME: config, HOME: home, }, }); let exited = false; proc.on('exit', () => { exited = true; }); let stderr = ''; proc.stderr.on('data', (d) => { stderr += String(d); }); const portFile = join(runtime, 'velox', 'ws.port'); try { await waitFor(async () => { if (exited) throw new Error(`veloxd exited early (code ${proc.exitCode}, signal ${proc.signalCode}): ${stderr}`); try { await readFile(portFile); return true; } catch { return false; } }, 10_000, 'veloxd to write ws.port'); } catch (e) { proc.kill('SIGKILL'); rmSync(scratch, { recursive: true, force: true }); throw e; } const wsPort = Number((await readFile(portFile, 'utf8')).trim()); return { proc, scratch, wsPort, hasExited: () => exited, kill(signal: NodeJS.Signals = 'SIGTERM') { proc.kill(signal); }, }; } interface TestServerInstance { proc: ChildProcessWithoutNullStreams; baseUrl: string; } async function startTestServer(): Promise { const proc = spawn('python3', [TESTSERVER_PY, '--port', '0'], {}); let stdout = ''; let port: number | null = null; proc.stdout.on('data', (d) => { stdout += String(d); const m = /^(\d+)\s*$/m.exec(stdout); if (m) port = Number(m[1]); }); await waitFor(() => port !== null, 5_000, 'testserver to print its port'); const baseUrl = `http://127.0.0.1:${port}`; await waitFor(async () => { try { const res = await fetch(`${baseUrl}/__health`); return res.ok; } catch { return false; } }, 5_000, 'testserver /__health'); return { proc, baseUrl }; } function memDeps(init: { token?: string | null } = {}): { deps: WebSocketTransportDeps; store: { token: string | null } } { const store = { token: init.token ?? null }; return { store, deps: { getToken: async () => store.token, setToken: async (t) => { store.token = t; }, getCachedPort: async () => null, setCachedPort: async () => undefined, extensionId: EXTENSION_ID, }, }; } function makeTransport(port: number, deps: WebSocketTransportDeps, extra: Partial = {}): WebSocketTransport { return new WebSocketTransport({ ...deps, ...extra, webSocketCtor: CTOR, portRange: { start: port, end: port }, openTimeoutMs: 2000, }); } const maybeDescribe = VELOXD_BIN ? describe : describe.skip; if (!VELOXD_BIN) { console.warn('tests/live/real-veloxd.test.ts: VELOXD_BIN not set — skipping (see file header).'); } maybeDescribe('WebSocketTransport against a real veloxd', () => { let daemon: VeloxdInstance; let testserver: TestServerInstance; // The mid-test fail-open case kills `daemon` and a later test starts a replacement — // every scratch dir that ever existed gets cleaned up here, not just the last one. const allScratchDirs: string[] = []; async function freshVeloxd(): Promise { const d = await startVeloxd(VELOXD_BIN!); allScratchDirs.push(d.scratch); return d; } beforeAll(async () => { daemon = await freshVeloxd(); testserver = await startTestServer(); }, 20_000); afterAll(() => { daemon?.kill('SIGKILL'); testserver?.proc.kill('SIGKILL'); for (const dir of allScratchDirs) rmSync(dir, { recursive: true, force: true }); }); it('session.hello without a token surfaces NotPaired / needsPairing', async () => { const { deps } = memDeps(); const t = makeTransport(daemon.wsPort, deps, { autoPair: false }); await expect(t.connect()).rejects.toBeInstanceOf(RpcError); expect(t.status.needsPairing).toBe(true); t.disconnect(); }); let pairedToken: string; it('pairs (VELOX_PAIR_AUTO=1 stands in for the human Allow click) and hellos with the issued token', async () => { const { deps, store } = memDeps(); const t = makeTransport(daemon.wsPort, deps); // autoPair: true (default) await t.connect(); expect(t.state).toBe('connected'); expect(t.status.daemonVersion).toBeTruthy(); expect(store.token).toBeTruthy(); pairedToken = store.token!; t.disconnect(); }); it('the pairing token survives a reconnect: a fresh transport reuses it with no fresh pairing', async () => { const { deps } = memDeps({ token: pairedToken }); // autoPair: false — if this succeeds at all, it can only be because the stored // token from the previous test was accepted outright, not because this transport // silently re-paired. const t = makeTransport(daemon.wsPort, deps, { autoPair: false }); await t.connect(); expect(t.state).toBe('connected'); t.disconnect(); }); it('a wrong token is rejected, and repeating it rate-limits the next pairing attempt', async () => { // Five failed session.hello attempts from this origin (ws_server.cpp records a // rate-limiter failure on every not-paired hello, not only on a failed session.pair) // exhausts the window; the sixth thing this origin tries — a pairing attempt — gets // RateLimited rather than a fresh token. for (let i = 0; i < 5; i += 1) { const { deps } = memDeps({ token: 'not-the-real-token' }); const t = makeTransport(daemon.wsPort, deps, { autoPair: false }); await expect(t.connect()).rejects.toBeInstanceOf(RpcError); t.disconnect(); } const { deps } = memDeps(); // no token -> autoPair kicks in -> session.pair const t = makeTransport(daemon.wsPort, deps); await expect(t.connect()).rejects.toBeInstanceOf(RpcError); expect(t.status.needsPairing).toBe(true); expect(t.status.retryAfterSec).toBeGreaterThan(0); t.disconnect(); }); it('download.add creates a real task the engine picks up', async () => { const { deps } = memDeps({ token: pairedToken }); const t = makeTransport(daemon.wsPort, deps, { autoPair: false }); await t.connect(); try { const spec: DownloadSpec = { url: `${testserver.baseUrl}/plain/file/64K`, filename: 'plain-download.bin' }; const added = await t.call('download.add', spec); expect(added.taskId).toBeTruthy(); await waitFor(async () => { const detail = await t.call('download.get', { taskId: added.taskId }); const state = detail.summary.state; return state === 'complete' || state === 'downloading' || state === 'verifying'; }, 10_000, 'the task to leave the queued state'); } finally { t.disconnect(); } }, 15_000); it('capture.offer end to end: the real capture path takes a monitored download, ignores its own duplicate, and fails open when the daemon dies mid-offer', async () => { const { deps } = memDeps({ token: pairedToken }); const t = makeTransport(daemon.wsPort, deps, { autoPair: false }); await t.connect(); const rules: CaptureRules = await t.call('capture.getRules', {}); expect(rules.monitoredExtensions).toContain('zip'); // seeded default (0001_initial.sql) const hook = new CaptureHook({ offer: (params, opts) => t.call('capture.offer', params, opts), stash: { take: () => undefined, peek: () => undefined }, getCookies: async () => [], getRules: () => rules, origin: ORIGIN, }); // A throttled URL so the task the first offer creates is still active (not yet // complete) when the dedupe offer for the same URL follows immediately after. const url = `${testserver.baseUrl}/throttled/file/512K`; const details: OnHeadersReceivedDetails = { requestId: 'live-1', url, method: 'GET', type: 'other', statusCode: 200, tabId: 1, responseHeaders: [{ name: 'content-disposition', value: 'attachment; filename="live-capture.zip"' }], }; const first = await hook.handle(details); expect(first).toEqual({ cancel: true }); // the daemon took it — Firefox never starts its own download const list = await t.call('download.list', { filter: { query: 'live-capture' } }); expect(list.items.length).toBeGreaterThan(0); const task = list.items[0]!; expect(task.categoryId).toBe('programs'); // "zip" routes to the built-in Programs category expect(task.saveDir).toContain('Downloads/Programs'); // Same URL again, task still active: the daemon's own dedupe (has_active_duplicate) // says Ignore, so the hook proceeds instead of cancelling a second time. const dup = await hook.handle({ ...details, requestId: 'live-2' }); expect(dup).toEqual({}); // Now kill the daemon mid-offer and prove fail-open holds against the REAL binary, // not just FakeDaemon: the hook must still resolve to {} (Firefox downloads // normally) well inside its own 750 ms budget. daemon.kill('SIGKILL'); await waitFor(() => daemon.hasExited(), 5_000, 'veloxd to actually die'); const started = Date.now(); const afterDeath = await hook.handle({ ...details, requestId: 'live-3', url: `${url}?after-death=1` }); const elapsedMs = Date.now() - started; expect(afterDeath).toEqual({}); // fail open — never {cancel: true} with a dead daemon expect(elapsedMs).toBeLessThan(900); // budget is 750ms; the hook's own timer bounds this t.disconnect(); }, 20_000); it('event.task.progress reaches a subscribed client (the popup\'s own path)', async () => { if (daemon.hasExited()) { // The previous test kills the daemon on purpose to prove fail-open; start a fresh // one so this test still exercises the real event path end to end. daemon = await freshVeloxd(); } const { deps } = memDeps(); const t = makeTransport(daemon.wsPort, deps); // fresh daemon instance -> fresh pairing await t.connect(); try { await t.call('session.subscribe', { events: ['event.task.added', 'event.task.state', 'event.task.progress'], }); const progressEvents: TaskProgressEvent[] = []; const stateEvents: TaskStateEvent[] = []; t.on('event.task.progress', (p) => progressEvents.push(p as TaskProgressEvent)); t.on('event.task.state', (p) => stateEvents.push(p as TaskStateEvent)); const spec: DownloadSpec = { url: `${testserver.baseUrl}/throttled/file/1M`, filename: 'progress-check.bin' }; const added = await t.call('download.add', spec); // /throttled defaults to 1 MiB/s, so a 1 MiB file takes ~1s — long enough that at // least one 4 Hz progress tick (event.task.progress's documented cap) lands before // it completes, exactly the path popup/store.ts consumes in the real extension. await waitFor( () => progressEvents.some((e) => e.tasks.some((row) => row.taskId === added.taskId)), 8_000, 'a live event.task.progress tick for our task', ); expect(stateEvents.some((e) => e.taskId === added.taskId)).toBe(true); } finally { t.disconnect(); } }, 15_000); it('fail-open also holds through the transport itself: a call against a dead socket rejects, never hangs past its deadline', async () => { const { deps } = memDeps(); const t = makeTransport(daemon.wsPort, deps); await t.connect(); t.disconnect(); // closes the socket without telling the daemon anything is wrong await expect(t.call('capture.offer', { url: 'https://example.com/x.zip', method: 'GET', tabUrl: '' }, { timeoutMs: 200 })).rejects.toBeInstanceOf( TransportClosedError, ); }); });