Public: anyone can read this; the crew can change it.

Fast test/chaos.test.ts: the seeded run of every fault under 1 s #1443

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1439-fast-test-chaos-test-ts-the-seeded-run-o → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/1439-fast-test-chaos-test-ts-the-seeded-run-o was merged into main as 90188ea.

Review

Sign in to review this change.
4 files changed +104 −28
src/chaos/invariants.ts +3
@@ -15,6 +15,8 @@ import { SEALED_VALUE, type World } from "./world.ts";
1515
1616 export interface Invariant {
1717 readonly name: string;
18 /** Costly: checked once at the end of a night, not after every fault. */
19 readonly final?: true;
1820 check(world: World): Promise<string[]>;
1921 }
2022
@@ -71,6 +73,7 @@ const pullsKeepHeads: Invariant = {
7173 /** A snapshot taken now restores into an empty directory with the same runs. */
7274 const snapshotRestores: Invariant = {
7375 name: "a snapshot taken after restores",
76 final: true,
7477 async check(world) {
7578 const root = mkdtempSync(join(tmpdir(), "forge-chaos-snap-"));
7679 try {
src/chaos/run.ts +21 −9
@@ -12,9 +12,9 @@ import { mkdtempSync, rmSync } from "node:fs";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
1414 import { FAULTS, type Fault } from "./faults.ts";
15 import { INVARIANTS } from "./invariants.ts";
15 import { INVARIANTS, type Invariant } from "./invariants.ts";
1616 import { fileReport, type Finding } from "./report.ts";
17 import { bootWorld } from "./world.ts";
17 import { bootWorld, type World } from "./world.ts";
1818
1919 /** mulberry32: a seed in, the same sequence out. */
2020 export function rng(seed: number): () => number {
@@ -35,18 +35,33 @@ export interface Night {
3535 readonly log: readonly string[];
3636 }
3737
38 export interface Options {
39 /** A world already booted (the caller closes it); default is a fresh one per night. */
40 readonly world?: World;
41 readonly invariants?: readonly Invariant[];
42 }
43
3844 export async function chaos(
3945 seed: number,
4046 rounds = FAULTS.length,
4147 faults: readonly Fault[] = FAULTS,
48 options: Options = {},
4249 ): Promise<Night> {
50 const invariants = options.invariants ?? INVARIANTS;
4351 const rand = rng(seed);
4452 const root = mkdtempSync(join(tmpdir(), "forge-chaos-"));
4553 const log: string[] = [];
4654 const findings: Finding[] = [];

The root = "" sentinel as a flag for 'nothing to clean' is awkward; use an undefined/nullable root or split the owned-world path out.

4755 const order: string[] = [];
4856 try {
49 const world = await bootWorld(join(root, "data"));
57 const world = options.world ?? (await bootWorld(join(root, "data")));
58 const assertAll = async (which: readonly Invariant[], fault: string): Promise<void> => {
59 for (const invariant of which) {
60 const broken = await invariant.check(world).catch((e) => [`check threw: ${String(e)}`]);
61 log.push(` ${broken.length === 0 ? "holds" : "BROKEN"}: ${invariant.name}`);
62 if (broken.length > 0) findings.push({ fault, broken: broken.map((b) => `${invariant.name}: ${b}`) });
63 }
64 };
5065 try {
5166 for (let i = 0; i < rounds; i++) {
5267 const fault = faults[Math.floor(rand() * faults.length)]!;
@@ -59,14 +74,11 @@ export async function chaos(
5974 log.push(` failed: ${String(error)}`);
6075 continue;
6176 }
62 for (const invariant of INVARIANTS) {
63 const broken = await invariant.check(world).catch((e) => [`check threw: ${String(e)}`]);
64 log.push(` ${broken.length === 0 ? "holds" : "BROKEN"}: ${invariant.name}`);
65 if (broken.length > 0) findings.push({ fault: fault.name, broken: broken.map((b) => `${invariant.name}: ${b}`) });
66 }
77 await assertAll(invariants.filter((i) => i.final !== true), fault.name);

Final invariants are skipped when order is empty (every fault threw before being recorded). Say so in a comment, or run them regardless.

6778 }
79 if (order.length > 0) await assertAll(invariants.filter((i) => i.final === true), order[order.length - 1]!);
6880 } finally {
69 world.app.close();
81 if (options.world === undefined) world.app.close();
7082 }
7183 } finally {
7284 rmSync(root, { recursive: true, force: true });
src/chaos/world.ts −1
@@ -29,7 +29,6 @@ export async function bootWorld(dataDir: string): Promise<World> {
2929 trusted: true,
3030 username: "chaos",
3131 email: "[email protected]",
32 password: "a-good-passphrase",
3332 adminIfFirst: true,
3433 });
3534 if (!made.ok) throw new Error(made.error);
test/chaos.test.ts +80 −18
@@ -2,18 +2,40 @@
22 * The small chaos in the gate: every fault once, on a side instance in a
33 * temporary directory, with every invariant asserted after each.
44 */
5 import { describe, expect, test } from "bun:test";
6 import { FAULTS, slowProxy } from "../src/chaos/faults.ts";
7 import { INVARIANTS } from "../src/chaos/invariants.ts";
5 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6 import { FAULTS, slowProxy, type Fault } from "../src/chaos/faults.ts";
7 import { INVARIANTS, type Invariant } from "../src/chaos/invariants.ts";
88 import { issueFor, quietLine } from "../src/chaos/report.ts";
99 import { chaos, rng } from "../src/chaos/run.ts";
10 import { bootWorld, SEALED_VALUE } from "../src/chaos/world.ts";
10 import { bootWorld, SEALED_VALUE, type World } from "../src/chaos/world.ts";
1111 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
1414
1515 const SEED = Number(process.env["CHAOS_SEED"] ?? 1244);
1616
17 let root: string;
18 let world: World;
19
20 // The per-fault invariants; the snapshot one is costly and runs once per night.
21 const quick = INVARIANTS.filter((i) => i.final !== true);
22
23 // One world for the file. Faults share it, so after every test the cheap
24 // invariants are asserted: a test that leaves damage behind fails itself,
25 // not whichever test runs next.
26 afterEach(async () => {
27 const broken = (await Promise.all(quick.map((i) => i.check(world)))).flat();
28 expect(broken).toEqual([]);
29 });
30 beforeAll(async () => {
31 root = mkdtempSync(join(tmpdir(), "forge-chaos-"));
32 world = await bootWorld(join(root, "data"));
33 });
34 afterAll(() => {
35 world.app.close();
36 rmSync(root, { recursive: true, force: true });
37 });
38
1739 describe("chaos", () => {
1840 test("the rng repeats from a seed", () => {
1941 const a = rng(7);
@@ -21,18 +43,58 @@ describe("chaos", () => {
2143 expect([a(), a(), a()]).toEqual([b(), b(), b()]);
2244 });
2345
46 const stub = (name: string): Fault => ({ name, inject: async () => {} });
47 const stubs = [stub("a"), stub("b"), stub("c")];
48
49 test("the same seed picks the same fault order", async () => {
50 const a = await chaos(SEED, 5, stubs, { world, invariants: [] });
outdated

Stubs with invariants: [] mean this no longer exercises the faults seeded together; it duplicates the rng test. Keep one real multi-fault night, or drop the test.

51 const b = await chaos(SEED, 5, stubs, { world, invariants: [] });
52 expect(a.faults).toHaveLength(5);
53 expect(a.faults).toEqual(b.faults);
54 });
55
56 test("a final invariant is checked once after the last fault, the others every round", async () => {
57 const events: string[] = [];
58 const spy = (name: string, final: boolean): Invariant => ({
59 name,
60 ...(final ? { final: true as const } : {}),
61 check: async () => {
62 events.push(name);
63 return [];
64 },
65 });
66 const faults = stubs.map((f) => ({
67 name: f.name,
68 inject: async () => {
69 events.push(`fault ${f.name}`);
70 },
71 }));
72 const night = await chaos(SEED, 3, faults, { world, invariants: [spy("each", false), spy("last", true)] });
73 expect(events.filter((e) => e === "last")).toHaveLength(1);
74 expect(events.filter((e) => e === "each")).toHaveLength(3);
75 expect(events.at(-1)).toBe("last");
76 expect(events.lastIndexOf("each")).toBeLessThan(events.indexOf("last"));
77 expect(night.findings).toEqual([]);
78 });
79
80 test("a broken final invariant is attributed to the last fault of the night", async () => {
81 const broken: Invariant = { name: "snap", final: true, check: async () => ["gone"] };
82 const night = await chaos(SEED, 4, stubs, { world, invariants: [broken] });
83 expect(night.findings).toEqual([{ fault: night.faults[3]!, broken: ["snap: gone"] }]);
84 });
85
2486 for (const fault of FAULTS) {
2587 test(`after "${fault.name}" every invariant holds (seed ${SEED})`, async () => {
26 const night = await chaos(SEED, 1, [fault]);
88 const night = await chaos(SEED, 1, [fault], { world, invariants: quick });
2789 expect(night.findings, `reproduce: CHAOS_SEED=${SEED}\n${night.log.join("\n")}`).toEqual([]);
28 }, 60_000);
90 });
2991 }
3092
3193 test("all faults in a seeded order, one after another", async () => {
32 const night = await chaos(SEED, 6);
33 expect(night.findings, night.log.join("\n")).toEqual([]);
34 expect(night.faults).toEqual((await chaos(SEED, 6)).faults);
35 }, 120_000);
94 const night = await chaos(SEED, FAULTS.length, FAULTS, { world });
95 expect(night.faults).toHaveLength(FAULTS.length);
96 expect(night.findings, `reproduce: CHAOS_SEED=${SEED}\n${night.log.join("\n")}`).toEqual([]);
97 });
3698
3799 test("the slow proxy delays but delivers", async () => {
38100 const backend = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") });
@@ -49,19 +111,19 @@ describe("chaos", () => {
49111 });
50112
51113 test("an invariant catches what it guards", async () => {
52 const root = mkdtempSync(join(tmpdir(), "forge-chaos-neg-"));
114 const dir = mkdtempSync(join(tmpdir(), "forge-chaos-neg-"));
115 const side = await bootWorld(join(dir, "data"));
53116 try {
54 const world = await bootWorld(join(root, "data"));
55 world.app.db.query("DELETE FROM run WHERE id = ?1").run([...world.runIds][1]!);
56 writeFileSync(join(world.dataDir, "leak.txt"), SEALED_VALUE);
57 const broken = (await Promise.all(INVARIANTS.map((i) => i.check(world)))).flat();
117 side.app.db.query("DELETE FROM run WHERE id = ?1").run([...side.runIds][1]!);
118 writeFileSync(join(side.dataDir, "leak.txt"), SEALED_VALUE);
119 const broken = (await Promise.all(INVARIANTS.map((i) => i.check(side)))).flat();
58120 expect(broken.some((b) => b.includes("vanished"))).toBe(true);
59121 expect(broken.some((b) => b.includes("sealed value is readable"))).toBe(true);
60 world.app.close();
61122 } finally {
62 rmSync(root, { recursive: true, force: true });
123 side.app.close();
124 rmSync(dir, { recursive: true, force: true });
63125 }
64 }, 60_000);
126 });
65127
66128 test("a broken night makes an issue with fault, seed and log; a quiet one a line", () => {
67129 const night = {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs