diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 9820628..1dcbc7f 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -19,7 +19,12 @@ import { } from './context-menus.js'; import { MediaBridge, notifyTab } from './media-bridge.js'; import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js'; -import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js'; +import { + SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES, + type CaptureOfferParams, + type CaptureRules, + type DownloadSpec, +} from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; let rules: CaptureRules = DEFAULT_CAPTURE_RULES; @@ -75,10 +80,31 @@ async function refreshRules(): Promise { } } +/** + * "Nothing is delivered until this is called" (session.subscribe's own description) — + * without it, event.task.progress et al. never reach this connection at all, no matter + * how many listeners bridge.ts registers locally. Requests the whole set every time + * because any popup/options document could open at any moment and none of them narrow + * per-tab; a fresh connection (first connect, or after a drop) starts with nothing + * subscribed until this runs again. + */ +async function subscribeToEvents(): Promise { + try { + await mustTransport().call('session.subscribe', { + events: [...SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES], + }); + } catch { + // Best-effort; a reconnect (or the next event.settings.changed-driven refresh) retries. + } +} + function onTransportState(status: TransportStatus): void { const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`); - if (status.state === 'connected') void refreshRules(); + if (status.state === 'connected') { + void refreshRules(); + void subscribeToEvents(); + } } async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise { diff --git a/extension/tests/live/real-veloxd.test.ts b/extension/tests/live/real-veloxd.test.ts new file mode 100644 index 0000000..bf49465 --- /dev/null +++ b/extension/tests/live/real-veloxd.test.ts @@ -0,0 +1,389 @@ +// 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, + ); + }); +});