diff --git a/frontend/test-client/README.md b/frontend/test-client/README.md index d58b0e85..277a6015 100644 --- a/frontend/test-client/README.md +++ b/frontend/test-client/README.md @@ -19,7 +19,9 @@ metadata, visible/durable disk images and CAS requests before cleanup. ## Workload and oracle -Version 2 traces contain an editor-intent program and its compiled steps. +Version 2 traces store the editor-intent program once. Replay compiles its steps +and full-state checkpoints, avoiding repeated file contents in large artifacts. +Older version 2 traces with compiled steps still replay from their program. `model.ts` independently interprets that intent into complete expected file sets: exact bytes, paths and persistent identity keys. It imports no production merge implementation and never adopts observed output as the expected state. Deleted diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 1b8dbced..24683c6c 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -6,8 +6,12 @@ import { Logger } from "sync-client"; import { ServerControl } from "../../deterministic-tests/src/server-control"; import { ServerManager } from "../../deterministic-tests/src/server-manager"; import { TestRunner } from "../../deterministic-tests/src/test-runner"; -import { generateTrace, traceTest, type Trace } from "./workload"; -import { compileProgram } from "./model"; +import { + generateTrace, + serializeTrace, + traceTest, + type Trace +} from "./workload"; import { minimizeProgram } from "./minimize"; function integer(value: string, zero = false): number { @@ -44,8 +48,7 @@ async function main(): Promise { : fs.mkdtempSync(path.join(os.tmpdir(), "vault-link-fuzz-")); fs.mkdirSync(artifacts, { recursive: true }); const tracePath = path.join(artifacts, `trace-${trace.seed}.json`); - if (trace.version === 2) trace.steps = compileProgram(trace.program!); - fs.writeFileSync(tracePath, JSON.stringify(trace, null, 2)); + fs.writeFileSync(tracePath, serializeTrace(trace)); console.log(`Replay: node ${__filename} --replay ${tracePath}`); const logger = new Logger(); logger.onLogEmitted.add((line) => console.log(line.message)); @@ -72,7 +75,7 @@ async function main(): Promise { JSON.stringify(result, null, 2) ); if (!result.success) { - if (trace.program && !values["no-shrink"]) { + if (trace.version === 2 && !values["no-shrink"]) { try { const reduced = await minimizeProgram( trace.program, @@ -82,8 +85,7 @@ async function main(): Promise { await server.start(); const candidate = { ...trace, - program, - steps: compileProgram(program) + program }; const outcome = await new TestRunner( server, @@ -104,17 +106,13 @@ async function main(): Promise { ); const minimized = { ...trace, - program: reduced.program, - steps: compileProgram(reduced.program) + program: reduced.program }; const minimizedPath = path.join( artifacts, `minimized-${trace.seed}.json` ); - fs.writeFileSync( - minimizedPath, - JSON.stringify(minimized, null, 2) - ); + fs.writeFileSync(minimizedPath, serializeTrace(minimized)); console.log( `Reduced in ${reduced.attempts} attempts: node ${__filename} --replay ${minimizedPath}` ); diff --git a/frontend/test-client/src/stateful-workload.test.ts b/frontend/test-client/src/stateful-workload.test.ts index 38e0630f..637b3c20 100644 --- a/frontend/test-client/src/stateful-workload.test.ts +++ b/frontend/test-client/src/stateful-workload.test.ts @@ -1,9 +1,42 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { generateTrace, traceTest } from "./workload"; +import { generateTrace, serializeTrace, traceTest } from "./workload"; import { compileProgram, type Program } from "./model"; import { minimizeProgram } from "./minimize"; +test("seed 1329 at 500 iterations serializes a compact, complete replay", () => { + const trace = generateTrace(1329, 500); + const serialized = serializeTrace(trace); + assert.equal(serialized, JSON.stringify(trace, null, 2)); + assert( + serialized.length < 10_000_000, + "Trace should store editor intent once" + ); + const replay: unknown = JSON.parse(serialized); + assert.deepEqual(replay, trace); + const { steps } = traceTest(replay); + assert.equal(steps.length, 11883); + assert.equal( + steps.filter((step) => step.type === "assert-documents").length, + 720 + ); +}); + +test("older version 2 traces discard compiled steps when saved and replay from intent", () => { + const trace = generateTrace(0, 1); + const expected = traceTest(trace); + const oldTrace = { ...trace, steps: expected.steps }; + const serialized = serializeTrace(oldTrace); + assert.equal(serialized, serializeTrace(trace)); + assert.deepEqual(traceTest(JSON.parse(serialized)), expected); + assert.equal( + oldTrace.steps, + expected.steps, + "Saving must not mutate the trace" + ); + assert.deepEqual(traceTest({ ...oldTrace, steps: [] }), expected); +}); + test("stateful programs replay primitive batches, crash boundaries, byte domains and conflict histories", () => { const seen = new Set(); let large = false, @@ -19,9 +52,8 @@ test("stateful programs replay primitive batches, crash boundaries, byte domains traceTest(trace), traceTest(JSON.parse(JSON.stringify(trace))) ); - assert(trace.program); large ||= trace.program.initial.length >= 70; - for (const step of trace.steps) seen.add(step.type); + for (const step of traceTest(trace).steps) seen.add(step.type); for (const epoch of trace.program.epochs) { assert(epoch.actions.length); for (let i = 0; i < epoch.actions.length; i++) { @@ -123,7 +155,7 @@ test("shrinking retains the failing editor intent and regenerates a complete ind test("shrinking rejects dependency-invalid histories and different failure mechanisms", async () => { const trace = generateTrace(0, 1); - const original = trace.program!; + const original = trace.program; const reduced = await minimizeProgram( original, "Byte mismatch at a.md", diff --git a/frontend/test-client/src/stateful-workload.ts b/frontend/test-client/src/stateful-workload.ts index ee562a27..3e9abd34 100644 --- a/frontend/test-client/src/stateful-workload.ts +++ b/frontend/test-client/src/stateful-workload.ts @@ -1,13 +1,12 @@ import { Random } from "../../test-support/random"; import { - compileProgram, ReferenceModel, type Content, type Epoch, type Mutation, type Program } from "./model"; -import type { Trace } from "./workload"; +import type { StatefulTrace } from "./workload"; const words = [ "alpha", @@ -42,7 +41,10 @@ export function randomContent(random: Random): Content { /** Random primitive histories share a persistent model, including conflict * copies. Only checkpoints settle the system; each epoch composes 2–12 edits. */ -export function generateStatefulTrace(seed: number, iterations: number): Trace { +export function generateStatefulTrace( + seed: number, + iterations: number +): StatefulTrace { const random = new Random(seed); const clients = 3; const model = new ReferenceModel(); @@ -475,7 +477,6 @@ export function generateStatefulTrace(seed: number, iterations: number): Trace { seed, iterations, clients, - program, - steps: compileProgram(program) + program }; } diff --git a/frontend/test-client/src/workload.test.ts b/frontend/test-client/src/workload.test.ts index 4663756f..ce4d64fc 100644 --- a/frontend/test-client/src/workload.test.ts +++ b/frontend/test-client/src/workload.test.ts @@ -1,14 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { generateLegacyTrace as generateTrace, traceTest } from "./workload"; +import { + generateLegacyTrace as generateTrace, + serializeTrace, + traceTest +} from "./workload"; test("concrete traces replay all choices and retain preservation assertions with delayed events", () => { const trace = generateTrace(42, 12); + assert.equal(serializeTrace(trace), JSON.stringify(trace, null, 2)); assert.deepEqual(trace, generateTrace(42, 12)); assert.notDeepEqual(trace, generateTrace(43, 12)); assert.deepEqual( traceTest(trace), - traceTest(JSON.parse(JSON.stringify(trace))) + traceTest(JSON.parse(serializeTrace(trace))) ); assert.equal( trace.steps.filter((step) => step.type === "assert-markers").length, diff --git a/frontend/test-client/src/workload.ts b/frontend/test-client/src/workload.ts index 7aecf9d8..fdb47eef 100644 --- a/frontend/test-client/src/workload.ts +++ b/frontend/test-client/src/workload.ts @@ -7,23 +7,48 @@ import type { } from "../../deterministic-tests/src/test-definition"; import { Random } from "../../test-support/random"; -export interface Trace { - version: 1 | 2; - program?: Program; +interface TraceMetadata { seed: number; iterations: number; +} + +export interface LegacyTrace extends TraceMetadata { + version: 1; /** Old traces omit this and retain their original two-client behavior. */ clients?: number; steps: TestStep[]; } +export interface StatefulTrace extends TraceMetadata { + version: 2; + clients: number; + program: Program; + /** Older artifacts included these; replay always compiles the program. */ + steps?: TestStep[]; +} + +export type Trace = LegacyTrace | StatefulTrace; + +/** Full-state checkpoints can exceed V8's string limit on long campaigns. + * Persist their source program once, including when rewriting old artifacts. */ +export function serializeTrace(trace: Trace): string { + return JSON.stringify( + trace.version === 2 ? { ...trace, steps: undefined } : trace, + null, + 2 + ); +} + type Document = ExpectedDocument & { content: string }; /** Concrete actions and independent expected states are serialized together. * Every round checks the whole history, including earlier bytes and UUIDs. * A shuffled cycle covers nine actions; most revisit existing documents. */ -export function generateLegacyTrace(seed: number, iterations: number): Trace { +export function generateLegacyTrace( + seed: number, + iterations: number +): LegacyTrace { const random = new Random(seed); const clients = 2 + random.int(2); const steps: TestStep[] = []; @@ -297,12 +322,13 @@ export function generateLegacyTrace(seed: number, iterations: number): Trace { } export function traceTest(trace: Trace): TestDefinition { + const program = trace.version === 2 ? trace.program : undefined; if ( ![1, 2].includes(trace.version) || (trace.version === 1 && (!Array.isArray(trace.steps) || !trace.steps.length)) || (trace.version === 2 && - (!trace.program || trace.clients !== trace.program.clients)) || + (!program || trace.clients !== program.clients)) || (trace.clients !== undefined && (!Number.isInteger(trace.clients) || trace.clients < 2)) ) @@ -310,8 +336,7 @@ export function traceTest(trace: Trace): TestDefinition { return { clients: trace.clients ?? 2, description: `Seed ${trace.seed}; ${trace.iterations} generated rounds`, - steps: - trace.version === 2 ? compileProgram(trace.program!) : trace.steps + steps: trace.version === 2 ? compileProgram(trace.program) : trace.steps }; }