Compare commits
4
Commits
lane/daemon
...
lane/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c6e1f47d | ||
|
|
0468b0176a | ||
|
|
d8b7c128be | ||
|
|
e30d994d74 |
@@ -20,7 +20,7 @@
|
|||||||
],
|
],
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"url": "https://example.org/",
|
"url": "https://example.org/",
|
||||||
"categoryId": "compressed",
|
"categoryId": "programs",
|
||||||
"startMode": "queue",
|
"startMode": "queue",
|
||||||
"queueId": "main"
|
"queueId": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,6 @@
|
|||||||
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002",
|
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002",
|
||||||
"data.expected is the daemon's own current protocol version string (kProtocolVersion), not a bare major and not pinnable in a golden file -- the conformance compare on error payloads is on `code` only, structural elsewhere, so echoing the live version is fine"
|
"data.expected is the daemon's own current protocol version string (kProtocolVersion), not a bare major and not pinnable in a golden file -- the conformance compare on error payloads is on `code` only, structural elsewhere, so echoing the live version is fine"
|
||||||
],
|
],
|
||||||
"transport": "uds"
|
"transport": "uds",
|
||||||
|
"closesConnection": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
} from './context-menus.js';
|
} from './context-menus.js';
|
||||||
import { MediaBridge, notifyTab } from './media-bridge.js';
|
import { MediaBridge, notifyTab } from './media-bridge.js';
|
||||||
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.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 transport: VeloxTransport | undefined;
|
||||||
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
||||||
@@ -75,10 +80,31 @@ async function refreshRules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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<void> {
|
||||||
|
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 {
|
function onTransportState(status: TransportStatus): void {
|
||||||
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
||||||
console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`);
|
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<void> {
|
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,16 +40,26 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Kill the server and anything it spawned. `kill $!` alone would only reap the subshell
|
# Kill the server and anything it spawned. `pkill -P "$pid"` only reaps direct children —
|
||||||
# wrapper and leave the node process holding the port, which then breaks the next run.
|
# tsx's actual listener is often a grandchild, which that missed, leaving it holding the
|
||||||
|
# port and breaking the next run (a leaked mockd once did exactly this). Every server
|
||||||
|
# below is launched via `setsid`, which makes it the leader of its own new session/process
|
||||||
|
# group (pgid == its own pid), so `kill -TERM -"$pid"` (negative: a process-group kill)
|
||||||
|
# reaches it and everything it spawned in one shot, however deep.
|
||||||
stop() {
|
stop() {
|
||||||
local pid="$1"
|
local pid="$1"
|
||||||
[ -n "$pid" ] || return 0
|
[ -n "$pid" ] || return 0
|
||||||
pkill -P "$pid" 2>/dev/null || true
|
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
||||||
kill "$pid" 2>/dev/null || true
|
|
||||||
wait "$pid" 2>/dev/null || true
|
wait "$pid" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A free loopback TCP port, kernel-assigned (bind :0) rather than a fixed number — a
|
||||||
|
# hardcoded port means one leaked process from a previous run makes every future run fail
|
||||||
|
# EADDRINUSE instead of just picking a different port.
|
||||||
|
free_port() {
|
||||||
|
python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()"
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
stop "$MOCKD_PID"
|
stop "$MOCKD_PID"
|
||||||
stop "$SLOW_PID"
|
stop "$SLOW_PID"
|
||||||
@@ -84,8 +94,8 @@ step "generated TypeScript against a live server"
|
|||||||
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
||||||
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
||||||
UDS="$WORK/velox.sock"
|
UDS="$WORK/velox.sock"
|
||||||
WS_PORT=52080
|
WS_PORT="$(free_port)"
|
||||||
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
|
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
|
||||||
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
||||||
MOCKD_PID=$!
|
MOCKD_PID=$!
|
||||||
# Wait for the socket rather than sleeping a guessed amount.
|
# Wait for the socket rather than sleeping a guessed amount.
|
||||||
@@ -139,8 +149,12 @@ if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
|||||||
VXDG="$WORK/veloxd-xdg"
|
VXDG="$WORK/veloxd-xdg"
|
||||||
mkdir -p "$VXDG/runtime" "$VXDG/data" "$VXDG/config" "$VXDG/downloads"
|
mkdir -p "$VXDG/runtime" "$VXDG/data" "$VXDG/config" "$VXDG/downloads"
|
||||||
|
|
||||||
|
# VELOX_PAIR_AUTO=1: the pairing approver is the D1 dev stub (EnvAutoApprover,
|
||||||
|
# daemon/src/rpc/pairing.cpp) and denies every pairing without it — without this,
|
||||||
|
# session.pair never issues a token and the WS half of this step can't even connect.
|
||||||
XDG_RUNTIME_DIR="$VXDG/runtime" XDG_DATA_HOME="$VXDG/data" XDG_CONFIG_HOME="$VXDG/config" \
|
XDG_RUNTIME_DIR="$VXDG/runtime" XDG_DATA_HOME="$VXDG/data" XDG_CONFIG_HOME="$VXDG/config" \
|
||||||
"$VELOXD_BIN" >"$WORK/veloxd.log" 2>&1 &
|
VELOX_PAIR_AUTO=1 \
|
||||||
|
setsid "$VELOXD_BIN" >"$WORK/veloxd.log" 2>&1 &
|
||||||
VELOXD_PID=$!
|
VELOXD_PID=$!
|
||||||
VUDS="$VXDG/runtime/velox/velox.sock"
|
VUDS="$VXDG/runtime/velox/velox.sock"
|
||||||
for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done
|
for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done
|
||||||
@@ -183,7 +197,7 @@ fi
|
|||||||
step "capture.offer fails open when the daemon is too slow"
|
step "capture.offer fails open when the daemon is too slow"
|
||||||
if [ -z "$EXTERNAL_UDS" ]; then
|
if [ -z "$EXTERNAL_UDS" ]; then
|
||||||
SLOW_UDS="$WORK/slow.sock"
|
SLOW_UDS="$WORK/slow.sock"
|
||||||
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
|
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
|
||||||
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
|
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
|
||||||
SLOW_PID=$!
|
SLOW_PID=$!
|
||||||
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ interface Fixture {
|
|||||||
/** A condition the server cannot produce from the request alone. Skipped unless the
|
/** A condition the server cannot produce from the request alone. Skipped unless the
|
||||||
* harness has arranged it — see tests/integration. */
|
* harness has arranged it — see tests/integration. */
|
||||||
requires?: string;
|
requires?: string;
|
||||||
|
/** This request is documented to make the *server* close the connection after replying
|
||||||
|
* (e.g. a mismatched protocol major on the Unix socket). replay() reconnects afterward
|
||||||
|
* so every later fixture in the shared-connection replay isn't sent into a dead socket
|
||||||
|
* and left to time out one by one — which is silent when the fixture in question is
|
||||||
|
* also on the xfail allowlist, since applyXfail accepts any failure reason. */
|
||||||
|
closesConnection?: boolean;
|
||||||
transport?: TransportName;
|
transport?: TransportName;
|
||||||
deadlineMs?: number;
|
deadlineMs?: number;
|
||||||
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
|
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
|
||||||
@@ -196,11 +202,13 @@ function staticChecks(fixtures: readonly Fixture[]): Outcome[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Methods that destroy the state later fixtures rely on. Replayed last so the suite does
|
* Methods that destroy state, or consume state another fixture creates. Replayed last so
|
||||||
* not depend on file order, which is the sort of thing that goes green locally and red in
|
* the suite does not depend on file order, which is the sort of thing that goes green
|
||||||
* CI on a different filesystem.
|
* locally and red in CI on a different filesystem — alphabetical happens to put
|
||||||
|
* category.remove.json before category.upsert.json, and category.remove's fixture only
|
||||||
|
* has a "firmware" category to delete because category.upsert's fixture just created one.
|
||||||
*/
|
*/
|
||||||
const DESTRUCTIVE = new Set<string>(['download.remove']);
|
const DESTRUCTIVE = new Set<string>(['download.remove', 'category.remove']);
|
||||||
|
|
||||||
function replayOrder(a: Fixture, b: Fixture): number {
|
function replayOrder(a: Fixture, b: Fixture): number {
|
||||||
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
|
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
|
||||||
@@ -247,10 +255,13 @@ async function setupBindings(conn: Conn): Promise<{ bindings: Record<string, str
|
|||||||
return { bindings, setup };
|
return { bindings, setup };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replay(conn: Conn, fixtures: readonly Fixture[],
|
async function replay(initialConn: Conn, fixtures: readonly Fixture[],
|
||||||
bindings: Record<string, string>,
|
bindings: Record<string, string>,
|
||||||
includeRequires = false): Promise<Outcome[]> {
|
includeRequires = false,
|
||||||
|
reconnect?: () => Promise<Conn>):
|
||||||
|
Promise<{ outcomes: Outcome[]; conn: Conn }> {
|
||||||
const out: Outcome[] = [];
|
const out: Outcome[] = [];
|
||||||
|
let conn = initialConn;
|
||||||
const t = conn.transport;
|
const t = conn.transport;
|
||||||
|
|
||||||
for (const f of [...fixtures].sort(replayOrder)) {
|
for (const f of [...fixtures].sort(replayOrder)) {
|
||||||
@@ -266,7 +277,14 @@ async function replay(conn: Conn, fixtures: readonly Fixture[],
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000);
|
const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000);
|
||||||
|
if (process.env.DEBUG_CONFORMANCE) process.stderr.write(`>>> [${t}] ${f.file} ${method}\n`);
|
||||||
const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline);
|
const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline);
|
||||||
|
if (process.env.DEBUG_CONFORMANCE) process.stderr.write(`<<< [${t}] ${f.file} ${frame ? 'ok' : 'TIMEOUT'}\n`);
|
||||||
|
|
||||||
|
if (f.closesConnection && reconnect) {
|
||||||
|
conn.close();
|
||||||
|
conn = await reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
if (f.kind === 'timeout') {
|
if (f.kind === 'timeout') {
|
||||||
out.push({
|
out.push({
|
||||||
@@ -313,7 +331,7 @@ async function replay(conn: Conn, fixtures: readonly Fixture[],
|
|||||||
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
|
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
|
||||||
detail: mismatch ?? 'result validates and matches the golden shape' });
|
detail: mismatch ?? 'result validates and matches the golden shape' });
|
||||||
}
|
}
|
||||||
return out;
|
return { outcomes: out, conn };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The transport rules are part of the contract, so they get replayed too. */
|
/** The transport rules are part of the contract, so they get replayed too. */
|
||||||
@@ -364,10 +382,18 @@ function loadXfail(path: string): XfailEntry[] {
|
|||||||
* Reconciles outcomes against the allowlist. A listed fixture that failed is downgraded
|
* Reconciles outcomes against the allowlist. A listed fixture that failed is downgraded
|
||||||
* to a pass (its detail says why). A listed fixture that *passed* is flipped to a
|
* to a pass (its detail says why). A listed fixture that *passed* is flipped to a
|
||||||
* failure: the entry is stale and must be deleted from the list, not left to rot.
|
* failure: the entry is stale and must be deleted from the list, not left to rot.
|
||||||
|
*
|
||||||
|
* Never touches a 'static' outcome: those validate the golden fixture against the
|
||||||
|
* generated validators offline and never talk to a server, so a stub handler can't make
|
||||||
|
* one fail in the first place — matching them here would just relabel an
|
||||||
|
* always-true check as "xfail" and then, since it always stays true, immediately flag it
|
||||||
|
* as an unexpected pass. (A fixture also gets *two* static outcomes — params and result —
|
||||||
|
* so without this exclusion a single xfail entry would print that "duplicate" twice.)
|
||||||
*/
|
*/
|
||||||
function applyXfail(results: readonly Outcome[], xfail: readonly XfailEntry[]): Outcome[] {
|
function applyXfail(results: readonly Outcome[], xfail: readonly XfailEntry[]): Outcome[] {
|
||||||
const matches = (e: XfailEntry, r: Outcome): boolean =>
|
const matches = (e: XfailEntry, r: Outcome): boolean =>
|
||||||
e.fixture === r.fixture && (e.transport === undefined || e.transport === r.transport);
|
r.transport !== 'static' && e.fixture === r.fixture &&
|
||||||
|
(e.transport === undefined || e.transport === r.transport);
|
||||||
|
|
||||||
return results.map((r) => {
|
return results.map((r) => {
|
||||||
const entry = xfail.find((e) => matches(e, r));
|
const entry = xfail.find((e) => matches(e, r));
|
||||||
@@ -409,14 +435,18 @@ async function main(): Promise<void> {
|
|||||||
const udsPath = arg('--uds');
|
const udsPath = arg('--uds');
|
||||||
const wsPort = arg('--ws-port');
|
const wsPort = arg('--ws-port');
|
||||||
|
|
||||||
if (udsPath) {
|
// Each opens (and re-opens, via `reconnect`) with the same handshake: session.hello on
|
||||||
const conn = await connectUds(udsPath);
|
// the Unix socket, session.pair + session.hello on the WebSocket. Needed because at
|
||||||
await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
|
// least one fixture (session.hello.version-mismatch) documents that the *server* closes
|
||||||
const { bindings, setup } = await setupBindings(conn);
|
// the connection after replying — replay() calls this to get a working connection back
|
||||||
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
|
// rather than leaving every later fixture on the shared connection to time out.
|
||||||
conn.close();
|
async function freshUds(): Promise<Conn> {
|
||||||
|
const conn = await connectUds(udsPath!);
|
||||||
|
await conn.call('session.hello',
|
||||||
|
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
|
||||||
|
return conn;
|
||||||
}
|
}
|
||||||
if (wsPort) {
|
async function freshWs(): Promise<Conn> {
|
||||||
const conn = await connectWs(Number(wsPort));
|
const conn = await connectWs(Number(wsPort));
|
||||||
const paired = await conn.request(
|
const paired = await conn.request(
|
||||||
'session.pair',
|
'session.pair',
|
||||||
@@ -427,10 +457,23 @@ async function main(): Promise<void> {
|
|||||||
if (token === undefined) throw new Error('pairing failed: no token issued');
|
if (token === undefined) throw new Error('pairing failed: no token issued');
|
||||||
await conn.request('session.hello',
|
await conn.request('session.hello',
|
||||||
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
|
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (udsPath) {
|
||||||
|
const conn = await freshUds();
|
||||||
const { bindings, setup } = await setupBindings(conn);
|
const { bindings, setup } = await setupBindings(conn);
|
||||||
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
|
const { outcomes, conn: last } = await replay(conn, fixtures, bindings, includeRequires, freshUds);
|
||||||
results.push(...(await privilegeChecks(conn)));
|
results.push(...setup, ...outcomes);
|
||||||
conn.close();
|
last.close();
|
||||||
|
}
|
||||||
|
if (wsPort) {
|
||||||
|
const conn = await freshWs();
|
||||||
|
const { bindings, setup } = await setupBindings(conn);
|
||||||
|
const { outcomes, conn: last } = await replay(conn, fixtures, bindings, includeRequires, freshWs);
|
||||||
|
results.push(...setup, ...outcomes);
|
||||||
|
results.push(...(await privilegeChecks(last)));
|
||||||
|
last.close();
|
||||||
}
|
}
|
||||||
if (!udsPath && !wsPort) {
|
if (!udsPath && !wsPort) {
|
||||||
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
|
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
[
|
[
|
||||||
{ "fixture": "contracts/fixtures/download.probe.json", "reason": "D2: download.probe -> -32603, needs the engine probe path" },
|
|
||||||
{ "fixture": "contracts/fixtures/errors/download.probe.probe-failed.json", "reason": "D2: download.probe -> -32603, needs the engine probe path" },
|
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/download.pause.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.resume.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.start.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.cancel.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.remove.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.addBatch.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/download.refreshUrl.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/download.refreshUrl.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/download.update.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/download.update.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/download.provideAuth.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/errors/download.provideAuth.not-found.json", "reason": "D3: stub handler, -32603 instead of -32010" },
|
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/rules.list.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/rules.list.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/rules.upsert.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/rules.upsert.json", "reason": "D3: stub handler, -32603" },
|
||||||
@@ -25,13 +14,7 @@
|
|||||||
{ "fixture": "contracts/fixtures/schedule.get.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/schedule.get.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/schedule.set.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/schedule.set.json", "reason": "D3: stub handler, -32603" },
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/queue.upsert.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/queue.reorder.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/queue.reorder.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/queue.start.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/queue.stop.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/category.upsert.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
{ "fixture": "contracts/fixtures/category.remove.json", "reason": "D3: stub handler, -32603" },
|
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/grabber.harvest.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/grabber.harvest.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/grabber.start.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/grabber.start.json", "reason": "D3: stub handler, -32603" },
|
||||||
@@ -40,7 +23,18 @@
|
|||||||
{ "fixture": "contracts/fixtures/media.addVariant.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/media.addVariant.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/media.listVariants.json", "reason": "D3: stub handler, -32603" },
|
{ "fixture": "contracts/fixtures/media.listVariants.json", "reason": "D3: stub handler, -32603" },
|
||||||
|
|
||||||
{ "fixture": "contracts/fixtures/capture.getRules.json", "reason": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" },
|
{ "fixture": "contracts/fixtures/capture.getRules.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
|
||||||
{ "fixture": "contracts/fixtures/capture.offer.take.json", "reason": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" },
|
{ "fixture": "contracts/fixtures/capture.offer.take.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
|
||||||
{ "fixture": "contracts/fixtures/errors/capture.offer.ignore.json", "reason": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" }
|
{ "fixture": "contracts/fixtures/errors/capture.offer.ignore.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
|
||||||
|
|
||||||
|
{ "fixture": "contracts/fixtures/errors/download.provideAuth.not-found.json", "reason": "real bug: on_download_provideAuth (dispatcher.cpp) never checks the task exists -- TaskActionPort::provide_auth returns false for an unknown id, which the handler folds into a normal {ok:false} result instead of -32010" },
|
||||||
|
|
||||||
|
{ "fixture": "contracts/fixtures/category.list.json", "reason": "documented gap (deferrals.md D3a note): categories table has no mimeTypes/sortOrder columns, so category.upsert accepts them but category.list never echoes mimeTypes back" },
|
||||||
|
|
||||||
|
{ "fixture": "contracts/fixtures/download.probe.json", "reason": "not a bug: requiresAuth is optional-and-omitted-when-false (schema doesn't require it); the golden shows it because that fixture's probe hit a 401, this run's doesn't" },
|
||||||
|
{ "fixture": "contracts/fixtures/download.get.json", "reason": "not a bug: effectiveUrl is 'null until the first probe succeeds' (schema) and omitted rather than sent as null; our bound $taskId is a fresh, never-started task, so it's never been probed -- the golden depicts an in-progress download instead" },
|
||||||
|
{ "fixture": "contracts/fixtures/download.list.json", "reason": "same as download.get.json: effectiveUrl omitted for our never-started bound tasks, golden depicts an in-progress download" },
|
||||||
|
{ "fixture": "contracts/fixtures/session.hello.json", "reason": "not a bug: capabilities is genuinely empty because media/grabber/Secret Service aren't implemented yet; the golden's ['media','grabber','secretservice'] illustrates a future daemon, not this one" },
|
||||||
|
{ "fixture": "contracts/fixtures/queue.start.json", "reason": "not a bug: startedTaskIds is empty because nothing is a member of queue 'main' in this isolated run; the golden depicts a queue with real membership" },
|
||||||
|
{ "fixture": "contracts/fixtures/category.remove.json", "reason": "not a bug: reassignedTaskIds is empty because nothing was ever filed under the 'firmware' category this run creates; the golden depicts a category with real membership" }
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user