main.cpp's single-instance lock is now keyed to the runtime directory
instead of the euid, so an isolated XDG_RUNTIME_DIR/XDG_DATA_HOME/
XDG_CONFIG_HOME/HOME gets its own veloxd alongside anyone else's.
tests/live/real-veloxd.test.ts spawns one (VELOX_PAIR_AUTO=1 standing
in for the GUI's Allow click) plus tools/testserver/testserver.py, and
exercises the real WebSocketTransport end to end: session.hello,
pairing, the token surviving a reconnect, a wrong token rejected and
then rate-limiting the next pairing attempt, download.add reaching a
real running task, the real capture.offer path (rules table + category
folder + dedupe) taking a monitored download and ignoring its own
duplicate, and fail-open proven by SIGKILLing the daemon mid-offer —
the hook still resolves to {} inside its 750ms budget. A last case
proves fail-open at the transport layer too: a call against a closed
socket rejects instead of hanging.
Guarded behind so it skips itself (with a clear message)
when no daemon binary is around — npm test and CI are unaffected;
run it with VELOXD_BIN=/path/to/veloxd npx vitest run tests/live.
Real-daemon testing found one actual bug, fixed here: background/
index.ts never called session.subscribe, so event.task.progress and
friends never reached this connection at all — FakeDaemon's tests
never caught it because FakeDaemon broadcasts regardless of
subscription state. Now subscribed to the full event set on every
connect (first connect and every reconnect), which is what the popup's
live-progress path actually depends on against a real daemon.
Per instructions: ctest -L conformance was not run (pending PROTO's
fixture fix).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
// 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<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
async function waitFor(cond: () => Promise<boolean> | boolean, timeoutMs: number, what: string): Promise<void> {
|
|
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<VeloxdInstance> {
|
|
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<TestServerInstance> {
|
|
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<WebSocketTransportDeps> = {}): 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<VeloxdInstance> {
|
|
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,
|
|
);
|
|
});
|
|
});
|