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
+89
View File
@@ -0,0 +1,89 @@
/**
* Loading and replaying contracts/fixtures.
*
* mockd answers from the golden files rather than from hand-written mock objects, so a
* GUI or extension built against it is built against the same bytes the conformance suite
* replays at the real daemon. If a fixture is wrong, everyone finds out at once.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { randomUUID } from 'node:crypto';
export interface Fixture {
readonly file: string;
readonly name: string;
readonly description: string;
readonly kind?: 'timeout';
readonly transport?: 'uds' | 'ws';
readonly request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
readonly notification?: { jsonrpc: '2.0'; method: string; params: unknown };
readonly response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: unknown } | null;
readonly assertions?: readonly string[];
}
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;
}
export function loadFixtures(root: string): Fixture[] {
return walk(root).map((file) => ({
...(JSON.parse(readFileSync(file, 'utf8')) as Omit<Fixture, 'file'>),
file: relative(root, file),
}));
}
/**
* Placeholders stand for values a golden file cannot pin: a fresh uuid, the current time,
* an opaque token. Resolving them here is what lets one fixture be replayed forever.
*/
export function resolvePlaceholders(value: unknown): unknown {
if (typeof value === 'string') {
switch (value) {
case '$uuid':
return randomUUID();
case '$isoDate':
return new Date().toISOString();
case '$opaque':
return Buffer.from(randomUUID() + randomUUID()).toString('base64url');
case '$any':
return 'placeholder';
default:
return value;
}
}
if (Array.isArray(value)) return value.map(resolvePlaceholders);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = resolvePlaceholders(v);
}
return out;
}
return value;
}
/** Success fixtures indexed by method, so a request can be answered from a golden file. */
export function indexByMethod(fixtures: readonly Fixture[]): Map<string, Fixture> {
const out = new Map<string, Fixture>();
for (const f of fixtures) {
const method = f.request?.method;
if (!method || !f.response || !('result' in f.response)) continue;
if (!out.has(method)) out.set(method, f);
}
return out;
}
export function eventFixtures(fixtures: readonly Fixture[]): Map<string, Fixture> {
const out = new Map<string, Fixture>();
for (const f of fixtures) {
if (f.notification) out.set(f.notification.method, f);
}
return out;
}