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:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* The little bit of real state mockd keeps.
|
||||
*
|
||||
* A pure fixture replayer would be useless to the GUI lane: adding a download and seeing
|
||||
* nothing appear teaches you nothing about your table model. So mockd keeps a task list,
|
||||
* moves tasks between states, and advances byte counters on a timer. Everything else is
|
||||
* answered straight from a golden file.
|
||||
*
|
||||
* This is deliberately not a download engine. Bytes advance on a clock, not from a socket.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||
|
||||
export interface MockOptions {
|
||||
readonly progressHz: number;
|
||||
readonly speedBps: number;
|
||||
}
|
||||
|
||||
const ACTIVE: readonly TaskState[] = ['connecting', 'downloading'];
|
||||
|
||||
export class MockState {
|
||||
readonly tasks = new Map<string, TaskSummary>();
|
||||
limiter = { enabled: false, globalBps: 2097152, applyToRunning: false };
|
||||
settings: Record<string, unknown> = {};
|
||||
private readonly opts: MockOptions;
|
||||
|
||||
constructor(seed: readonly TaskSummary[], opts: MockOptions) {
|
||||
this.opts = opts;
|
||||
for (const t of seed) this.tasks.set(t.taskId, { ...t });
|
||||
}
|
||||
|
||||
list(): TaskSummary[] {
|
||||
return [...this.tasks.values()];
|
||||
}
|
||||
|
||||
add(partial: Partial<TaskSummary> & { url: string }): TaskSummary {
|
||||
const now = new Date().toISOString();
|
||||
const task: TaskSummary = {
|
||||
taskId: randomUUID(),
|
||||
filename: partial.filename ?? filenameFromUrl(partial.url),
|
||||
saveDir: partial.saveDir ?? '/home/sami/Downloads',
|
||||
url: partial.url,
|
||||
effectiveUrl: partial.url,
|
||||
sizeBytes: partial.sizeBytes ?? 734003200,
|
||||
downloadedBytes: 0,
|
||||
state: partial.state ?? 'connecting',
|
||||
speedBps: 0,
|
||||
etaSeconds: null,
|
||||
resumable: true,
|
||||
segments: partial.segments ?? 8,
|
||||
categoryId: partial.categoryId ?? null,
|
||||
queueId: partial.queueId ?? null,
|
||||
queuePosition: partial.queuePosition ?? null,
|
||||
description: partial.description ?? null,
|
||||
createdAt: now,
|
||||
lastTryAt: now,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.tasks.set(task.taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
transition(taskId: string, state: TaskState): { changed: boolean; task: TaskSummary } | null {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) return null;
|
||||
const changed = task.state !== state;
|
||||
task.state = state;
|
||||
task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0;
|
||||
if (!ACTIVE.includes(state)) task.etaSeconds = null;
|
||||
if (state === 'complete') {
|
||||
task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes;
|
||||
task.completedAt = new Date().toISOString();
|
||||
}
|
||||
return { changed, task };
|
||||
}
|
||||
|
||||
remove(taskId: string): boolean {
|
||||
return this.tasks.delete(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one progress tick. Returns the tasks that moved and any that just finished,
|
||||
* so the caller can emit event.task.progress and event.task.state from one place.
|
||||
*/
|
||||
tick(): { moved: TaskSummary[]; completed: TaskSummary[] } {
|
||||
const moved: TaskSummary[] = [];
|
||||
const completed: TaskSummary[] = [];
|
||||
const perTick = Math.floor(this.opts.speedBps / this.opts.progressHz);
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.state === 'connecting') {
|
||||
task.state = 'downloading';
|
||||
moved.push(task);
|
||||
}
|
||||
if (task.state !== 'downloading') continue;
|
||||
|
||||
task.speedBps = jitter(perTick * this.opts.progressHz);
|
||||
task.downloadedBytes += perTick;
|
||||
const size = task.sizeBytes;
|
||||
if (size !== null && size !== undefined && task.downloadedBytes >= size) {
|
||||
task.downloadedBytes = size;
|
||||
task.state = 'complete';
|
||||
task.speedBps = 0;
|
||||
task.etaSeconds = null;
|
||||
task.completedAt = new Date().toISOString();
|
||||
completed.push(task);
|
||||
} else if (size !== null && size !== undefined && task.speedBps > 0) {
|
||||
task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps);
|
||||
}
|
||||
moved.push(task);
|
||||
}
|
||||
return { moved, completed };
|
||||
}
|
||||
|
||||
globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } {
|
||||
let downBps = 0;
|
||||
let activeCount = 0;
|
||||
let queuedCount = 0;
|
||||
for (const t of this.tasks.values()) {
|
||||
if (ACTIVE.includes(t.state)) {
|
||||
downBps += t.speedBps;
|
||||
activeCount += 1;
|
||||
} else if (t.state === 'queued') {
|
||||
queuedCount += 1;
|
||||
}
|
||||
}
|
||||
return { downBps, activeCount, queuedCount };
|
||||
}
|
||||
}
|
||||
|
||||
function jitter(bps: number): number {
|
||||
return Math.max(0, Math.round(bps * (0.85 + Math.random() * 0.3)));
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const path = new URL(url).pathname;
|
||||
const last = path.split('/').filter(Boolean).pop();
|
||||
return last && last.length > 0 ? decodeURIComponent(last) : 'download.bin';
|
||||
} catch {
|
||||
return 'download.bin';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user