diff --git a/apps/local-qa-runtime/README.md b/apps/local-qa-runtime/README.md index 0fc6e60c2..c8ccd0fb4 100644 --- a/apps/local-qa-runtime/README.md +++ b/apps/local-qa-runtime/README.md @@ -55,11 +55,17 @@ production policy code. It: - always finalizes the injected session after acquisition; and - returns a bounded, deterministic serialized result. +The fixed Worker executable walks that policy through the registered bounded +`qa.local-worker-protocol/v1` over stdin/stdout. It accepts one invocation, +performs the seven fixed typed capability exchanges, emits one terminal result, +and exits. The process acceptance harness acts as the Host-shaped peer; the +production Host does not yet invoke this Worker. + The worker never discovers or launches Chrome, opens network or filesystem -resources, creates profiles, downloads, or processes, persists Evidence, or -owns Host cleanup. Browser output acquisition, screenshot production, reference -digesting, and storage are responsibilities of the injected ports, not worker -policy. The Host does not yet invoke this worker boundary. +resources, creates profiles, downloads, or child processes, persists Evidence, +or owns Host cleanup. Browser output acquisition, screenshot production, +reference digesting, and storage are responsibilities of the protocol peer, not +worker policy. The Rust `evidence-stager/` library owns bounded Local Evidence filesystem effects. It accepts validated Evidence identities and bytes, derives confined @@ -82,8 +88,7 @@ safety net do not expose caller-programmable browser behavior. The following capabilities remain explicitly deferred: -- Host coordination of the fixed Browser adapter and a Host-worker process - protocol; +- Host executor integration of the fixed Browser adapter and Worker protocol; - Host integration of filesystem Evidence staging and journal Evidence references; - execution terminal outcomes and restart-to-`lost` reconciliation; diff --git a/apps/local-qa-runtime/tests/scaffold-structure.sh b/apps/local-qa-runtime/tests/scaffold-structure.sh index 89e02bc04..174c2fa2b 100755 --- a/apps/local-qa-runtime/tests/scaffold-structure.sh +++ b/apps/local-qa-runtime/tests/scaffold-structure.sh @@ -132,14 +132,19 @@ done node - <<'NODE' const pkg = require('./apps/local-qa-runtime/workers/package.json'); -const scripts = { - build: 'tsc -p tsconfig.build.json', - typecheck: 'tsc -p tsconfig.json', - test: 'tsc -p tsconfig.build.json && node --test test/*.test.mjs', +const dependencies = { '@chronoai/fkst-qa-contracts': 'file:../../../packages/qa-contracts' }; +const devDependencies = { '@types/node': '20.19.43', typescript: '5.9.3' }; +const bin = { 'fkst-local-qa-worker': 'dist/worker-main.js' }; +const expectedScripts = { + build: 'npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.build.json', + typecheck: 'npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.json', + test: 'npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.build.json && node --test test/*.test.mjs', }; -if (pkg.dependencies || JSON.stringify(pkg.scripts) !== JSON.stringify(scripts) || - JSON.stringify(pkg.devDependencies) !== JSON.stringify({ typescript: '5.9.3' })) { - throw new Error('workers package gained unreviewed dependencies or scripts'); +if (JSON.stringify(pkg.dependencies) !== JSON.stringify(dependencies) || + JSON.stringify(pkg.scripts) !== JSON.stringify(expectedScripts) || + JSON.stringify(pkg.devDependencies) !== JSON.stringify(devDependencies) || + JSON.stringify(pkg.bin) !== JSON.stringify(bin)) { + throw new Error('workers package gained unreviewed dependencies, scripts, or executables'); } NODE @@ -157,6 +162,7 @@ unexpected=$(find apps/local-qa-runtime -type f \ \( -perm -111 -o -name '*.sh' -o -name '*.py' -o -name '*.js' -o -name '*.mjs' \) \ ! -path 'apps/local-qa-runtime/tests/scaffold-structure.sh' \ ! -path 'apps/local-qa-runtime/workers/test/browser-smoke.test.mjs' \ + ! -path 'apps/local-qa-runtime/workers/test/protocol-worker.test.mjs' \ ! -path '*/node_modules/*' ! -path '*/target/*' ! -path '*/dist/*') [[ -z "$unexpected" ]] || { echo 'unexpected executable implementation file' >&2; exit 1; } diff --git a/apps/local-qa-runtime/workers/package-lock.json b/apps/local-qa-runtime/workers/package-lock.json index 8d5e8d912..7dff8f26d 100644 --- a/apps/local-qa-runtime/workers/package-lock.json +++ b/apps/local-qa-runtime/workers/package-lock.json @@ -8,7 +8,11 @@ "name": "@chronoai/fkst-local-qa-workers", "version": "0.0.0", "devDependencies": { + "@types/node": "20.19.43", "typescript": "5.9.3" + }, + "dependencies": { + "@chronoai/fkst-qa-contracts": "file:../../../packages/qa-contracts" } }, "node_modules/typescript": { @@ -24,6 +28,98 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/@chronoai/fkst-qa-contracts": { + "resolved": "../../../packages/qa-contracts", + "link": true + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "license": "MIT" } } } diff --git a/apps/local-qa-runtime/workers/package.json b/apps/local-qa-runtime/workers/package.json index a292a647d..80c9afade 100644 --- a/apps/local-qa-runtime/workers/package.json +++ b/apps/local-qa-runtime/workers/package.json @@ -4,11 +4,18 @@ "private": true, "type": "module", "scripts": { - "build": "tsc -p tsconfig.build.json", - "typecheck": "tsc -p tsconfig.json", - "test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs" + "build": "npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.build.json", + "typecheck": "npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.json", + "test": "npm run build --prefix ../../../packages/qa-contracts && tsc -p tsconfig.build.json && node --test test/*.test.mjs" }, "devDependencies": { + "@types/node": "20.19.43", "typescript": "5.9.3" + }, + "bin": { + "fkst-local-qa-worker": "dist/worker-main.js" + }, + "dependencies": { + "@chronoai/fkst-qa-contracts": "file:../../../packages/qa-contracts" } } diff --git a/apps/local-qa-runtime/workers/src/index.ts b/apps/local-qa-runtime/workers/src/index.ts index d106d03d4..5ddfabe76 100644 --- a/apps/local-qa-runtime/workers/src/index.ts +++ b/apps/local-qa-runtime/workers/src/index.ts @@ -12,3 +12,5 @@ export { type EvidenceStagingPort, type WorkerErrorCode, } from "./policy.js"; + +export { runProtocolWorker } from "./protocol-worker.js"; diff --git a/apps/local-qa-runtime/workers/src/policy.ts b/apps/local-qa-runtime/workers/src/policy.ts index 721b6e3d6..244babd42 100644 --- a/apps/local-qa-runtime/workers/src/policy.ts +++ b/apps/local-qa-runtime/workers/src/policy.ts @@ -42,8 +42,8 @@ export interface BrowserSessionPort { run(request: BrowserSmokeRequest): Promise<{ finalUrl: string; observedText: string; - sanitizedObservationRef: DigestBoundRef<"qa.sanitized-observation/v1">; - screenshotArtifactRef: DigestBoundRef<"qa.artifact-pointer/v1">; + sanitizedObservationRef: DigestBoundRef<"qa.local-evidence/v1">; + screenshotEvidenceRef: DigestBoundRef<"qa.local-evidence/v1">; }>; close(): Promise; } @@ -53,12 +53,12 @@ export interface EvidenceStagingPort { name: "runner.log"; mediaType: "text/plain; charset=utf-8"; bytes: Uint8Array; - }): Promise>; + }): Promise>; } export interface ClockPort { - now(): string; - monotonicMs(): number; + now(): string | Promise; + monotonicMs(): number | Promise; } export interface BrowserSmokeResult { @@ -70,7 +70,7 @@ export interface BrowserSmokeResult { readonly selector: typeof SELECTOR; readonly expectedText: typeof EXPECTED_TEXT; readonly observedText: typeof EXPECTED_TEXT; - readonly sanitizedObservationRef: DigestBoundRef<"qa.sanitized-observation/v1">; + readonly sanitizedObservationRef: DigestBoundRef<"qa.local-evidence/v1">; }; readonly startedAt: string; readonly finishedAt: string; @@ -79,12 +79,12 @@ export interface BrowserSmokeResult { { readonly objectId: "evidence/0"; readonly role: "screenshot"; - readonly artifactRef: DigestBoundRef<"qa.artifact-pointer/v1">; + readonly artifactRef: DigestBoundRef<"qa.local-evidence/v1">; }, { readonly objectId: "evidence/1"; readonly role: "runner-log"; - readonly artifactRef: DigestBoundRef<"qa.artifact-pointer/v1">; + readonly artifactRef: DigestBoundRef<"qa.local-evidence/v1">; }, ]; } @@ -148,10 +148,10 @@ export async function runBrowserSmoke( }, ): Promise { const request = parseBrowserSmokeRequest(source); - const startedAt = clockNow(ports.clock); - const startedMonotonicMs = monotonicNow(ports.clock); + const startedAt = await clockNow(ports.clock); + const startedMonotonicMs = await monotonicNow(ports.clock); let validatedSession: ReturnType; - let runnerLogArtifactRef: DigestBoundRef<"qa.artifact-pointer/v1">; + let runnerLogArtifactRef: DigestBoundRef<"qa.local-evidence/v1">; try { let sessionResult: Awaited>; @@ -181,8 +181,8 @@ export async function runBrowserSmoke( mediaType: "text/plain; charset=utf-8", bytes: RUNNER_LOG.slice(), }), - "artifact-pointer", - "qa.artifact-pointer/v1", + "local-evidence-object", + "qa.local-evidence/v1", ); } catch (error) { if (error instanceof BrowserSmokeWorkerError) { @@ -198,8 +198,8 @@ export async function runBrowserSmoke( } } - const finishedAt = clockNow(ports.clock); - const finishedMonotonicMs = monotonicNow(ports.clock); + const finishedAt = await clockNow(ports.clock); + const finishedMonotonicMs = await monotonicNow(ports.clock); const durationMs = finishedMonotonicMs - startedMonotonicMs; if (!Number.isSafeInteger(durationMs) || durationMs < 0) { throw new BrowserSmokeWorkerError("clock.invalid_value"); @@ -224,7 +224,7 @@ export async function runBrowserSmoke( { objectId: "evidence/0", role: "screenshot", - artifactRef: validatedSession.screenshotArtifactRef, + artifactRef: validatedSession.screenshotEvidenceRef, }, { objectId: "evidence/1", @@ -313,14 +313,14 @@ function isFixedFixtureUrl(value: string): boolean { function validateSessionResult(value: unknown): { readonly finalUrl: string; readonly observedText: string; - readonly sanitizedObservationRef: DigestBoundRef<"qa.sanitized-observation/v1">; - readonly screenshotArtifactRef: DigestBoundRef<"qa.artifact-pointer/v1">; + readonly sanitizedObservationRef: DigestBoundRef<"qa.local-evidence/v1">; + readonly screenshotEvidenceRef: DigestBoundRef<"qa.local-evidence/v1">; } { if (!isExactRecord(value, [ "finalUrl", "observedText", "sanitizedObservationRef", - "screenshotArtifactRef", + "screenshotEvidenceRef", ])) { throw new BrowserSmokeWorkerError("session.invalid_response"); } @@ -332,13 +332,13 @@ function validateSessionResult(value: unknown): { observedText: value.observedText, sanitizedObservationRef: validateReference( value.sanitizedObservationRef, - "sanitized-observation", - "qa.sanitized-observation/v1", + "local-sanitized-observation", + "qa.local-evidence/v1", ), - screenshotArtifactRef: validateReference( - value.screenshotArtifactRef, - "artifact-pointer", - "qa.artifact-pointer/v1", + screenshotEvidenceRef: validateReference( + value.screenshotEvidenceRef, + "local-evidence-object", + "qa.local-evidence/v1", ), }; } @@ -361,7 +361,10 @@ function validateReference( } if ( value.kind !== expectedKind || - !isNonEmptyString(value.id) || + typeof value.id !== "string" || + !(expectedKind === "local-sanitized-observation" + ? /^observation\/[0-9]+$/.test(value.id) + : /^evidence\/[0-9]+$/.test(value.id)) || value.schema_version !== expectedSchema || typeof value.content_digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.content_digest) || @@ -390,10 +393,10 @@ function canonicalReference( }; } -function clockNow(clock: ClockPort): string { +async function clockNow(clock: ClockPort): Promise { let value: string; try { - value = clock.now(); + value = await clock.now(); } catch { throw new BrowserSmokeWorkerError("clock.failed"); } @@ -403,10 +406,10 @@ function clockNow(clock: ClockPort): string { return value; } -function monotonicNow(clock: ClockPort): number { +async function monotonicNow(clock: ClockPort): Promise { let value: number; try { - value = clock.monotonicMs(); + value = await clock.monotonicMs(); } catch { throw new BrowserSmokeWorkerError("clock.failed"); } diff --git a/apps/local-qa-runtime/workers/src/protocol-worker.ts b/apps/local-qa-runtime/workers/src/protocol-worker.ts new file mode 100644 index 000000000..1965150b3 --- /dev/null +++ b/apps/local-qa-runtime/workers/src/protocol-worker.ts @@ -0,0 +1,187 @@ +import { + LocalWorkerFrameDecoder, + encodeLocalWorkerFrame, + validateLocalWorkerCapabilityRequest, + validateLocalWorkerTerminalResult, + type ValidatedValue, +} from "@chronoai/fkst-qa-contracts"; + +import { + runBrowserSmoke, + type BrowserSmokeRequest, + type DigestBoundRef, +} from "./policy.js"; + +const PROTOCOL = "qa.local-worker-protocol/v1"; +const FIXED_RUNNER_LOG = new TextEncoder().encode("navigation accepted\nassertion passed\n"); + +type FrameRecord = Readonly>; + +class ProtocolPeer { + readonly #decoder = new LocalWorkerFrameDecoder(); + readonly #iterator = process.stdin[Symbol.asyncIterator](); + readonly #pending: ValidatedValue[] = []; + + async read(): Promise { + while (this.#pending.length === 0) { + const next = await this.#iterator.next(); + if (next.done === true) { + this.#decoder.finish(); + throw new Error("protocol input ended before the expected frame"); + } + this.#pending.push(...this.#decoder.push(next.value)); + } + const value = this.#pending.shift()?.value(); + if (!isRecord(value)) throw new Error("validated protocol frame is not an object"); + return value; + } + + write(value: unknown, validate: (raw: Uint8Array) => ValidatedValue): void { + const raw = new TextEncoder().encode(JSON.stringify(value)); + process.stdout.write(encodeLocalWorkerFrame(validate(raw))); + } +} + +class CapabilityClient { + readonly #peer: ProtocolPeer; + readonly #invocationId: string; + #nextRequest = 0; + + constructor(peer: ProtocolPeer, invocationId: string) { + this.#peer = peer; + this.#invocationId = invocationId; + } + + async request(capability: string, input: unknown): Promise { + const requestId = `capability/${this.#nextRequest}`; + this.#nextRequest += 1; + this.#peer.write( + { + protocol: PROTOCOL, + kind: "capability_request", + invocation_id: this.#invocationId, + request_id: requestId, + capability, + input, + }, + validateLocalWorkerCapabilityRequest, + ); + const result = await this.#peer.read(); + if ( + result.protocol !== PROTOCOL || + result.kind !== "capability_result" || + result.invocation_id !== this.#invocationId || + result.request_id !== requestId || + result.capability !== capability || + !isRecord(result.output) + ) { + throw new Error("capability result does not match the outstanding request"); + } + return result.output; + } +} + +export async function runProtocolWorker(): Promise { + const peer = new ProtocolPeer(); + const invocation = await peer.read(); + if ( + invocation.protocol !== PROTOCOL || + invocation.kind !== "invocation" || + typeof invocation.invocation_id !== "string" || + invocation.operation !== "browser-smoke" || + !isRecord(invocation.input) + ) { + throw new Error("expected one browser-smoke invocation"); + } + const invocationId = invocation.invocation_id; + const request = invocation.input as BrowserSmokeRequest; + const capabilities = new CapabilityClient(peer, invocationId); + const bundle = await runBrowserSmoke(JSON.stringify(request), { + clock: { + async now() { + return stringValue(await capabilities.request("clock.now/v1", {}), "value"); + }, + async monotonicMs() { + return numberValue(await capabilities.request("clock.monotonic-ms/v1", {}), "value"); + }, + }, + session: { + async run(input) { + const output = await capabilities.request("browser-session.run/v1", input); + return { + finalUrl: stringValue(output, "finalUrl"), + observedText: stringValue(output, "observedText"), + sanitizedObservationRef: referenceValue(output, "sanitizedObservationRef"), + screenshotEvidenceRef: referenceValue(output, "screenshotEvidenceRef"), + }; + }, + async close() { + const output = await capabilities.request("browser-session.close/v1", {}); + if (Reflect.ownKeys(output).length !== 0) throw new Error("browser close output is not empty"); + }, + }, + evidence: { + async stageGeneratedLog(input) { + if ( + input.name !== "runner.log" || + input.mediaType !== "text/plain; charset=utf-8" || + !equalBytes(input.bytes, FIXED_RUNNER_LOG) + ) { + throw new Error("policy runner log does not match the fixed template"); + } + const output = await capabilities.request("evidence.stage-fixed-runner-log/v1", { + name: input.name, + mediaType: input.mediaType, + template: "fixed-browser-smoke-runner-log/v1", + }); + return referenceValue(output, "runnerLogEvidenceRef"); + }, + }, + }); + peer.write( + { + protocol: PROTOCOL, + kind: "terminal_result", + invocation_id: invocationId, + outcome: "passed", + result: bundle.result, + }, + validateLocalWorkerTerminalResult, + ); +} + +function referenceValue(value: FrameRecord, field: string): DigestBoundRef<"qa.local-evidence/v1"> { + const reference = value[field]; + if (!isRecord(reference)) throw new Error(`${field} is not an object`); + const { kind, id, schema_version, content_digest, version } = reference; + if ( + typeof kind !== "string" || + typeof id !== "string" || + schema_version !== "qa.local-evidence/v1" || + typeof content_digest !== "string" || + (version !== undefined && typeof version !== "string") + ) { + throw new Error(`${field} is not a digest-bound Local Evidence reference`); + } + return { kind, id, schema_version, content_digest, ...(version === undefined ? {} : { version }) }; +} + +function stringValue(value: FrameRecord, field: string): string { + const member = value[field]; + if (typeof member !== "string") throw new Error(`${field} is not a string`); + return member; +} + +function numberValue(value: FrameRecord, field: string): number { + const member = value[field]; + if (typeof member !== "number") throw new Error(`${field} is not a number`); + return member; +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.length === right.length && left.every((byte, index) => byte === right[index]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/local-qa-runtime/workers/src/worker-main.ts b/apps/local-qa-runtime/workers/src/worker-main.ts new file mode 100644 index 000000000..912152c38 --- /dev/null +++ b/apps/local-qa-runtime/workers/src/worker-main.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runProtocolWorker } from "./protocol-worker.js"; + +await runProtocolWorker(); diff --git a/apps/local-qa-runtime/workers/test/browser-smoke.test.mjs b/apps/local-qa-runtime/workers/test/browser-smoke.test.mjs index 139d05df2..50396bee3 100644 --- a/apps/local-qa-runtime/workers/test/browser-smoke.test.mjs +++ b/apps/local-qa-runtime/workers/test/browser-smoke.test.mjs @@ -17,24 +17,24 @@ const requestValue = { }; const requestJson = JSON.stringify(requestValue); const sanitizedObservationRef = { - kind: "sanitized-observation", + kind: "local-sanitized-observation", id: "observation/0", - schema_version: "qa.sanitized-observation/v1", + schema_version: "qa.local-evidence/v1", content_digest: `sha256:${"a".repeat(64)}`, }; -const screenshotArtifactRef = { - kind: "artifact-pointer", - id: "artifact/0", - schema_version: "qa.artifact-pointer/v1", +const screenshotEvidenceRef = { + kind: "local-evidence-object", + id: "evidence/0", + schema_version: "qa.local-evidence/v1", content_digest: "sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6", }; -const runnerLogArtifactRef = { - kind: "artifact-pointer", - id: "artifact/1", - schema_version: "qa.artifact-pointer/v1", +const runnerLogEvidenceRef = { + kind: "local-evidence-object", + id: "evidence/1", + schema_version: "qa.local-evidence/v1", content_digest: "sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e", }; -const expectedSerialized = '{"version":"local-qa-browser-smoke/result-v1","outcome":"passed","observation":{"fixtureUrl":"http://127.0.0.1:43123/fixed-page.html","finalUrl":"http://127.0.0.1:43123/fixed-page.html","selector":"[data-local-qa=\\"status\\"]","expectedText":"READY","observedText":"READY","sanitizedObservationRef":{"kind":"sanitized-observation","id":"observation/0","schema_version":"qa.sanitized-observation/v1","content_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"startedAt":"2026-01-02T03:04:05.000Z","finishedAt":"2026-01-02T03:04:05.012Z","durationMs":12,"evidence":[{"objectId":"evidence/0","role":"screenshot","artifactRef":{"kind":"artifact-pointer","id":"artifact/0","schema_version":"qa.artifact-pointer/v1","content_digest":"sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6"}},{"objectId":"evidence/1","role":"runner-log","artifactRef":{"kind":"artifact-pointer","id":"artifact/1","schema_version":"qa.artifact-pointer/v1","content_digest":"sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e"}}]}'; +const expectedSerialized = '{"version":"local-qa-browser-smoke/result-v1","outcome":"passed","observation":{"fixtureUrl":"http://127.0.0.1:43123/fixed-page.html","finalUrl":"http://127.0.0.1:43123/fixed-page.html","selector":"[data-local-qa=\\"status\\"]","expectedText":"READY","observedText":"READY","sanitizedObservationRef":{"kind":"local-sanitized-observation","id":"observation/0","schema_version":"qa.local-evidence/v1","content_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"startedAt":"2026-01-02T03:04:05.000Z","finishedAt":"2026-01-02T03:04:05.012Z","durationMs":12,"evidence":[{"objectId":"evidence/0","role":"screenshot","artifactRef":{"kind":"local-evidence-object","id":"evidence/0","schema_version":"qa.local-evidence/v1","content_digest":"sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6"}},{"objectId":"evidence/1","role":"runner-log","artifactRef":{"kind":"local-evidence-object","id":"evidence/1","schema_version":"qa.local-evidence/v1","content_digest":"sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e"}}]}'; test("walks the fixed request through pure policy in exact order", async () => { const harness = createHarness(); @@ -64,8 +64,8 @@ test("walks the fixed request through pure policy in exact order", async () => { assert.equal("objects" in bundle, false); assert.deepEqual(bundle.result.observation.sanitizedObservationRef, sanitizedObservationRef); assert.deepEqual(bundle.result.evidence, [ - { objectId: "evidence/0", role: "screenshot", artifactRef: screenshotArtifactRef }, - { objectId: "evidence/1", role: "runner-log", artifactRef: runnerLogArtifactRef }, + { objectId: "evidence/0", role: "screenshot", artifactRef: screenshotEvidenceRef }, + { objectId: "evidence/1", role: "runner-log", artifactRef: runnerLogEvidenceRef }, ]); assert.equal(new TextDecoder().decode(serializeBrowserSmokeResult(bundle.result)), expectedSerialized); }); @@ -237,8 +237,8 @@ test("rejects observed-text mismatch and non-string observations", async () => { test("rejects the complete canonical reference failure matrix", async () => { const matrices = [ - ["sanitizedObservationRef", sanitizedObservationRef, "artifact-pointer", "qa.artifact-pointer/v1"], - ["screenshotArtifactRef", screenshotArtifactRef, "sanitized-observation", "qa.sanitized-observation/v1"], + ["sanitizedObservationRef", sanitizedObservationRef, "local-evidence-object", "qa.local-evidence/v2"], + ["screenshotEvidenceRef", screenshotEvidenceRef, "local-sanitized-observation", "qa.local-evidence/v2"], ]; for (const [field, reference, wrongKind, wrongSchema] of matrices) { for (const invalidReference of invalidReferenceVariants(reference, wrongKind, wrongSchema)) { @@ -253,13 +253,13 @@ test("rejects the complete canonical reference failure matrix", async () => { }); test("accepts optional reference versions and forwards every value unchanged", async () => { - const versionedObservation = { ...sanitizedObservationRef, id: 'observation/"quoted"', version: "obs-v1" }; - const versionedScreenshot = { ...screenshotArtifactRef, id: "artifact/screenshot", version: "screen-v1" }; - const versionedLog = { ...runnerLogArtifactRef, id: "artifact/log", version: "log-v1" }; + const versionedObservation = { ...sanitizedObservationRef, version: "obs-v1" }; + const versionedScreenshot = { ...screenshotEvidenceRef, version: "screen-v1" }; + const versionedLog = { ...runnerLogEvidenceRef, version: "log-v1" }; const harness = createHarness({ sessionResult: validSessionResult({ sanitizedObservationRef: versionedObservation, - screenshotArtifactRef: versionedScreenshot, + screenshotEvidenceRef: versionedScreenshot, }), stagingResult: versionedLog, }); @@ -273,7 +273,6 @@ test("accepts optional reference versions and forwards every value unchanged", a const fragment = `"content_digest":${JSON.stringify(reference.content_digest)},"version":${JSON.stringify(reference.version)}`; assert.equal(serialized.includes(fragment), true); } - assert.equal(serialized.includes('observation/\\"quoted\\"'), true); assert.equal(serialized.endsWith("\n"), false); }); @@ -288,9 +287,9 @@ test("maps staging rejection, validates staged references, and always finalizes" assert.equal(rejected.calls.close, 1); for (const invalidReference of invalidReferenceVariants( - runnerLogArtifactRef, - "sanitized-observation", - "qa.sanitized-observation/v1", + runnerLogEvidenceRef, + "local-sanitized-observation", + "qa.local-evidence/v2", )) { const harness = createHarness({ stagingResult: invalidReference }); await captureWorkerError(() => runBrowserSmoke(requestJson, harness.ports), "evidence.invalid_reference"); @@ -418,7 +417,7 @@ function createHarness(options = {}) { : validSessionResult(); const stagingResult = Object.prototype.hasOwnProperty.call(options, "stagingResult") ? options.stagingResult - : runnerLogArtifactRef; + : runnerLogEvidenceRef; return { events, @@ -482,7 +481,7 @@ function validSessionResult(overrides = {}) { finalUrl: requestValue.fixtureUrl, observedText: "READY", sanitizedObservationRef, - screenshotArtifactRef, + screenshotEvidenceRef, ...overrides, }; } diff --git a/apps/local-qa-runtime/workers/test/protocol-worker.test.mjs b/apps/local-qa-runtime/workers/test/protocol-worker.test.mjs new file mode 100644 index 000000000..dfc87fff2 --- /dev/null +++ b/apps/local-qa-runtime/workers/test/protocol-worker.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const fixture = JSON.parse(await readFile(new URL("../../../../packages/qa-contracts/fixtures/qa.local-worker-protocol/v1/happy-path.json", import.meta.url), "utf8")); +const fromHex = (value) => Buffer.from(value, "hex"); + +test("walks one fragmented invocation through the fixed worker process", async () => { + const child = spawn(process.execPath, [fileURLToPath(new URL("../dist/worker-main.js", import.meta.url))], { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = createFrameReader(child.stdout); + const stderr = []; + child.stderr.on("data", (chunk) => stderr.push(chunk)); + + await writeFragmented(child.stdin, fromHex(fixture.frames[0].wire_hex)); + for (let index = 1; index < 15; index += 2) { + assert.deepEqual(await stdout.read(), fromHex(fixture.frames[index].wire_hex)); + await writeFragmented(child.stdin, fromHex(fixture.frames[index + 1].wire_hex)); + } + assert.deepEqual(await stdout.read(), fromHex(fixture.frames[15].wire_hex)); + child.stdin.end(); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assert.equal(exitCode, 0); + assert.equal(Buffer.concat(stderr).length, 0); + assert.equal(stdout.trailing().length, 0); +}); + +async function writeFragmented(stream, frame) { + for (const chunk of [frame.subarray(0, 1), frame.subarray(1, 5), frame.subarray(5)]) { + if (!stream.write(chunk)) await new Promise((resolve) => stream.once("drain", resolve)); + } +} + +function createFrameReader(stream) { + let buffer = Buffer.alloc(0); + const waiters = []; + stream.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + flush(); + }); + function flush() { + while (waiters.length > 0 && buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < length + 4) return; + const frame = buffer.subarray(0, length + 4); + buffer = buffer.subarray(length + 4); + waiters.shift()(frame); + } + } + return { + read() { + return new Promise((resolve) => { + waiters.push(resolve); + flush(); + }); + }, + trailing() { + return buffer; + }, + }; +} diff --git a/apps/local-qa-runtime/workers/tsconfig.json b/apps/local-qa-runtime/workers/tsconfig.json index 61eb5f67d..672468bfa 100644 --- a/apps/local-qa-runtime/workers/tsconfig.json +++ b/apps/local-qa-runtime/workers/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], - "module": "ESNext", - "moduleResolution": "Bundler", + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, @@ -11,7 +13,11 @@ "noFallthroughCasesInSwitch": true, "isolatedModules": true, "noEmit": true, - "types": [] + "types": [ + "node" + ] }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts" + ] } diff --git a/packages/qa-contracts/README.md b/packages/qa-contracts/README.md index 75ffed91e..9b400a488 100644 --- a/packages/qa-contracts/README.md +++ b/packages/qa-contracts/README.md @@ -56,7 +56,9 @@ The checked-in conformance sources are: canonicalization, and digesting. - `fixtures/qa/local-evidence-v1.json` for Local Evidence canonical bytes, raw Evidence byte identity, contract-object content digests, and reference - binding across the four public Local Evidence types. + binding across the four public Local Evidence types; +- `fixtures/qa.local-worker-protocol/v1/happy-path.json` for the shared logical + and length-prefixed golden transcript of the fixed browser-smoke Worker. The implementations are: @@ -90,6 +92,7 @@ cargo test --manifest-path apps/local-qa-runtime/Cargo.toml \ --workspace --locked -- --nocapture ``` -These libraries provide contract validation and deterministic bytes only. They -do not start a process, expose an endpoint, authorize a request, persist state, -verify signatures, invoke a worker, or perform any other side effect. +These libraries provide contract validation, RFC 8785/JCS bytes, and bounded +`qa.local-worker-protocol/v1` framing only. They do not start a process, expose +an endpoint, authorize a request, persist state, verify signatures, invoke a +worker, or perform any other side effect. diff --git a/packages/qa-contracts/contracts/qa.local-worker-protocol/v1/schema.json b/packages/qa-contracts/contracts/qa.local-worker-protocol/v1/schema.json new file mode 100644 index 000000000..6bbf90187 --- /dev/null +++ b/packages/qa-contracts/contracts/qa.local-worker-protocol/v1/schema.json @@ -0,0 +1,763 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:chronoai:fkst:qa-contracts:qa.local-worker-protocol:v1", + "$defs": { + "InvocationId": { + "type": "string", + "pattern": "^invocation/[0-9]+$", + "maxLength": 64 + }, + "RequestId": { + "type": "string", + "pattern": "^capability/[0-9]+$", + "maxLength": 64 + }, + "EmptyObject": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "BrowserSmokeRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "fixtureUrl", + "selector", + "expectedText", + "timeoutMs" + ], + "properties": { + "version": { + "const": "local-qa-browser-smoke/request-v1" + }, + "fixtureUrl": { + "type": "string", + "pattern": "^http://127\\.0\\.0\\.1:([0-9]+)/fixed-page\\.html$" + }, + "selector": { + "const": "[data-local-qa=\"status\"]" + }, + "expectedText": { + "const": "READY" + }, + "timeoutMs": { + "const": 5000 + } + } + }, + "LocalSanitizedObservationRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "schema_version", + "content_digest" + ], + "properties": { + "kind": { + "const": "local-sanitized-observation" + }, + "id": { + "type": "string", + "pattern": "^observation/[0-9]+$" + }, + "schema_version": { + "const": "qa.local-evidence/v1" + }, + "content_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "version": { + "type": "string" + } + } + }, + "LocalEvidenceObjectRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "schema_version", + "content_digest" + ], + "properties": { + "kind": { + "const": "local-evidence-object" + }, + "id": { + "type": "string", + "pattern": "^evidence/[0-9]+$" + }, + "schema_version": { + "const": "qa.local-evidence/v1" + }, + "content_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "version": { + "type": "string" + } + } + }, + "ClockNowResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + } + } + }, + "MonotonicResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "BrowserRunResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "finalUrl", + "observedText", + "sanitizedObservationRef", + "screenshotEvidenceRef" + ], + "properties": { + "finalUrl": { + "type": "string", + "pattern": "^http://127\\.0\\.0\\.1:([0-9]+)/fixed-page\\.html$" + }, + "observedText": { + "const": "READY" + }, + "sanitizedObservationRef": { + "$ref": "#/$defs/LocalSanitizedObservationRef" + }, + "screenshotEvidenceRef": { + "$ref": "#/$defs/LocalEvidenceObjectRef" + } + } + }, + "StageLogRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "mediaType", + "template" + ], + "properties": { + "name": { + "const": "runner.log" + }, + "mediaType": { + "const": "text/plain; charset=utf-8" + }, + "template": { + "const": "fixed-browser-smoke-runner-log/v1" + } + } + }, + "StageLogResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "runnerLogEvidenceRef" + ], + "properties": { + "runnerLogEvidenceRef": { + "$ref": "#/$defs/LocalEvidenceObjectRef" + } + } + }, + "BrowserSmokeResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "outcome", + "observation", + "startedAt", + "finishedAt", + "durationMs", + "evidence" + ], + "properties": { + "version": { + "const": "local-qa-browser-smoke/result-v1" + }, + "outcome": { + "const": "passed" + }, + "observation": { + "type": "object", + "additionalProperties": false, + "required": [ + "fixtureUrl", + "finalUrl", + "selector", + "expectedText", + "observedText", + "sanitizedObservationRef" + ], + "properties": { + "fixtureUrl": { + "type": "string", + "pattern": "^http://127\\.0\\.0\\.1:([0-9]+)/fixed-page\\.html$" + }, + "finalUrl": { + "type": "string", + "pattern": "^http://127\\.0\\.0\\.1:([0-9]+)/fixed-page\\.html$" + }, + "selector": { + "const": "[data-local-qa=\"status\"]" + }, + "expectedText": { + "const": "READY" + }, + "observedText": { + "const": "READY" + }, + "sanitizedObservationRef": { + "$ref": "#/$defs/LocalSanitizedObservationRef" + } + } + }, + "startedAt": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + }, + "finishedAt": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "evidence": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "objectId", + "role", + "artifactRef" + ], + "properties": { + "objectId": { + "const": "evidence/0" + }, + "role": { + "const": "screenshot" + }, + "artifactRef": { + "$ref": "#/$defs/LocalEvidenceObjectRef" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "objectId", + "role", + "artifactRef" + ], + "properties": { + "objectId": { + "const": "evidence/1" + }, + "role": { + "const": "runner-log" + }, + "artifactRef": { + "$ref": "#/$defs/LocalEvidenceObjectRef" + } + } + } + ], + "items": false + } + } + }, + "LocalWorkerInvocation": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "operation", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "invocation" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "operation": { + "const": "browser-smoke" + }, + "input": { + "$ref": "#/$defs/BrowserSmokeRequest" + } + } + }, + "CapabilityRequest0": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_request" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "clock.now/v1" + }, + "input": { + "$ref": "#/$defs/EmptyObject" + } + } + }, + "CapabilityRequest1": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_request" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "clock.monotonic-ms/v1" + }, + "input": { + "$ref": "#/$defs/EmptyObject" + } + } + }, + "CapabilityRequest2": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_request" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "browser-session.run/v1" + }, + "input": { + "$ref": "#/$defs/BrowserSmokeRequest" + } + } + }, + "CapabilityRequest3": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_request" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "evidence.stage-fixed-runner-log/v1" + }, + "input": { + "$ref": "#/$defs/StageLogRequest" + } + } + }, + "CapabilityRequest4": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "input" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_request" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "browser-session.close/v1" + }, + "input": { + "$ref": "#/$defs/EmptyObject" + } + } + }, + "CapabilityResult0": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "output" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "clock.now/v1" + }, + "output": { + "$ref": "#/$defs/ClockNowResult" + } + } + }, + "CapabilityResult1": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "output" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "clock.monotonic-ms/v1" + }, + "output": { + "$ref": "#/$defs/MonotonicResult" + } + } + }, + "CapabilityResult2": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "output" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "browser-session.run/v1" + }, + "output": { + "$ref": "#/$defs/BrowserRunResult" + } + } + }, + "CapabilityResult3": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "output" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "evidence.stage-fixed-runner-log/v1" + }, + "output": { + "$ref": "#/$defs/StageLogResult" + } + } + }, + "CapabilityResult4": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "request_id", + "capability", + "output" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "capability_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "capability": { + "const": "browser-session.close/v1" + }, + "output": { + "$ref": "#/$defs/EmptyObject" + } + } + }, + "LocalWorkerCapabilityRequest": { + "oneOf": [ + { + "$ref": "#/$defs/CapabilityRequest0" + }, + { + "$ref": "#/$defs/CapabilityRequest1" + }, + { + "$ref": "#/$defs/CapabilityRequest2" + }, + { + "$ref": "#/$defs/CapabilityRequest3" + }, + { + "$ref": "#/$defs/CapabilityRequest4" + } + ] + }, + "LocalWorkerCapabilityResult": { + "oneOf": [ + { + "$ref": "#/$defs/CapabilityResult0" + }, + { + "$ref": "#/$defs/CapabilityResult1" + }, + { + "$ref": "#/$defs/CapabilityResult2" + }, + { + "$ref": "#/$defs/CapabilityResult3" + }, + { + "$ref": "#/$defs/CapabilityResult4" + } + ] + }, + "LocalWorkerTerminalResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "invocation_id", + "outcome", + "result" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "terminal_result" + }, + "invocation_id": { + "$ref": "#/$defs/InvocationId" + }, + "outcome": { + "const": "passed" + }, + "result": { + "$ref": "#/$defs/BrowserSmokeResult" + } + } + }, + "LocalWorkerProtocolFailure": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "kind", + "code" + ], + "properties": { + "protocol": { + "const": "qa.local-worker-protocol/v1" + }, + "kind": { + "const": "protocol_failure" + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "LocalWorkerFrame": { + "oneOf": [ + { + "$ref": "#/$defs/LocalWorkerInvocation" + }, + { + "$ref": "#/$defs/LocalWorkerCapabilityRequest" + }, + { + "$ref": "#/$defs/LocalWorkerCapabilityResult" + }, + { + "$ref": "#/$defs/LocalWorkerTerminalResult" + }, + { + "$ref": "#/$defs/LocalWorkerProtocolFailure" + } + ] + } + } +} diff --git a/packages/qa-contracts/contracts/registry.json b/packages/qa-contracts/contracts/registry.json index 4f1758803..fbe12635e 100644 --- a/packages/qa-contracts/contracts/registry.json +++ b/packages/qa-contracts/contracts/registry.json @@ -17,6 +17,11 @@ "path": "contracts/qa.local-evidence/v1/schema.json", "id": "urn:chronoai:fkst:qa-contracts:qa.local-evidence:v1", "major": 1 + }, + "qa.local-worker-protocol/v1": { + "path": "contracts/qa.local-worker-protocol/v1/schema.json", + "id": "urn:chronoai:fkst:qa-contracts:qa.local-worker-protocol:v1", + "major": 1 } }, "types": { @@ -36,6 +41,12 @@ "LocalSanitizedObservation": { "schema": "qa.local-evidence/v1", "pointer": "#/$defs/LocalSanitizedObservation" }, "LocalEvidenceObject": { "schema": "qa.local-evidence/v1", "pointer": "#/$defs/LocalEvidenceObject" }, "LocalSanitizedObservationRef": { "schema": "qa.local-evidence/v1", "pointer": "#/$defs/LocalSanitizedObservationRef" }, - "LocalEvidenceObjectRef": { "schema": "qa.local-evidence/v1", "pointer": "#/$defs/LocalEvidenceObjectRef" } + "LocalEvidenceObjectRef": { "schema": "qa.local-evidence/v1", "pointer": "#/$defs/LocalEvidenceObjectRef" }, + "LocalWorkerFrame": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerFrame" }, + "LocalWorkerInvocation": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerInvocation" }, + "LocalWorkerCapabilityRequest": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerCapabilityRequest" }, + "LocalWorkerCapabilityResult": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerCapabilityResult" }, + "LocalWorkerTerminalResult": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerTerminalResult" }, + "LocalWorkerProtocolFailure": { "schema": "qa.local-worker-protocol/v1", "pointer": "#/$defs/LocalWorkerProtocolFailure" } } } diff --git a/packages/qa-contracts/fixtures/qa.local-worker-protocol/v1/happy-path.json b/packages/qa-contracts/fixtures/qa.local-worker-protocol/v1/happy-path.json new file mode 100644 index 000000000..9c0736ed2 --- /dev/null +++ b/packages/qa-contracts/fixtures/qa.local-worker-protocol/v1/happy-path.json @@ -0,0 +1,298 @@ +{ + "fixture_version": "qa.local-worker-protocol/happy-path-v1", + "frames": [ + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "kind": "invocation", + "invocation_id": "invocation/0", + "operation": "browser-smoke", + "input": { + "version": "local-qa-browser-smoke/request-v1", + "fixtureUrl": "http://127.0.0.1:43123/fixed-page.html", + "selector": "[data-local-qa=\"status\"]", + "expectedText": "READY", + "timeoutMs": 5000 + } + }, + "canonical_utf8": "{\"input\":{\"expectedText\":\"READY\",\"fixtureUrl\":\"http://127.0.0.1:43123/fixed-page.html\",\"selector\":\"[data-local-qa=\\\"status\\\"]\",\"timeoutMs\":5000,\"version\":\"local-qa-browser-smoke/request-v1\"},\"invocation_id\":\"invocation/0\",\"kind\":\"invocation\",\"operation\":\"browser-smoke\",\"protocol\":\"qa.local-worker-protocol/v1\"}", + "wire_hex": "000001377b22696e707574223a7b22657870656374656454657874223a225245414459222c226669787475726555726c223a22687474703a2f2f3132372e302e302e313a34333132332f66697865642d706167652e68746d6c222c2273656c6563746f72223a225b646174612d6c6f63616c2d71613d5c227374617475735c225d222c2274696d656f75744d73223a353030302c2276657273696f6e223a226c6f63616c2d71612d62726f777365722d736d6f6b652f726571756573742d7631227d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a22696e766f636174696f6e222c226f7065726174696f6e223a2262726f777365722d736d6f6b65222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/0", + "capability": "clock.now/v1", + "kind": "capability_request", + "input": {} + }, + "canonical_utf8": "{\"capability\":\"clock.now/v1\",\"input\":{},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/0\"}", + "wire_hex": "000000a87b226361706162696c697479223a22636c6f636b2e6e6f772f7631222c22696e707574223a7b7d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f30227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/0", + "capability": "clock.now/v1", + "kind": "capability_result", + "output": { + "value": "2026-01-02T03:04:05.000Z" + } + }, + "canonical_utf8": "{\"capability\":\"clock.now/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"value\":\"2026-01-02T03:04:05.000Z\"},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/0\"}", + "wire_hex": "000000ca7b226361706162696c697479223a22636c6f636b2e6e6f772f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2276616c7565223a22323032362d30312d30325430333a30343a30352e3030305a227d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f30227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/1", + "capability": "clock.monotonic-ms/v1", + "kind": "capability_request", + "input": {} + }, + "canonical_utf8": "{\"capability\":\"clock.monotonic-ms/v1\",\"input\":{},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/1\"}", + "wire_hex": "000000b17b226361706162696c697479223a22636c6f636b2e6d6f6e6f746f6e69632d6d732f7631222c22696e707574223a7b7d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f31227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/1", + "capability": "clock.monotonic-ms/v1", + "kind": "capability_result", + "output": { + "value": 1000 + } + }, + "canonical_utf8": "{\"capability\":\"clock.monotonic-ms/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"value\":1000},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/1\"}", + "wire_hex": "000000bd7b226361706162696c697479223a22636c6f636b2e6d6f6e6f746f6e69632d6d732f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2276616c7565223a313030307d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f31227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/2", + "capability": "browser-session.run/v1", + "kind": "capability_request", + "input": { + "version": "local-qa-browser-smoke/request-v1", + "fixtureUrl": "http://127.0.0.1:43123/fixed-page.html", + "selector": "[data-local-qa=\"status\"]", + "expectedText": "READY", + "timeoutMs": 5000 + } + }, + "canonical_utf8": "{\"capability\":\"browser-session.run/v1\",\"input\":{\"expectedText\":\"READY\",\"fixtureUrl\":\"http://127.0.0.1:43123/fixed-page.html\",\"selector\":\"[data-local-qa=\\\"status\\\"]\",\"timeoutMs\":5000,\"version\":\"local-qa-browser-smoke/request-v1\"},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/2\"}", + "wire_hex": "000001657b226361706162696c697479223a2262726f777365722d73657373696f6e2e72756e2f7631222c22696e707574223a7b22657870656374656454657874223a225245414459222c226669787475726555726c223a22687474703a2f2f3132372e302e302e313a34333132332f66697865642d706167652e68746d6c222c2273656c6563746f72223a225b646174612d6c6f63616c2d71613d5c227374617475735c225d222c2274696d656f75744d73223a353030302c2276657273696f6e223a226c6f63616c2d71612d62726f777365722d736d6f6b652f726571756573742d7631227d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f32227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/2", + "capability": "browser-session.run/v1", + "kind": "capability_result", + "output": { + "finalUrl": "http://127.0.0.1:43123/fixed-page.html", + "observedText": "READY", + "sanitizedObservationRef": { + "kind": "local-sanitized-observation", + "id": "observation/0", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "screenshotEvidenceRef": { + "kind": "local-evidence-object", + "id": "evidence/0", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6" + } + } + }, + "canonical_utf8": "{\"capability\":\"browser-session.run/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"finalUrl\":\"http://127.0.0.1:43123/fixed-page.html\",\"observedText\":\"READY\",\"sanitizedObservationRef\":{\"content_digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"id\":\"observation/0\",\"kind\":\"local-sanitized-observation\",\"schema_version\":\"qa.local-evidence/v1\"},\"screenshotEvidenceRef\":{\"content_digest\":\"sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6\",\"id\":\"evidence/0\",\"kind\":\"local-evidence-object\",\"schema_version\":\"qa.local-evidence/v1\"}},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/2\"}", + "wire_hex": "000002a37b226361706162696c697479223a2262726f777365722d73657373696f6e2e72756e2f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2266696e616c55726c223a22687474703a2f2f3132372e302e302e313a34333132332f66697865642d706167652e68746d6c222c226f6273657276656454657874223a225245414459222c2273616e6974697a65644f62736572766174696f6e526566223a7b22636f6e74656e745f646967657374223a227368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c226964223a226f62736572766174696f6e2f30222c226b696e64223a226c6f63616c2d73616e6974697a65642d6f62736572766174696f6e222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d2c2273637265656e73686f7445766964656e6365526566223a7b22636f6e74656e745f646967657374223a227368613235363a34633462366133626531333134616238363133386265663433313464646530323265363030393630643836383961326338663836333138303264323064616236222c226964223a2265766964656e63652f30222c226b696e64223a226c6f63616c2d65766964656e63652d6f626a656374222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d7d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f32227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/3", + "capability": "evidence.stage-fixed-runner-log/v1", + "kind": "capability_request", + "input": { + "name": "runner.log", + "mediaType": "text/plain; charset=utf-8", + "template": "fixed-browser-smoke-runner-log/v1" + } + }, + "canonical_utf8": "{\"capability\":\"evidence.stage-fixed-runner-log/v1\",\"input\":{\"mediaType\":\"text/plain; charset=utf-8\",\"name\":\"runner.log\",\"template\":\"fixed-browser-smoke-runner-log/v1\"},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/3\"}", + "wire_hex": "000001287b226361706162696c697479223a2265766964656e63652e73746167652d66697865642d72756e6e65722d6c6f672f7631222c22696e707574223a7b226d6564696154797065223a22746578742f706c61696e3b20636861727365743d7574662d38222c226e616d65223a2272756e6e65722e6c6f67222c2274656d706c617465223a2266697865642d62726f777365722d736d6f6b652d72756e6e65722d6c6f672f7631227d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f33227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/3", + "capability": "evidence.stage-fixed-runner-log/v1", + "kind": "capability_result", + "output": { + "runnerLogEvidenceRef": { + "kind": "local-evidence-object", + "id": "evidence/1", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e" + } + } + }, + "canonical_utf8": "{\"capability\":\"evidence.stage-fixed-runner-log/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"runnerLogEvidenceRef\":{\"content_digest\":\"sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e\",\"id\":\"evidence/1\",\"kind\":\"local-evidence-object\",\"schema_version\":\"qa.local-evidence/v1\"}},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/3\"}", + "wire_hex": "0000018a7b226361706162696c697479223a2265766964656e63652e73746167652d66697865642d72756e6e65722d6c6f672f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2272756e6e65724c6f6745766964656e6365526566223a7b22636f6e74656e745f646967657374223a227368613235363a62623963363263633834666335333365353231393361383936313737386230626532353163643866313961383962336661383336653934303433613030373565222c226964223a2265766964656e63652f31222c226b696e64223a226c6f63616c2d65766964656e63652d6f626a656374222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d7d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f33227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/4", + "capability": "browser-session.close/v1", + "kind": "capability_request", + "input": {} + }, + "canonical_utf8": "{\"capability\":\"browser-session.close/v1\",\"input\":{},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/4\"}", + "wire_hex": "000000b47b226361706162696c697479223a2262726f777365722d73657373696f6e2e636c6f73652f7631222c22696e707574223a7b7d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f34227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/4", + "capability": "browser-session.close/v1", + "kind": "capability_result", + "output": {} + }, + "canonical_utf8": "{\"capability\":\"browser-session.close/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/4\"}", + "wire_hex": "000000b47b226361706162696c697479223a2262726f777365722d73657373696f6e2e636c6f73652f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b7d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f34227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/5", + "capability": "clock.now/v1", + "kind": "capability_request", + "input": {} + }, + "canonical_utf8": "{\"capability\":\"clock.now/v1\",\"input\":{},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/5\"}", + "wire_hex": "000000a87b226361706162696c697479223a22636c6f636b2e6e6f772f7631222c22696e707574223a7b7d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f35227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/5", + "capability": "clock.now/v1", + "kind": "capability_result", + "output": { + "value": "2026-01-02T03:04:05.012Z" + } + }, + "canonical_utf8": "{\"capability\":\"clock.now/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"value\":\"2026-01-02T03:04:05.012Z\"},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/5\"}", + "wire_hex": "000000ca7b226361706162696c697479223a22636c6f636b2e6e6f772f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2276616c7565223a22323032362d30312d30325430333a30343a30352e3031325a227d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f35227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/6", + "capability": "clock.monotonic-ms/v1", + "kind": "capability_request", + "input": {} + }, + "canonical_utf8": "{\"capability\":\"clock.monotonic-ms/v1\",\"input\":{},\"invocation_id\":\"invocation/0\",\"kind\":\"capability_request\",\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/6\"}", + "wire_hex": "000000b17b226361706162696c697479223a22636c6f636b2e6d6f6e6f746f6e69632d6d732f7631222c22696e707574223a7b7d2c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f72657175657374222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f36227d" + }, + { + "direction": "host_to_worker", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "invocation_id": "invocation/0", + "request_id": "capability/6", + "capability": "clock.monotonic-ms/v1", + "kind": "capability_result", + "output": { + "value": 1012 + } + }, + "canonical_utf8": "{\"capability\":\"clock.monotonic-ms/v1\",\"invocation_id\":\"invocation/0\",\"kind\":\"capability_result\",\"output\":{\"value\":1012},\"protocol\":\"qa.local-worker-protocol/v1\",\"request_id\":\"capability/6\"}", + "wire_hex": "000000bd7b226361706162696c697479223a22636c6f636b2e6d6f6e6f746f6e69632d6d732f7631222c22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a226361706162696c6974795f726573756c74222c226f7574707574223a7b2276616c7565223a313031327d2c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726571756573745f6964223a226361706162696c6974792f36227d" + }, + { + "direction": "worker_to_host", + "value": { + "protocol": "qa.local-worker-protocol/v1", + "kind": "terminal_result", + "invocation_id": "invocation/0", + "outcome": "passed", + "result": { + "version": "local-qa-browser-smoke/result-v1", + "outcome": "passed", + "observation": { + "fixtureUrl": "http://127.0.0.1:43123/fixed-page.html", + "finalUrl": "http://127.0.0.1:43123/fixed-page.html", + "selector": "[data-local-qa=\"status\"]", + "expectedText": "READY", + "observedText": "READY", + "sanitizedObservationRef": { + "kind": "local-sanitized-observation", + "id": "observation/0", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "startedAt": "2026-01-02T03:04:05.000Z", + "finishedAt": "2026-01-02T03:04:05.012Z", + "durationMs": 12, + "evidence": [ + { + "objectId": "evidence/0", + "role": "screenshot", + "artifactRef": { + "kind": "local-evidence-object", + "id": "evidence/0", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6" + } + }, + { + "objectId": "evidence/1", + "role": "runner-log", + "artifactRef": { + "kind": "local-evidence-object", + "id": "evidence/1", + "schema_version": "qa.local-evidence/v1", + "content_digest": "sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e" + } + } + ] + } + }, + "canonical_utf8": "{\"invocation_id\":\"invocation/0\",\"kind\":\"terminal_result\",\"outcome\":\"passed\",\"protocol\":\"qa.local-worker-protocol/v1\",\"result\":{\"durationMs\":12,\"evidence\":[{\"artifactRef\":{\"content_digest\":\"sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6\",\"id\":\"evidence/0\",\"kind\":\"local-evidence-object\",\"schema_version\":\"qa.local-evidence/v1\"},\"objectId\":\"evidence/0\",\"role\":\"screenshot\"},{\"artifactRef\":{\"content_digest\":\"sha256:bb9c62cc84fc533e52193a8961778b0be251cd8f19a89b3fa836e94043a0075e\",\"id\":\"evidence/1\",\"kind\":\"local-evidence-object\",\"schema_version\":\"qa.local-evidence/v1\"},\"objectId\":\"evidence/1\",\"role\":\"runner-log\"}],\"finishedAt\":\"2026-01-02T03:04:05.012Z\",\"observation\":{\"expectedText\":\"READY\",\"finalUrl\":\"http://127.0.0.1:43123/fixed-page.html\",\"fixtureUrl\":\"http://127.0.0.1:43123/fixed-page.html\",\"observedText\":\"READY\",\"sanitizedObservationRef\":{\"content_digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"id\":\"observation/0\",\"kind\":\"local-sanitized-observation\",\"schema_version\":\"qa.local-evidence/v1\"},\"selector\":\"[data-local-qa=\\\"status\\\"]\"},\"outcome\":\"passed\",\"startedAt\":\"2026-01-02T03:04:05.000Z\",\"version\":\"local-qa-browser-smoke/result-v1\"}}", + "wire_hex": "000004b97b22696e766f636174696f6e5f6964223a22696e766f636174696f6e2f30222c226b696e64223a227465726d696e616c5f726573756c74222c226f7574636f6d65223a22706173736564222c2270726f746f636f6c223a2271612e6c6f63616c2d776f726b65722d70726f746f636f6c2f7631222c22726573756c74223a7b226475726174696f6e4d73223a31322c2265766964656e6365223a5b7b226172746966616374526566223a7b22636f6e74656e745f646967657374223a227368613235363a34633462366133626531333134616238363133386265663433313464646530323265363030393630643836383961326338663836333138303264323064616236222c226964223a2265766964656e63652f30222c226b696e64223a226c6f63616c2d65766964656e63652d6f626a656374222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d2c226f626a6563744964223a2265766964656e63652f30222c22726f6c65223a2273637265656e73686f74227d2c7b226172746966616374526566223a7b22636f6e74656e745f646967657374223a227368613235363a62623963363263633834666335333365353231393361383936313737386230626532353163643866313961383962336661383336653934303433613030373565222c226964223a2265766964656e63652f31222c226b696e64223a226c6f63616c2d65766964656e63652d6f626a656374222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d2c226f626a6563744964223a2265766964656e63652f31222c22726f6c65223a2272756e6e65722d6c6f67227d5d2c2266696e69736865644174223a22323032362d30312d30325430333a30343a30352e3031325a222c226f62736572766174696f6e223a7b22657870656374656454657874223a225245414459222c2266696e616c55726c223a22687474703a2f2f3132372e302e302e313a34333132332f66697865642d706167652e68746d6c222c226669787475726555726c223a22687474703a2f2f3132372e302e302e313a34333132332f66697865642d706167652e68746d6c222c226f6273657276656454657874223a225245414459222c2273616e6974697a65644f62736572766174696f6e526566223a7b22636f6e74656e745f646967657374223a227368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c226964223a226f62736572766174696f6e2f30222c226b696e64223a226c6f63616c2d73616e6974697a65642d6f62736572766174696f6e222c22736368656d615f76657273696f6e223a2271612e6c6f63616c2d65766964656e63652f7631227d2c2273656c6563746f72223a225b646174612d6c6f63616c2d71613d5c227374617475735c225d227d2c226f7574636f6d65223a22706173736564222c22737461727465644174223a22323032362d30312d30325430333a30343a30352e3030305a222c2276657273696f6e223a226c6f63616c2d71612d62726f777365722d736d6f6b652f726573756c742d7631227d7d" + } + ] +} diff --git a/packages/qa-contracts/rust/src/lib.rs b/packages/qa-contracts/rust/src/lib.rs index c901b29c8..6bd998ab2 100644 --- a/packages/qa-contracts/rust/src/lib.rs +++ b/packages/qa-contracts/rust/src/lib.rs @@ -21,10 +21,13 @@ const LOCAL_LIFECYCLE_SCHEMA_PATH: &str = "contracts/qa.local-lifecycle/v1/schem const LOCAL_EVIDENCE_SCHEMA: &str = include_str!("../../contracts/qa.local-evidence/v1/schema.json"); const LOCAL_EVIDENCE_SCHEMA_PATH: &str = "contracts/qa.local-evidence/v1/schema.json"; +const LOCAL_WORKER_SCHEMA: &str = include_str!("../../contracts/qa.local-worker-protocol/v1/schema.json"); +const LOCAL_WORKER_SCHEMA_PATH: &str = "contracts/qa.local-worker-protocol/v1/schema.json"; const EMBEDDED_SCHEMAS: &[(&str, &str)] = &[ (FOUNDATION_SCHEMA_PATH, FOUNDATION_SCHEMA), (LOCAL_LIFECYCLE_SCHEMA_PATH, LOCAL_LIFECYCLE_SCHEMA), (LOCAL_EVIDENCE_SCHEMA_PATH, LOCAL_EVIDENCE_SCHEMA), + (LOCAL_WORKER_SCHEMA_PATH, LOCAL_WORKER_SCHEMA), ]; const LOCAL_STATE_TYPE_NAME: &str = "LocalState"; const EXECUTION_OUTCOME_TYPE_NAME: &str = "ExecutionOutcome"; @@ -49,6 +52,15 @@ const LOCAL_EVIDENCE_TYPE_NAMES: [&str; 4] = [ LOCAL_EVIDENCE_OBJECT_REF_TYPE_NAME, ]; const SUPPORTED_SCHEMA_MAJOR: u64 = 1; +pub const LOCAL_WORKER_MAX_FRAME_BYTES: usize = 65_536; +const LOCAL_WORKER_TYPE_NAMES: [&str; 6] = [ + "LocalWorkerFrame", + "LocalWorkerInvocation", + "LocalWorkerCapabilityRequest", + "LocalWorkerCapabilityResult", + "LocalWorkerTerminalResult", + "LocalWorkerProtocolFailure", +]; const MAX_DEPTH: usize = 128; const MAX_SAFE_INTEGER_TEXT: &str = "9007199254740991"; @@ -251,6 +263,107 @@ pub fn validate_local_evidence_object_ref(raw: &[u8]) -> Result Result { + let validated = validate_registered_value(admit_json(raw)?, "LocalWorkerFrame")?; + validate_local_worker_urls(validated.value())?; + Ok(validated) +} + +pub fn validate_local_worker_invocation(raw: &[u8]) -> Result { + let validated = validate_registered_value(admit_json(raw)?, "LocalWorkerInvocation")?; + validate_local_worker_urls(validated.value())?; + Ok(validated) +} + +pub fn validate_local_worker_capability_request( + raw: &[u8], +) -> Result { + let validated = + validate_registered_value(admit_json(raw)?, "LocalWorkerCapabilityRequest")?; + validate_local_worker_urls(validated.value())?; + Ok(validated) +} + +pub fn validate_local_worker_capability_result( + raw: &[u8], +) -> Result { + let validated = validate_registered_value(admit_json(raw)?, "LocalWorkerCapabilityResult")?; + validate_local_worker_urls(validated.value())?; + Ok(validated) +} + +pub fn validate_local_worker_terminal_result(raw: &[u8]) -> Result { + let validated = validate_registered_value(admit_json(raw)?, "LocalWorkerTerminalResult")?; + validate_local_worker_urls(validated.value())?; + Ok(validated) +} + +pub fn validate_local_worker_protocol_failure(raw: &[u8]) -> Result { + validate_registered_value(admit_json(raw)?, "LocalWorkerProtocolFailure") +} + +pub fn encode_local_worker_frame(value: &ValidatedValue) -> Result, ContractError> { + let payload = canonical_bytes(value)?; + if payload.is_empty() || payload.len() > LOCAL_WORKER_MAX_FRAME_BYTES { + return Err(ContractError(Rejection::validation( + "frame_length_out_of_range", + "/", + ))); + } + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + frame.extend_from_slice(&payload); + Ok(frame) +} + +#[derive(Default)] +pub struct LocalWorkerFrameDecoder { + buffer: Vec, +} + +impl LocalWorkerFrameDecoder { + pub fn push(&mut self, chunk: &[u8]) -> Result, ContractError> { + self.buffer.extend_from_slice(chunk); + let mut frames = Vec::new(); + let mut offset = 0; + while self.buffer.len().saturating_sub(offset) >= 4 { + let length = u32::from_be_bytes( + self.buffer[offset..offset + 4] + .try_into() + .expect("four-byte prefix"), + ) as usize; + if length == 0 || length > LOCAL_WORKER_MAX_FRAME_BYTES { + return Err(ContractError(Rejection::validation( + "frame_length_out_of_range", + "/", + ))); + } + if self.buffer.len() - offset - 4 < length { + break; + } + frames.push(validate_local_worker_frame( + &self.buffer[offset + 4..offset + 4 + length], + )?); + offset += 4 + length; + } + if offset > 0 { + self.buffer.drain(..offset); + } + Ok(frames) + } + + pub fn finish(&self) -> Result<(), ContractError> { + if self.buffer.is_empty() { + Ok(()) + } else { + Err(ContractError(Rejection::validation( + "truncated_frame", + "/", + ))) + } + } +} + pub fn validate_value( admitted: AdmittedJson, foundation_type: FoundationType, @@ -410,6 +523,44 @@ fn validate_registry_value(registry: &Registry) -> Result<(), ContractError> { ))); } } + for type_name in LOCAL_WORKER_TYPE_NAMES { + schema_for_registered_type(registry, type_name, embedded_schema)?; + let entry = registry + .types + .get(type_name) + .expect("registered type was resolved above"); + if entry.fixture_only { + return Err(ContractError(Rejection::validation( + "invalid_embedded_registry", + format!("/types/{type_name}"), + ))); + } + } + Ok(()) +} + +fn validate_local_worker_urls(value: &Value) -> Result<(), ContractError> { + match value { + Value::Array(items) => { + for item in items { + validate_local_worker_urls(item)?; + } + } + Value::Object(object) => { + for (key, child) in object { + if matches!(key.as_str(), "fixtureUrl" | "finalUrl") + && !child.as_str().is_some_and(is_fixed_fixture_url) + { + return Err(ContractError(Rejection::validation( + "schema_violation", + format!("/{key}"), + ))); + } + validate_local_worker_urls(child)?; + } + } + _ => {} + } Ok(()) } diff --git a/packages/qa-contracts/rust/tests/local_worker_protocol.rs b/packages/qa-contracts/rust/tests/local_worker_protocol.rs new file mode 100644 index 000000000..ec6bbdaec --- /dev/null +++ b/packages/qa-contracts/rust/tests/local_worker_protocol.rs @@ -0,0 +1,84 @@ +use fkst_qa_contracts::{ + canonical_bytes, contract_registry, encode_local_worker_frame, validate_local_worker_frame, + LocalWorkerFrameDecoder, +}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize)] +struct Fixture { + frames: Vec, +} + +#[derive(Deserialize)] +struct FixtureFrame { + value: Value, + canonical_utf8: String, + wire_hex: String, +} + +fn fixture() -> Fixture { + serde_json::from_str(include_str!( + "../../fixtures/qa.local-worker-protocol/v1/happy-path.json" + )) + .expect("parse shared worker fixture") +} + +fn decode_hex(value: &str) -> Vec { + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16).expect("hex byte") + }) + .collect() +} + +#[test] +fn registers_and_round_trips_the_shared_worker_transcript() { + assert_eq!( + contract_registry().expect("registry")["schemas"]["qa.local-worker-protocol/v1"]["major"], + 1 + ); + for frame in fixture().frames { + let source = serde_json::to_vec(&frame.value).expect("serialize logical frame"); + let validated = validate_local_worker_frame(&source).expect("validate worker frame"); + assert_eq!( + canonical_bytes(&validated).expect("canonical bytes"), + frame.canonical_utf8.as_bytes() + ); + assert_eq!( + encode_local_worker_frame(&validated).expect("encode frame"), + decode_hex(&frame.wire_hex) + ); + } +} + +#[test] +fn decodes_coalesced_and_fragmented_binary_frames() { + let combined: Vec = fixture() + .frames + .iter() + .flat_map(|frame| decode_hex(&frame.wire_hex)) + .collect(); + let mut coalesced = LocalWorkerFrameDecoder::default(); + assert_eq!(coalesced.push(&combined).expect("decode combined").len(), 16); + coalesced.finish().expect("complete combined input"); + + let mut fragmented = LocalWorkerFrameDecoder::default(); + let mut count = 0; + count += fragmented + .push(&combined[..2]) + .expect("prefix fragment") + .len(); + count += fragmented + .push(&combined[2..9]) + .expect("payload fragment") + .len(); + count += fragmented + .push(&combined[9..]) + .expect("remaining frames") + .len(); + assert_eq!(count, 16); + fragmented.finish().expect("complete fragmented input"); +} diff --git a/packages/qa-contracts/src/index.ts b/packages/qa-contracts/src/index.ts index 711b3f67b..5adadc99b 100644 --- a/packages/qa-contracts/src/index.ts +++ b/packages/qa-contracts/src/index.ts @@ -16,6 +16,7 @@ import { const MAX_DEPTH = 128; const MAX_SAFE_INTEGER = 9_007_199_254_740_991n; const SUPPORTED_SCHEMA_MAJOR = 1; +export const LOCAL_WORKER_MAX_FRAME_BYTES = 65_536; const LOCAL_STATE_TYPE_NAME = "LocalState"; const EXECUTION_OUTCOME_TYPE_NAME = "ExecutionOutcome"; const CANCEL_DISPOSITION_TYPE_NAME = "CancelDisposition"; @@ -38,6 +39,14 @@ const LOCAL_EVIDENCE_TYPE_NAMES = Object.freeze([ LOCAL_SANITIZED_OBSERVATION_REF_TYPE_NAME, LOCAL_EVIDENCE_OBJECT_REF_TYPE_NAME, ] as const); +const LOCAL_WORKER_TYPE_NAMES = Object.freeze([ + "LocalWorkerFrame", + "LocalWorkerInvocation", + "LocalWorkerCapabilityRequest", + "LocalWorkerCapabilityResult", + "LocalWorkerTerminalResult", + "LocalWorkerProtocolFailure", +] as const); const FOUNDATION_TYPE_NAMES = Object.freeze([ "ContractMeta", "HostScopedMeta", @@ -144,6 +153,9 @@ for (const type of LIFECYCLE_TYPE_NAMES) { for (const type of LOCAL_EVIDENCE_TYPE_NAMES) { VALIDATORS.set(type, compileRegisteredValidator(REGISTRY, type)); } +for (const type of LOCAL_WORKER_TYPE_NAMES) { + VALIDATORS.set(type, compileRegisteredValidator(REGISTRY, type)); +} export class AdmittedJson { readonly #value: unknown; @@ -244,6 +256,83 @@ export function validateLocalEvidenceObjectRef(raw: Uint8Array): ValidatedValue return validateRegisteredValue(admitJson(raw), LOCAL_EVIDENCE_OBJECT_REF_TYPE_NAME); } +export function validateLocalWorkerFrame(raw: Uint8Array): ValidatedValue { + const validated = validateRegisteredValue(admitJson(raw), "LocalWorkerFrame"); + validateLocalWorkerUrls(validated.value()); + return validated; +} + +export function validateLocalWorkerInvocation(raw: Uint8Array): ValidatedValue { + const validated = validateRegisteredValue(admitJson(raw), "LocalWorkerInvocation"); + validateLocalWorkerUrls(validated.value()); + return validated; +} + +export function validateLocalWorkerCapabilityRequest(raw: Uint8Array): ValidatedValue { + const validated = validateRegisteredValue(admitJson(raw), "LocalWorkerCapabilityRequest"); + validateLocalWorkerUrls(validated.value()); + return validated; +} + +export function validateLocalWorkerCapabilityResult(raw: Uint8Array): ValidatedValue { + const validated = validateRegisteredValue(admitJson(raw), "LocalWorkerCapabilityResult"); + validateLocalWorkerUrls(validated.value()); + return validated; +} + +export function validateLocalWorkerTerminalResult(raw: Uint8Array): ValidatedValue { + const validated = validateRegisteredValue(admitJson(raw), "LocalWorkerTerminalResult"); + validateLocalWorkerUrls(validated.value()); + return validated; +} + +export function validateLocalWorkerProtocolFailure(raw: Uint8Array): ValidatedValue { + return validateRegisteredValue(admitJson(raw), "LocalWorkerProtocolFailure"); +} + +export function encodeLocalWorkerFrame(value: ValidatedValue): Uint8Array { + const payload = canonicalBytes(value); + if (payload.length < 1 || payload.length > LOCAL_WORKER_MAX_FRAME_BYTES) { + throw new ContractError({ category: "validation", reason: "frame_length_out_of_range", path: "/" }); + } + const frame = new Uint8Array(4 + payload.length); + new DataView(frame.buffer).setUint32(0, payload.length, false); + frame.set(payload, 4); + return frame; +} + +export class LocalWorkerFrameDecoder { + #buffer = new Uint8Array(0); + + push(chunk: Uint8Array): readonly ValidatedValue[] { + if (chunk.length > 0) { + const combined = new Uint8Array(this.#buffer.length + chunk.length); + combined.set(this.#buffer); + combined.set(chunk, this.#buffer.length); + this.#buffer = combined; + } + const frames: ValidatedValue[] = []; + let offset = 0; + while (this.#buffer.length - offset >= 4) { + const length = new DataView(this.#buffer.buffer, this.#buffer.byteOffset + offset, 4).getUint32(0, false); + if (length < 1 || length > LOCAL_WORKER_MAX_FRAME_BYTES) { + throw new ContractError({ category: "validation", reason: "frame_length_out_of_range", path: "/" }); + } + if (this.#buffer.length - offset - 4 < length) break; + frames.push(validateLocalWorkerFrame(this.#buffer.slice(offset + 4, offset + 4 + length))); + offset += 4 + length; + } + if (offset > 0) this.#buffer = this.#buffer.slice(offset); + return frames; + } + + finish(): void { + if (this.#buffer.length !== 0) { + throw new ContractError({ category: "validation", reason: "truncated_frame", path: "/" }); + } + } +} + export function validateValue(admitted: AdmittedJson, type: FoundationType): ValidatedValue { const value = admitted.value(); validateSpecialRules(value, type); @@ -397,6 +486,28 @@ function validateRegistry(registry: ContractRegistry): void { throw new Error(`qa contract fixture-only marker is invalid: ${type}`); } } + for (const type of LOCAL_WORKER_TYPE_NAMES) { + const entry = registry.types[type]; + if (entry?.fixture_only !== undefined) { + throw new Error(`qa contract fixture-only marker is invalid: ${type}`); + } + } +} + + +function validateLocalWorkerUrls(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) validateLocalWorkerUrls(item); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value)) { + if ((key === "fixtureUrl" || key === "finalUrl") && + (typeof child !== "string" || !isFixedFixtureUrl(child))) { + throw new ContractError({ category: "validation", reason: "schema_violation", path: `/${key}` }); + } + validateLocalWorkerUrls(child); + } } function validateLocalSanitizedObservationRules(value: LocalSanitizedObservation): void { diff --git a/packages/qa-contracts/test/local-worker-protocol.test.ts b/packages/qa-contracts/test/local-worker-protocol.test.ts new file mode 100644 index 000000000..47542d832 --- /dev/null +++ b/packages/qa-contracts/test/local-worker-protocol.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + LocalWorkerFrameDecoder, + canonicalBytes, + contractRegistry, + encodeLocalWorkerFrame, + validateLocalWorkerFrame, +} from "../src/index.js"; + +type FixtureFrame = { + readonly value: unknown; + readonly canonical_utf8: string; + readonly wire_hex: string; +}; + +type Fixture = { readonly frames: readonly FixtureFrame[] }; + +const fixture = JSON.parse( + readFileSync(new URL("../../fixtures/qa.local-worker-protocol/v1/happy-path.json", import.meta.url), "utf8"), +) as Fixture; +const encoder = new TextEncoder(); + +function fromHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/../g)?.map((pair) => Number.parseInt(pair, 16)) ?? []); +} + +test("registers and round-trips the shared worker transcript", () => { + assert.equal(contractRegistry().schemas["qa.local-worker-protocol/v1"]?.major, 1); + for (const frame of fixture.frames) { + const validated = validateLocalWorkerFrame(encoder.encode(JSON.stringify(frame.value))); + assert.equal(new TextDecoder().decode(canonicalBytes(validated)), frame.canonical_utf8); + assert.deepEqual(encodeLocalWorkerFrame(validated), fromHex(frame.wire_hex)); + } +}); + +test("decodes coalesced and fragmented binary frames without newline semantics", () => { + const wires = fixture.frames.map((frame) => fromHex(frame.wire_hex)); + const combined = new Uint8Array(wires.reduce((total, wire) => total + wire.length, 0)); + let offset = 0; + for (const wire of wires) { + combined.set(wire, offset); + offset += wire.length; + } + const coalesced = new LocalWorkerFrameDecoder(); + assert.equal(coalesced.push(combined).length, fixture.frames.length); + coalesced.finish(); + + const fragmented = new LocalWorkerFrameDecoder(); + const decoded = [ + ...fragmented.push(combined.slice(0, 2)), + ...fragmented.push(combined.slice(2, 9)), + ...fragmented.push(combined.slice(9)), + ]; + assert.equal(decoded.length, fixture.frames.length); + fragmented.finish(); + assert.equal(fixture.frames[0]?.canonical_utf8.includes("\\n"), false); +});