proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* A minimal client for each transport, built on the generated types.
*
* Deliberately not the extension's transport implementation: the conformance suite must
* fail when the *contract* is broken, not when the extension's reconnect logic is. It
* speaks the two framings and nothing else.
*/
import net from 'node:net';
import { WebSocket } from 'ws';
import type { MethodName, Params, Result } from '../../../extension/src/shared/protocol/methods.js';
export type TransportName = 'uds' | 'ws';
export interface RpcFrame {
jsonrpc: '2.0';
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
export interface Conn {
readonly transport: TransportName;
/** Send and wait. Resolves to null when nothing arrives inside `timeoutMs`. */
request(method: string, params: unknown, timeoutMs: number): Promise<RpcFrame | null>;
/** Typed convenience wrapper, so the suite itself is checked against the contract. */
call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;
notifications(): RpcFrame[];
close(): void;
}
abstract class BaseConn implements Conn {
abstract readonly transport: TransportName;
protected nextId = 1;
protected readonly pending = new Map<number | string, (f: RpcFrame) => void>();
private readonly received: RpcFrame[] = [];
protected abstract write(text: string): void;
abstract close(): void;
protected onFrame(frame: RpcFrame): void {
if (frame.id !== undefined && this.pending.has(frame.id)) {
const resolve = this.pending.get(frame.id);
this.pending.delete(frame.id);
resolve?.(frame);
return;
}
if (frame.method !== undefined) this.received.push(frame);
}
notifications(): RpcFrame[] {
return [...this.received];
}
request(method: string, params: unknown, timeoutMs: number): Promise<RpcFrame | null> {
const id = this.nextId++;
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve(null); // the fail-open case: no answer inside the deadline
}, timeoutMs);
this.pending.set(id, (frame) => {
clearTimeout(timer);
resolve(frame);
});
this.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
});
}
async call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>> {
const frame = await this.request(method, params, 10_000);
if (frame === null) throw new Error(`${method}: no response`);
if (frame.error) throw new Error(`${method}: error ${frame.error.code}: ${frame.error.message}`);
return frame.result as Result<M>;
}
}
class UdsConn extends BaseConn {
readonly transport = 'uds' as const;
private buffer = '';
constructor(private readonly socket: net.Socket) {
super();
socket.on('data', (chunk) => {
this.buffer += chunk.toString('utf8');
let nl = this.buffer.indexOf('\n');
while (nl !== -1) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
nl = this.buffer.indexOf('\n');
if (line) this.onFrame(JSON.parse(line) as RpcFrame);
}
});
}
protected write(text: string): void {
this.socket.write(text + '\n');
}
close(): void {
this.socket.destroy();
}
}
class WsConn extends BaseConn {
readonly transport = 'ws' as const;
constructor(private readonly socket: WebSocket) {
super();
socket.on('message', (data) => this.onFrame(JSON.parse(data.toString()) as RpcFrame));
}
protected write(text: string): void {
this.socket.send(text);
}
close(): void {
this.socket.close();
}
}
export async function connectUds(path: string): Promise<Conn> {
const socket = net.connect(path);
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
return new UdsConn(socket);
}
export async function connectWs(port: number): Promise<Conn> {
// The daemon verifies this Origin on the upgrade, so the suite must present a real one.
const socket = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { Origin: 'moz-extension://11111111-2222-3333-4444-555555555555' },
});
await new Promise<void>((resolve, reject) => {
socket.once('open', () => resolve());
socket.once('error', reject);
});
return new WsConn(socket);
}
+567
View File
@@ -0,0 +1,567 @@
{
"name": "@velox/conformance",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@velox/conformance",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.7.0",
"@types/ws": "^8.5.12",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@velox/conformance",
"version": "1.0.0",
"private": true,
"description": "Replays contracts/fixtures against a live server through the generated TypeScript client.",
"type": "module",
"scripts": {
"conformance": "tsx replay.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": { "ws": "^8.18.0" },
"devDependencies": {
"@types/node": "^22.7.0",
"@types/ws": "^8.5.12",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Conformance runner, TypeScript side.
*
* Replays every fixture in contracts/fixtures against a live server — mockd today, veloxd
* from M1 — through the generated client types and validators. The same suite runs against
* both, which is the point: if a lane drifts from the contract, this goes red the same day
* rather than at M2 integration.
*
* What each fixture asserts
* success the reply carries a result; the result passes the generated validator; its
* shape matches the golden file
* error the reply carries an error with the fixture's code
* timeout nothing arrives inside the deadline, and the client is expected to give up.
* This is capture.offer's fail-open guarantee, and it is a pass when the
* server stays silent.
*
* Values are compared by *shape*, not by equality: a live daemon returns its own task ids
* and its own clock, and demanding byte-identical results would only teach the suite to
* lie. Types, key sets and error codes are compared exactly.
*
* npx tsx replay.ts --uds /run/user/1000/velox/velox.sock --ws-port 52000
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { connectUds, connectWs, type Conn, type TransportName } from './client.js';
import {
METHODS,
isMethodName,
type MethodName,
} from '../../../extension/src/shared/protocol/methods.js';
import { isEventName } from '../../../extension/src/shared/protocol/events.js';
import {
validateEventParams,
validateParams,
validateResult,
} from '../../../extension/src/shared/protocol/validate.js';
const HERE = resolve(fileURLToPath(import.meta.url), '..');
const REPO = resolve(HERE, '..', '..', '..');
const FIXTURES = resolve(REPO, 'contracts', 'fixtures');
const PLACEHOLDERS = new Set(['$uuid', '$isoDate', '$any', '$opaque', '$taskId', '$taskId2']);
/**
* Concrete stand-ins for the placeholders, used when a golden payload is validated on its
* own. A validator applies length and pattern rules, so "$opaque" has to become something
* token-shaped before it is checked.
*/
const CONCRETE: Record<string, string> = {
$uuid: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b',
$taskId: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b',
$taskId2: '11112222-3333-4444-8555-666677778888',
$isoDate: '2026-09-09T10:14:52Z',
$any: 'placeholder',
$opaque: 'cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU',
};
function concrete(value: unknown): unknown {
if (typeof value === 'string') return CONCRETE[value] ?? value;
if (Array.isArray(value)) return value.map(concrete);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = concrete(v);
return out;
}
return value;
}
interface Fixture {
file: string;
name: string;
kind?: 'timeout';
/** A condition the server cannot produce from the request alone. Skipped unless the
* harness has arranged it — see tests/integration. */
requires?: string;
transport?: TransportName;
deadlineMs?: number;
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
notification?: { jsonrpc: '2.0'; method: string; params: unknown };
response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: { code: number } } | null;
}
interface Outcome {
fixture: string;
transport: TransportName | 'static';
ok: boolean;
detail: string;
}
// ---------------------------------------------------------------- shape match
/**
* Compare an actual value against a golden one structurally. Placeholders match anything;
* objects must have the same keys; arrays must agree on emptiness and on element shape.
*/
function shapeMismatch(golden: unknown, actual: unknown, path = ''): string | null {
if (typeof golden === 'string' && PLACEHOLDERS.has(golden)) return null;
// The generated validator has already ruled on whether null is allowed here, so a null
// is never a shape failure: a golden file shows one plausible value, not the only one.
if (actual === null) return null;
if (golden === null) return actual === null ? null : `${path}: expected null, got ${typeName(actual)}`;
if (Array.isArray(golden)) {
if (!Array.isArray(actual)) return `${path}: expected an array, got ${typeName(actual)}`;
if (golden.length > 0 && actual.length === 0) return `${path}: expected a non-empty array`;
if (golden.length > 0 && actual.length > 0) return shapeMismatch(golden[0], actual[0], `${path}/0`);
return null;
}
if (typeof golden === 'object') {
if (typeof actual !== 'object' || actual === null || Array.isArray(actual))
return `${path}: expected an object, got ${typeName(actual)}`;
const g = golden as Record<string, unknown>;
const a = actual as Record<string, unknown>;
for (const key of Object.keys(g)) {
// A golden null means "may be absent"; the contract treats absent and null alike.
if (!(key in a)) {
if (g[key] === null) continue;
return `${path}/${key}: missing from the response`;
}
const sub = shapeMismatch(g[key], a[key], `${path}/${key}`);
if (sub) return sub;
}
for (const key of Object.keys(a)) {
if (!(key in g)) return `${path}/${key}: not in the contract's result`;
}
return null;
}
if (typeof golden !== typeof actual) return `${path}: expected ${typeof golden}, got ${typeName(actual)}`;
return null;
}
function typeName(v: unknown): string {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
// ------------------------------------------------------------------- fixtures
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (entry.endsWith('.json')) out.push(full);
}
return out;
}
function loadFixtures(): Fixture[] {
return walk(FIXTURES).map((file) => ({
...(JSON.parse(readFileSync(file, 'utf8')) as Omit<Fixture, 'file'>),
file: relative(REPO, file),
}));
}
// -------------------------------------------------------------------- checks
/** Runs with no server: the generated validators must accept every golden payload. */
function staticChecks(fixtures: readonly Fixture[]): Outcome[] {
const out: Outcome[] = [];
for (const f of fixtures) {
if (f.notification) {
const name = f.notification.method;
if (!isEventName(name)) {
out.push({ fixture: f.file, transport: 'static', ok: false, detail: `unknown event ${name}` });
continue;
}
const r = validateEventParams(name, concrete(f.notification.params));
out.push({ fixture: f.file, transport: 'static', ok: r.ok,
detail: r.ok ? 'event payload validates' : `${r.path}: ${r.message}` });
continue;
}
const method = f.request?.method;
if (method === undefined || !isMethodName(method)) continue;
const expectsInvalidParams = f.response?.error?.code === -32602;
const r = validateParams(method, concrete(f.request?.params ?? {}));
if (expectsInvalidParams) {
out.push({ fixture: f.file, transport: 'static', ok: !r.ok,
detail: r.ok ? 'expects -32602 but the params validate' : 'params correctly rejected' });
} else {
out.push({ fixture: f.file, transport: 'static', ok: r.ok,
detail: r.ok ? 'params validate' : `${r.path}: ${r.message}` });
}
if (f.response && 'result' in f.response) {
const rr = validateResult(method, concrete(f.response.result));
out.push({ fixture: f.file, transport: 'static', ok: rr.ok,
detail: rr.ok ? 'golden result validates' : `${rr.path}: ${rr.message}` });
}
}
return out;
}
/**
* Methods that destroy the state later fixtures rely on. Replayed last so the suite does
* not depend on file order, which is the sort of thing that goes green locally and red in
* CI on a different filesystem.
*/
const DESTRUCTIVE = new Set<string>(['download.remove']);
function replayOrder(a: Fixture, b: Fixture): number {
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
return rank(a) - rank(b) || a.file.localeCompare(b.file);
}
/** Substitute the ids the runner bound during setup into a fixture's params. */
function bind(value: unknown, bindings: Record<string, string>): unknown {
if (typeof value === 'string') return bindings[value] ?? value;
if (Array.isArray(value)) return value.map((v) => bind(v, bindings));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = bind(v, bindings);
return out;
}
return value;
}
/**
* Create the tasks the task-referencing fixtures bind to. Doing this per connection is
* what lets the same suite run against an empty veloxd and against a seeded mockd.
*/
async function setupBindings(conn: Conn): Promise<Record<string, string>> {
const bindings: Record<string, string> = {};
for (const [key, url] of [['$taskId', 'https://example.org/conformance-a.bin'],
['$taskId2', 'https://example.org/conformance-b.bin']] as const) {
const added = await conn.call('download.add', { url, startMode: 'later' });
bindings[key] = added.taskId;
}
return bindings;
}
async function replay(conn: Conn, fixtures: readonly Fixture[],
bindings: Record<string, string>,
includeRequires = false): Promise<Outcome[]> {
const out: Outcome[] = [];
const t = conn.transport;
for (const f of [...fixtures].sort(replayOrder)) {
if (!f.request) continue;
const method = f.request.method;
if (f.transport !== undefined && f.transport !== t) continue;
if (!isMethodName(method)) continue;
if (!(METHODS[method].transports as readonly string[]).includes(t)) continue;
if (f.requires !== undefined && !includeRequires) {
out.push({ fixture: f.file, transport: t, ok: true,
detail: `skipped: requires ${f.requires}` });
continue;
}
const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000);
const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline);
if (f.kind === 'timeout') {
out.push({
fixture: f.file, transport: t, ok: frame === null,
detail: frame === null
? `no reply within ${deadline} ms — the client fails open, as it must`
: 'the server answered a fixture that requires silence',
});
continue;
}
if (frame === null) {
out.push({ fixture: f.file, transport: t, ok: false, detail: `no reply within ${deadline} ms` });
continue;
}
const expected = f.response;
if (expected && 'error' in expected && expected.error) {
const got = frame.error?.code;
out.push({
fixture: f.file, transport: t, ok: got === expected.error.code,
detail: got === expected.error.code
? `error ${got} as documented`
: `expected error ${expected.error.code}, got ${frame.error ? `error ${got}` : 'a result'}`,
});
continue;
}
if (frame.error) {
out.push({ fixture: f.file, transport: t, ok: false,
detail: `expected a result, got error ${frame.error.code}: ${frame.error.message}` });
continue;
}
const validated = validateResult(method, frame.result);
if (!validated.ok) {
out.push({ fixture: f.file, transport: t, ok: false,
detail: `result fails the generated validator at ${validated.path}: ${validated.message}` });
continue;
}
const mismatch = expected && 'result' in expected
? shapeMismatch(expected.result, frame.result)
: null;
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
detail: mismatch ?? 'result validates and matches the golden shape' });
}
return out;
}
/** The transport rules are part of the contract, so they get replayed too. */
async function privilegeChecks(conn: Conn): Promise<Outcome[]> {
if (conn.transport !== 'ws') return [];
const out: Outcome[] = [];
const privileged = (Object.keys(METHODS) as MethodName[]).filter((m) => METHODS[m].privileged);
for (const method of privileged) {
const frame = await conn.request(method, {}, 3000);
const ok = frame?.error?.code === -32003;
out.push({
fixture: `transport-rules/${method}`, transport: 'ws', ok,
detail: ok ? 'refused with -32003 over the WebSocket, as required'
: `expected -32003, got ${frame ? JSON.stringify(frame.error ?? frame.result).slice(0, 80) : 'no reply'}`,
});
}
return out;
}
// ---------------------------------------------------------------------- main
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const arg = (name: string): string | undefined => {
const i = argv.indexOf(name);
return i === -1 ? undefined : argv[i + 1];
};
// --only narrows the run to fixtures whose path contains a substring, and
// --include-requires replays the ones needing a condition the harness has arranged
// (a slow daemon, a hostile origin server). run.sh uses both to prove capture.offer
// fails open, which cannot be shown against a healthy server.
const only = arg('--only');
const includeRequires = argv.includes('--include-requires');
const all = loadFixtures();
const fixtures = only === undefined ? all : all.filter((f) => f.file.includes(only));
if (fixtures.length === 0) {
process.stderr.write(`conformance: --only ${String(only)} matched no fixtures\n`);
process.exit(2);
}
const results: Outcome[] = [...staticChecks(fixtures)];
const udsPath = arg('--uds');
const wsPort = arg('--ws-port');
if (udsPath) {
const conn = await connectUds(udsPath);
await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires)));
conn.close();
}
if (wsPort) {
const conn = await connectWs(Number(wsPort));
const paired = await conn.request(
'session.pair',
{ clientName: 'conformance', extensionId: '11111111-2222-3333-4444-555555555555' },
5000,
);
const token = (paired?.result as { token?: string } | undefined)?.token;
if (token === undefined) throw new Error('pairing failed: no token issued');
await conn.request('session.hello',
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires)));
results.push(...(await privilegeChecks(conn)));
conn.close();
}
if (!udsPath && !wsPort) {
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
}
const failed = results.filter((r) => !r.ok);
for (const r of failed) {
process.stdout.write(`FAIL [${r.transport}] ${r.fixture}\n ${r.detail}\n`);
}
const byTransport = new Map<string, number>();
for (const r of results) byTransport.set(r.transport, (byTransport.get(r.transport) ?? 0) + 1);
const summary = [...byTransport].map(([k, v]) => `${k}:${v}`).join(' ');
process.stdout.write(`\n${results.length - failed.length}/${results.length} checks passed (${summary})\n`);
process.exit(failed.length === 0 ? 0 : 1);
}
main().catch((err: unknown) => {
process.stderr.write(`conformance: ${String(err)}\n`);
process.exit(2);
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts", "../../../extension/src/shared/protocol/**/*.ts"]
}