343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { generateStatefulTrace } from "./stateful-workload";
|
|
import { compileProgram, type Program } from "./model";
|
|
import type {
|
|
ExpectedDocument,
|
|
TestDefinition,
|
|
TestStep
|
|
} from "../../deterministic-tests/src/test-definition";
|
|
import { Random } from "../../test-support/random";
|
|
|
|
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
|
|
): LegacyTrace {
|
|
const random = new Random(seed);
|
|
const clients = 2 + random.int(2);
|
|
const steps: TestStep[] = [];
|
|
const documents: Document[] = [];
|
|
const markers: string[] = [];
|
|
const removed: string[] = [];
|
|
let serial = 0;
|
|
const newDocument = (path: string): Document => {
|
|
const key = `seed-${seed}-document-${serial++}`;
|
|
const marker = `[${key}]`;
|
|
markers.push(marker);
|
|
return {
|
|
key,
|
|
path,
|
|
content: `${marker}\n\nfirst paragraph\n\nlast paragraph\n`
|
|
};
|
|
};
|
|
const all = (type: "enable-sync" | "disable-sync") => {
|
|
for (let client = 0; client < clients; client++)
|
|
steps.push({ type, client });
|
|
};
|
|
const expected = () =>
|
|
steps.push({
|
|
type: "assert-documents",
|
|
expected: structuredClone(documents)
|
|
});
|
|
const barrier = () => {
|
|
steps.push({ type: "barrier" });
|
|
expected();
|
|
};
|
|
const forget = (doc: Document) => {
|
|
for (const marker of markers)
|
|
if (doc.content.includes(marker) && !removed.includes(marker))
|
|
removed.push(marker);
|
|
documents.splice(documents.indexOf(doc), 1);
|
|
};
|
|
all("enable-sync");
|
|
steps.push({ type: "barrier" });
|
|
// A lasting pool makes later actions interact with earlier versions/moves.
|
|
for (const path of ["shared/a.md", "日本語/b.md", "café/c.md"]) {
|
|
const doc = newDocument(path);
|
|
documents.push(doc);
|
|
steps.push({ type: "create", client: 0, path, content: doc.content });
|
|
}
|
|
barrier();
|
|
let choices: number[] = [];
|
|
for (let round = 0; round < iterations; round++) {
|
|
if (!choices.length) {
|
|
choices = Array.from({ length: 9 }, (_, i) => i);
|
|
for (let i = choices.length - 1; i > 0; i--) {
|
|
const j = random.int(i + 1);
|
|
[choices[i], choices[j]] = [choices[j], choices[i]];
|
|
}
|
|
}
|
|
const kind = choices.pop()!;
|
|
const writer = random.int(clients);
|
|
const other = (writer + 1 + random.int(clients - 1)) % clients;
|
|
const doc = random.pick(documents.filter((entry) => !entry.conflict));
|
|
const directory = `round-${round}/${random.pick(["nested", "日本語", "café", "space folder"])}`;
|
|
if (kind === 0) {
|
|
all("disable-sync");
|
|
const first = newDocument(`${directory}/same.md`);
|
|
const second = { ...newDocument(first.path), conflict: true };
|
|
steps.push(
|
|
{
|
|
type: "create",
|
|
client: writer,
|
|
path: first.path,
|
|
content: first.content
|
|
},
|
|
{
|
|
type: "create",
|
|
client: other,
|
|
path: second.path,
|
|
content: second.content
|
|
},
|
|
{
|
|
type: "drop-response",
|
|
client: writer,
|
|
kind: random.pick(["create", "manifest"] as const),
|
|
point: random.pick(["before", "after"] as const)
|
|
},
|
|
{ type: "enable-sync", client: writer },
|
|
{ type: "sync", client: writer },
|
|
{ type: "wait-for-response-drop", client: writer }
|
|
);
|
|
all("enable-sync");
|
|
documents.push(first, second);
|
|
} else if (kind === 1) {
|
|
// Both clients edit the same established version, at opposite ends.
|
|
all("disable-sync");
|
|
const prefix = `remote prefix ${round}\n\n`;
|
|
const suffix = `\n\nlocal suffix ${round}\n`;
|
|
steps.push(
|
|
{
|
|
type: "update",
|
|
client: writer,
|
|
path: doc.path,
|
|
content: doc.content + suffix
|
|
},
|
|
{
|
|
type: "update",
|
|
client: other,
|
|
path: doc.path,
|
|
content: prefix + doc.content
|
|
},
|
|
{ type: "enable-sync", client: writer }
|
|
);
|
|
all("enable-sync");
|
|
doc.content = prefix + doc.content + suffix;
|
|
} else if (kind === 2) {
|
|
all("disable-sync");
|
|
const target = `${directory}/moved.md`;
|
|
doc.content += `\nremote addition ${round}\n`;
|
|
steps.push(
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: doc.path,
|
|
newPath: target
|
|
},
|
|
{
|
|
type: "update",
|
|
client: other,
|
|
path: doc.path,
|
|
content: doc.content
|
|
},
|
|
{ type: "enable-sync", client: writer }
|
|
);
|
|
all("enable-sync");
|
|
doc.path = target;
|
|
} else if (kind === 3) {
|
|
const second = random.pick(
|
|
documents.filter((entry) => !entry.conflict && entry !== doc)
|
|
);
|
|
steps.push(
|
|
{ type: "disable-sync", client: writer },
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: doc.path,
|
|
newPath: `${directory}/temporary.md`
|
|
},
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: second.path,
|
|
newPath: doc.path
|
|
},
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: `${directory}/temporary.md`,
|
|
newPath: second.path
|
|
},
|
|
{ type: "enable-sync", client: writer }
|
|
);
|
|
[doc.path, second.path] = [second.path, doc.path];
|
|
} else if (kind === 4) {
|
|
// Reuse the same path but require a new UUID, even in later rounds.
|
|
steps.push({ type: "delete", client: writer, path: doc.path });
|
|
forget(doc);
|
|
barrier();
|
|
const replacement = newDocument(doc.path);
|
|
steps.push({
|
|
type: "create",
|
|
client: other,
|
|
path: replacement.path,
|
|
content: replacement.content
|
|
});
|
|
documents.push(replacement);
|
|
} else if (kind === 5) {
|
|
const target = `${directory}/rapid.md`;
|
|
steps.push(
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: doc.path,
|
|
newPath: target
|
|
},
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: target,
|
|
newPath: doc.path
|
|
},
|
|
{
|
|
type: "rename",
|
|
client: writer,
|
|
oldPath: doc.path,
|
|
newPath: target
|
|
}
|
|
);
|
|
doc.path = target;
|
|
doc.content += `\nrapid edit ${round}\n`;
|
|
steps.push({
|
|
type: "update",
|
|
client: writer,
|
|
path: doc.path,
|
|
content: doc.content
|
|
});
|
|
} else if (kind === 6) {
|
|
steps.push({
|
|
type: "drop-response",
|
|
client: writer,
|
|
kind: "content",
|
|
point: random.pick(["before", "after"] as const)
|
|
});
|
|
doc.content += `\nretry edit ${round}\n`;
|
|
steps.push(
|
|
{
|
|
type: "update",
|
|
client: writer,
|
|
path: doc.path,
|
|
content: doc.content
|
|
},
|
|
{ type: "wait-for-response-drop", client: writer }
|
|
);
|
|
} else if (kind === 7) {
|
|
// Keep an editor event delayed across reconnection and another edit.
|
|
steps.push(
|
|
{ type: "disable-sync", client: writer },
|
|
{ type: "delay-notifications", client: writer }
|
|
);
|
|
doc.content += `\ndelayed local edit ${round}\n`;
|
|
steps.push(
|
|
{
|
|
type: "update",
|
|
client: writer,
|
|
path: doc.path,
|
|
content: doc.content
|
|
},
|
|
{ type: "enable-sync", client: writer }
|
|
);
|
|
barrier();
|
|
doc.content += `\nlater remote edit ${round}\n`;
|
|
steps.push({
|
|
type: "update",
|
|
client: other,
|
|
path: doc.path,
|
|
content: doc.content
|
|
});
|
|
barrier();
|
|
steps.push({ type: "flush-notifications", client: writer });
|
|
} else {
|
|
// Explicit replacement retains the UUID. Only markers in the
|
|
// overwritten bytes are exempted from preservation.
|
|
for (const marker of markers)
|
|
if (doc.content.includes(marker) && !removed.includes(marker))
|
|
removed.push(marker);
|
|
doc.content = newDocument(doc.path).content;
|
|
steps.push({
|
|
type: "update",
|
|
client: writer,
|
|
path: doc.path,
|
|
content: doc.content
|
|
});
|
|
}
|
|
barrier();
|
|
steps.push({
|
|
type: "assert-markers",
|
|
markers: [...markers],
|
|
removed: [...removed]
|
|
});
|
|
if (random.int(4) === 0) {
|
|
steps.push({ type: "reset", client: random.int(clients) });
|
|
barrier();
|
|
}
|
|
}
|
|
return { version: 1, seed, iterations, clients, steps };
|
|
}
|
|
|
|
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 &&
|
|
(!program || trace.clients !== program.clients)) ||
|
|
(trace.clients !== undefined &&
|
|
(!Number.isInteger(trace.clients) || trace.clients < 2))
|
|
)
|
|
throw new Error("Invalid/empty replay trace");
|
|
return {
|
|
clients: trace.clients ?? 2,
|
|
description: `Seed ${trace.seed}; ${trace.iterations} generated rounds`,
|
|
steps: trace.version === 2 ? compileProgram(trace.program) : trace.steps
|
|
};
|
|
}
|
|
|
|
export const generateTrace = generateStatefulTrace;
|