Public: anyone can read this; the crew can change it.
The engine-split soak, dead-end, live-hold, landing-simulation and OAuth browser test files run under the test budget #12391
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12382-the-engine-split-soak-dead-end-live-hold → main 8 commits fork
14 files
changed +277
−130
docs/features/nightly.md +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | The forge runs two jobs itself, at a fixed UTC slot each (`src/nightly/jobs.ts`): |
| 4 | 4 | |
| 5 | 5 | - **flake-hunt, 02:17.** The gate in 20 slices, five loaded runs each, then the quarantine. Files `Flaky:` issues and proposes the quarantine as a pull on `flake-hunt/<date>`. |
| 6 | - **outside-gate, 03:47.** Every file in `EXCLUDED` once. Opens, updates and closes `Red:` issues. | |
| 6 | - **outside-gate, 03:47.** Every file in `EXCLUDED` once. Opens, updates and closes `Red:` issues. Then the long runs the gate cuts short: the engine-split soak at 1000 round trips (`test/engine-split-soak.test.ts`; the gate runs 48), the landing simulator at 24 seeds (`test/landsim.test.ts`; the gate runs 4), and the dead-end near-miss and offered-action walks (`test/deadends.test.ts`; the gate crawls only). | |
| 7 | 7 | |
| 8 | 8 | Both are `scripts/flake-hunt.ts`, run in a cell on a checkout of the default branch that reaches only the forge and the registry. Only steps that file hold `FORGE_TOKEN`. |
| 9 | 9 |
scripts/gate.ts +4 −5
| @@ -95,12 +95,12 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 95 | 95 | { file: "test/crew-sandbox.test.ts", reason: "runs a sandboxed agent that pushes to a port-mode forge through pre-receive; 5.8 s a test on the gate box", issue: 1242 }, |
| 96 | 96 | { file: "test/data-door.test.ts", reason: "runs SQL through the data door until the per-person limit holds; 2.1 s a test on the gate box", issue: 1242 }, |
| 97 | 97 | { file: "test/data-home.test.ts", reason: "starts main.ts as a real process to see it refuse this checkout; 2.4 s a test on the gate box", issue: 1242 }, |
| 98 | { file: "test/deadends.test.ts", reason: "crawls every link of a seeded forge once per kind of viewer; 38.9 s a test on the gate box", issue: 1242 }, | |
| 98 | { file: "test/deadends.test.ts", reason: "crawls three seeded forges as every kind of viewer, in worker threads side by side; the action walks run nightly; the crawl test waits on the slowest", issue: 1242 }, | |
| 99 | 99 | { file: "test/domain-dns.test.ts", reason: "drives a real terminal session through publishing and cutting over DNS records; 2.2 s a test on the gate box", issue: 1242 }, |
| 100 | 100 | { file: "test/drill-400mb.test.ts", reason: "packs and restores a 400 MB snapshot, its wire time on an injected clock, not slept (#12381); was 26.6 s, about 7 s a test", issue: 1242 }, |
| 101 | 101 | { file: "test/drill-run.test.ts", reason: "spawns the drill script with piped stdin and waits for its JSON; 2.4 s a test on the gate box", issue: 1242 }, |
| 102 | 102 | { file: "test/drill-verdict.test.ts", reason: "boots a restored directory as a real process on a side port; 2.2 s a test on the gate box", issue: 1242 }, |
| 103 | { file: "test/engine-split-soak.test.ts", reason: "soaks 1000 write-then-read round trips beside a second writing engine process; 64.2 s a test on the gate box", issue: 1242 }, | |
| 103 | { file: "test/engine-split-soak.test.ts", reason: "soaks 48 write-then-read round trips beside a second engine process writing flat out (1000 nightly); its engine starts in a worker; 3-5 s a test", issue: 1242 }, | |
| 104 | 104 | { file: "test/engine-split.test.ts", reason: "races two engine threads over the same queued runs; 3.1 s a test on the gate box", issue: 1242 }, |
| 105 | 105 | { file: "test/forbidden-page.test.ts", reason: "posts to every form route across sites and as a non-admin; 5.4 s a test on the gate box", issue: 1242 }, |
| 106 | 106 | { file: "test/fork-pull-approval.test.ts", reason: "deploys previews of fork pulls and waits for one to be taken down; 4.2 s a test on the gate box", issue: 1242 }, |
| @@ -112,9 +112,8 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 112 | 112 | { file: "test/home-frame.test.ts", reason: "renders the signed-in home after writes and times the answer from memory; 2.2 s a test on the gate box", issue: 1242 }, |
| 113 | 113 | { file: "test/job-slices.test.ts", reason: "ticks every timed job on a busy fixture while sampling the event loop; 2.3 s a test on the gate box", issue: 1242 }, |
| 114 | 114 | { file: "test/kpis-landings-scale.test.ts", reason: "seeds a production-sized landings table and counts it two ways; 7.5 s a test on the gate box", issue: 1242 }, |
| 115 | { file: "test/landsim.test.ts", reason: "simulates landing a seeded corpus of pulls through review and the merge queue; 18.4 s a test on the gate box", issue: 12447 }, | |
| 115 | { file: "test/landsim.test.ts", reason: "lands four seeded scenarios through review and the merge queue at once, with the order, merge and determinism pair checks; 24 seeds nightly", issue: 12447 }, | |
| 116 | 116 | { file: "test/live-arrive.test.ts", reason: "drops and restores a live connection in happy-dom, waiting on real timers; 2.8 s a test on the gate box", issue: 1242 }, |
| 117 | { file: "test/live-hold.test.ts", reason: "runs the live client on a stubbed DOM with real timers; 30 s for one swap points to a stalled wait; 30.0 s a test on the gate box", issue: 1242 }, | |
| 118 | 117 | { file: "test/loading-chrome.test.ts", reason: "watches the loading indicator across pages in a real Chromium browser; 7.6 s a test on the gate box", issue: 1242 }, |
| 119 | 118 | { file: "test/maintenance-standby.test.ts", reason: "waits out the standby's answer timeout on the real clock; 2.0 s a test on the gate box", issue: 1242 }, |
| 120 | 119 | { file: "test/make-it-yours.test.ts", reason: "forks real repositories through the action, the API and the page under a limit; 3.8 s a test on the gate box", issue: 1242 }, |
| @@ -124,7 +123,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 124 | 123 | { file: "test/merge-queue.test.ts", reason: "lands real pulls through the merge queue, each with a git merge and a gate; 5.5 s a test on the gate box", issue: 1242 }, |
| 125 | 124 | { file: "test/model-grant.test.ts", reason: "starts stand-in agent runs under each kind of model grant and waits for them; 10.0 s a test on the gate box", issue: 1242 }, |
| 126 | 125 | { file: "test/no-polling.test.ts", reason: "walks every page the router lists, signed in as admin; 2.1 s a test on the gate box", issue: 1242 }, |
| 127 | { file: "test/oauth-connect-browser.test.ts", reason: "walks the OAuth connect flow in a real Chromium browser; 15.8 s a test on the gate box", issue: 1242 }, | |
| 126 | { file: "test/oauth-connect-browser.test.ts", reason: "starts a real Chromium and walks the OAuth connect flow through two pages and a new tab; no longer waits out the 15 s load deadline", issue: 1242 }, | |
| 128 | 127 | { file: "test/origin-every-mutation.test.ts", reason: "posts to every mutating route with a foreign Origin as a signed-in user; 3.0 s a test on the gate box", issue: 1242 }, |
| 129 | 128 | { file: "test/outsiders-member.test.ts", reason: "runs a member's repository through a stand-in agent run end to end; 6.1 s a test on the gate box", issue: 1242 }, |
| 130 | 129 | { file: "test/overlap.test.ts", reason: "runs two forge processes on one data directory to see each job start once; 4.6 s a test on the gate box", issue: 1242 }, |
src/nightly/jobs.ts +22
| @@ -70,6 +70,28 @@ export const JOBS: readonly Job[] = [ | ||
| 70 | 70 | timeoutMs: SLICE_MS, |
| 71 | 71 | reports: true, |
| 72 | 72 | }, |
| 73 | { | |
| 74 | // The gate soaks the engine split for 48 round trips; the night, for 1000. | |
| 75 | label: "the long engine-split soak", | |
| 76 | command: "SPLIT_ROUND_TRIPS=1000 bun test ./test/engine-split-soak.test.ts", | |
| 77 | timeoutMs: SLICE_MS, | |
| 78 | reports: false, | |
| 79 | }, | |
| 80 | { | |
| 81 | // The gate lands four seeds' scenarios; the night, 24. | |
| 82 | label: "the long landing simulation", | |
| 83 | command: "LANDSIM_SEEDS=24 bun test ./test/landsim.test.ts", | |
| 84 | timeoutMs: SLICE_MS, | |
| 85 | reports: false, | |
| 86 | }, | |
| 87 | { | |
| 88 | // The gate crawls for dead ends; the night also walks every near miss | |
| 89 | // and every offered action. | |
| 90 | label: "the dead-end action walks", | |
| 91 | command: "DEADENDS_ACTIONS=1 bun test ./test/deadends.test.ts", | |
| 92 | timeoutMs: SLICE_MS, | |
| 93 | reports: false, | |
| 94 | }, | |
| 73 | 95 | ], |
| 74 | 96 | }, |
| 75 | 97 | { |
test/deadends.test.ts +50 −49
| @@ -5,8 +5,11 @@ | ||
| 5 | 5 | * and holds the findings that are not fixed yet as KNOWN, so a new dead link |
| 6 | 6 | * fails the build and a fixed one asks to be removed from the list. |
| 7 | 7 | * |
| 8 | * The quick run (a handful of seeds, every viewer) is part of the suite. A | |
| 9 | * long run, on the server at low priority: | |
| 8 | * The quick run (three seeds' crawls as every viewer, the sign-in doors, | |
| 9 | * stale links, the instruction scan) is part of the suite. The near-miss and | |
| 10 | * offered-action walks run with DEADENDS_ACTIONS=1, which the nightly | |
| 11 | * outside-gate job sets (`src/nightly/jobs.ts`). A long run, which walks them | |
| 12 | * too, on the server at low priority: | |
| 10 | 13 | * |
| 11 | 14 | * DEADENDS_LONG=1 DEADENDS_SEEDS=60 nice -n 19 bun test ./test/deadends.test.ts |
| 12 | 15 | * |
| @@ -28,26 +31,24 @@ | ||
| 28 | 31 | * `crew/forge_budget.ts:80`, `crew/runner.ts:284`, `core/capacity.ts:439`, |
| 29 | 32 | * `packs/up.ts:67`, `flow/alarm_release_quiet.ts:30`. |
| 30 | 33 | */ |
| 31 | import { expect, test } from "bun:test"; | |
| 34 | import { beforeAll, expect, test } from "bun:test"; | |
| 32 | 35 | import { quietLogs } from "./helpers/globals.ts"; |
| 33 | 36 | import { |
| 34 | 37 | crawl, |
| 35 | 38 | group, |
| 36 | 39 | instructions, |
| 37 | nearMissProblems, | |
| 38 | offeredActionProblems, | |
| 39 | 40 | partsFor, |
| 40 | 41 | returnAddressProblems, |
| 41 | 42 | seedWorld, |
| 42 | 43 | shrink, |
| 43 | 44 | staleLinkProblems, |
| 44 | 45 | VIEWERS, |
| 45 | type Crawl, | |
| 46 | 46 | type Finding, |
| 47 | 47 | type Problem, |
| 48 | 48 | type SeedParts, |
| 49 | 49 | type ViewerName, |
| 50 | 50 | } from "./support/deadends.ts"; |
| 51 | import type { DeadendsDone, DeadendsJob } from "./support/deadends-worker.ts"; | |
| 51 | 52 | |
| 52 | 53 | quietLogs(); |
| 53 | 54 | |
| @@ -57,6 +58,8 @@ const SEEDS = Array.from( | ||
| 57 | 58 | (_, i) => i + 1, |
| 58 | 59 | ); |
| 59 | 60 | const PRINT = process.env.DEADENDS_PRINT === "1"; |
| 61 | /** The near-miss and offered-action walks: nightly and in a long run, not in the gate. */ | |
| 62 | const ACTIONS = LONG || process.env.DEADENDS_ACTIONS === "1"; | |
| 60 | 63 | |
| 61 | 64 | /** A finding without the ids that differ run to run, for the known list. */ |
| 62 | 65 | const fold = (s: string): string => |
| @@ -91,8 +94,8 @@ const noted = (key: string, where: string) => { | ||
| 91 | 94 | if (!seen.has(key)) seen.set(key, where); |
| 92 | 95 | }; |
| 93 | 96 | |
| 94 | function judgeCrawl(seed: number, c: Crawl): void { | |
| 95 | for (const f of c.findings) { | |
| 97 | function judgeCrawl(seed: number, findings: readonly Finding[]): void { | |
| 98 | for (const f of findings) { | |
| 96 | 99 | // An entry the viewer is not meant to open (a private repository, a page for another role) is not a link. |
| 97 | 100 | if (f.from === "(entry)") continue; |
| 98 | 101 | noted( |
| @@ -102,26 +105,39 @@ function judgeCrawl(seed: number, c: Crawl): void { | ||
| 102 | 105 | } |
| 103 | 106 | } |
| 104 | 107 | |
| 108 | /** | |
| 109 | * One job on a thread of its own (`test/support/deadends-worker.ts`): a walk | |
| 110 | * renders every page on the thread that serves it, so the walks of this file | |
| 111 | * run side by side, each in a worker with its own forge. | |
| 112 | */ | |
| 113 | function inWorker(job: DeadendsJob): Promise<DeadendsDone> { | |
| 114 | const worker = new Worker(new URL("./support/deadends-worker.ts", import.meta.url).href); | |
| 115 | return new Promise<DeadendsDone>((resolve, reject) => { | |
| 116 | worker.onmessage = (event: MessageEvent<{ ok: true; done: DeadendsDone } | { ok: false; error: string }>) => | |
| 117 | event.data.ok ? resolve(event.data.done) : reject(new Error(event.data.error)); | |
| 118 | worker.onerror = (event) => reject(new Error(event.message)); | |
| 119 | worker.postMessage(job); | |
| 120 | }).finally(() => worker.terminate()); | |
| 121 | } | |
| 122 | ||
| 123 | // Every walk starts at once, before the first test; each test waits for its own. | |
| 124 | let crawls: Promise<DeadendsDone[]>; | |
| 125 | let nearMisses: Promise<DeadendsDone[]>; | |
| 126 | let offered: Promise<DeadendsDone[]>; | |
| 127 | beforeAll(() => { | |
| 128 | const settled = <T>(p: Promise<T>): Promise<T> => (p.catch(() => {}), p); | |
| 129 | crawls = settled(Promise.all(SEEDS.map((seed) => inWorker({ kind: "crawl", seed, perPattern: LONG ? 8 : 3 })))); | |
| 130 | if (!ACTIONS) return; | |
| 131 | nearMisses = settled(Promise.all((LONG ? SEEDS : [1]).map((seed) => inWorker({ kind: "near-miss", seed, mutate: LONG })))); | |
| 132 | // A fresh forge each: a submitted form changes what the next one finds. | |
| 133 | offered = settled(Promise.all((["admin", "member", "newcomer"] as ViewerName[]).map((viewer) => inWorker({ kind: "offered", viewer })))); | |
| 134 | }); | |
| 135 | ||
| 105 | 136 | test("a crawl of the seeded forge, as every viewer, ends at no dead link", async () => { |
| 106 | 137 | const t0 = Date.now(); |
| 107 | const lines: string[] = []; | |
| 108 | for (const seed of SEEDS) { | |
| 109 | const world = await seedWorld(seed, partsFor(seed)); | |
| 110 | try { | |
| 111 | for (const v of VIEWERS) { | |
| 112 | const c = await crawl(world, world.viewers[v], { | |
| 113 | perPattern: LONG ? 8 : 3, | |
| 114 | }); | |
| 115 | lines.push( | |
| 116 | `seed ${seed} ${v}: ${c.fetched} fetched, ${c.pages} pages, ${c.edges} edges, ${c.patterns.size} routes`, | |
| 117 | ); | |
| 118 | judgeCrawl(seed, c); | |
| 119 | } | |
| 120 | } finally { | |
| 121 | world.close(); | |
| 122 | } | |
| 123 | } | |
| 124 | if (PRINT) console.log(lines.join("\n")); | |
| 138 | const done = await crawls; | |
| 139 | for (const d of done) judgeCrawl(d.seed, d.findings); | |
| 140 | if (PRINT) console.log(done.flatMap((d) => d.lines).join("\n")); | |
| 125 | 141 | if (!LONG) expect(Date.now() - t0).toBeLessThan(90_000); |
| 126 | 142 | }, 900_000); |
| 127 | 143 | |
| @@ -145,31 +161,16 @@ test("a link to a deleted repository is the forge's own 404, never a 500", async | ||
| 145 | 161 | } |
| 146 | 162 | }, 120_000); |
| 147 | 163 | |
| 148 | test("no route answers a 500 to its filled-in address, or to a light mutation of it", async () => { | |
| 149 | for (const seed of LONG ? SEEDS : [1]) { | |
| 150 | const world = await seedWorld(seed, partsFor(seed)); | |
| 151 | try { | |
| 152 | for (const v of ["visitor", "admin", "member"] as const) | |
| 153 | for (const p of await nearMissProblems(world, world.viewers[v], LONG)) | |
| 154 | noted(keyOfProblem(p), `seed ${seed} ${p.viewer}: ${p.at} ${p.what}`); | |
| 155 | } finally { | |
| 156 | world.close(); | |
| 157 | } | |
| 158 | } | |
| 164 | test.skipIf(!ACTIONS)("no route answers a 500 to its filled-in address, or to a light mutation of it", async () => { | |
| 165 | for (const d of await nearMisses) | |
| 166 | for (const p of d.problems) | |
| 167 | noted(keyOfProblem(p), `seed ${d.seed} ${p.viewer}: ${p.at} ${p.what}`); | |
| 159 | 168 | }, 600_000); |
| 160 | 169 | |
| 161 | test("every action a viewer is offered is one they may take", async () => { | |
| 162 | for (const v of ["admin", "member", "newcomer"] as ViewerName[]) { | |
| 163 | // A fresh forge each: a submitted form changes what the next one finds. | |
| 164 | const world = await seedWorld(1); | |
| 165 | try { | |
| 166 | const c = await crawl(world, world.viewers[v], { perPattern: 2 }); | |
| 167 | for (const p of await offeredActionProblems(world, world.viewers[v], c)) | |
| 168 | noted(keyOfProblem(p), `${p.viewer} at ${p.at}: ${p.what}`); | |
| 169 | } finally { | |
| 170 | world.close(); | |
| 171 | } | |
| 172 | } | |
| 170 | test.skipIf(!ACTIONS)("every action a viewer is offered is one they may take", async () => { | |
| 171 | for (const d of await offered) | |
| 172 | for (const p of d.problems) | |
| 173 | noted(keyOfProblem(p), `${p.viewer} at ${p.at}: ${p.what}`); | |
| 173 | 174 | }, 300_000); |
| 174 | 175 | |
| 175 | 176 | test("the findings are the known ones, and every known one still happens", async () => { |
test/engine-split-soak.test.ts +7 −2
| @@ -5,7 +5,12 @@ | ||
| 5 | 5 | * hold on every round trip; the stream must show the outbox's `seq` strictly |
| 6 | 6 | * increasing and without a gap; the database must come out clean. |
| 7 | 7 | * |
| 8 | * `SPLIT_ROUND_TRIPS` (default 1000) sets the length, `SPLIT_ENGINE=inline` | |
| 8 | * The gate's run is short: 48 round trips, three a client, still all at once | |
| 9 | * beside a writer that never pauses, so a stale read or a gap in the stream | |
| 10 | * still fails it. The long soak, 1000 round trips, is the nightly | |
| 11 | * `outside-gate` job's (`src/nightly/jobs.ts`). | |
| 12 | * | |
| 13 | * `SPLIT_ROUND_TRIPS` (default 48) sets the length, `SPLIT_ENGINE=inline` | |
| 9 | 14 | * runs the same writer on the serving thread instead, `SPLIT_DATA` names a |
| 10 | 15 | * data directory to run on (a copy of a real forge's), and `SPLIT_REPORT=1` |
| 11 | 16 | * prints the numbers. |
| @@ -27,7 +32,7 @@ import startWriter from "./helpers/engine_writer.ts"; | ||
| 27 | 32 | |
| 28 | 33 | quietLogs(); |
| 29 | 34 | |
| 30 | const TRIPS = Number(process.env["SPLIT_ROUND_TRIPS"] ?? 1000); | |
| 35 | const TRIPS = Number(process.env["SPLIT_ROUND_TRIPS"] ?? 48); | |
| 31 | 36 | const CLIENTS = 16; |
| 32 | 37 | const INLINE = process.env["SPLIT_ENGINE"] === "inline"; |
| 33 | 38 | const WRITER = new URL("./helpers/engine_writer.ts", import.meta.url).href; |
test/engine-split.test.ts +4 −2
| @@ -270,11 +270,13 @@ describe("the engine thread", () => { | ||
| 270 | 270 | const engine = startEngine(serve.config, tail, { |
| 271 | 271 | extra: [WRITER], |
| 272 | 272 | restartMinMs: 50, |
| 273 | stallMs: 1_500, | |
| 273 | // Longer than a boot: an engine is silent until it is up, and one ended before its first row | |
| 274 | // is life 1 again and hangs again. A boot takes about 2.5 s on a loaded box, so 1.5 s looped. | |
| 275 | stallMs: 5_000, | |
| 274 | 276 | }); |
| 275 | 277 | engines.push(engine); |
| 276 | 278 | // Stuck inside a write transaction from its first second, never ready: the watch ends it. |
| 277 | await until(() => engine.restarts() === 1, 15_000); | |
| 279 | await until(() => engine.restarts() === 1, 20_000); | |
| 278 | 280 | await engine.ready(); |
| 279 | 281 | expect( |
| 280 | 282 | serve.db |
test/helpers/chrome.ts +9 −2
| @@ -358,6 +358,11 @@ export async function retryStep( | ||
| 358 | 358 | } |
| 359 | 359 | } |
| 360 | 360 | |
| 361 | /** Resolves once `ok()` holds (looked at every 50 ms) or `deadline` passes. */ | |
| 362 | async function waitFor(ok: () => boolean, deadline: number): Promise<void> { | |
| 363 | while (!ok() && Date.now() < deadline) await Bun.sleep(50); | |
| 364 | } | |
| 365 | ||
| 361 | 366 | /** |
| 362 | 367 | * Load `url` with a session cookie, then run each of `steps` (an |
| 363 | 368 | * expression evaluated in the page — a click) after the page before it has |
| @@ -461,9 +466,11 @@ export async function clickAndFollow( | ||
| 461 | 466 | }, |
| 462 | 467 | deadline, |
| 463 | 468 | ); |
| 464 | await Promise.race([loaded, Bun.sleep(Math.max(0, deadline - Date.now()))]); | |
| 469 | // A step that opens a tab (an external link) never fires this page's | |
| 470 | // load event: waiting stops as soon as what was asked for is reached. | |
| 471 | await Promise.race([loaded, waitFor(reached, deadline)]); | |
| 465 | 472 | } |
| 466 | while (!reached() && Date.now() < deadline) await Bun.sleep(100); | |
| 473 | await waitFor(reached, deadline); | |
| 467 | 474 | // Late reports of what the last navigation refused. |
| 468 | 475 | await Bun.sleep(300); |
| 469 | 476 | const where = await cdp |
test/landsim.test.ts +16 −5
| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | * landing promises: what was reviewed is what lands, nothing is lost silently, |
| 6 | 6 | * what was checked is what landed. |
| 7 | 7 | * |
| 8 | * The quick run's seeds also cover the pair properties (order independence on seeds divisible by 4, additive | |
| 9 | * merges on seed % 4 == 1, determinism on seed % 16 == 2). `LANDSIM_SEEDS=n` runs n seeds instead of the quick few. A failure prints the | |
| 8 | * The quick run's four seeds also cover the pair properties, one seed each (order independence on seeds divisible by 4, additive | |
| 9 | * merges on seed % 4 == 1, determinism on seed % 16 == 2). `LANDSIM_SEEDS=n` runs n seeds instead of the quick four; the nightly | |
| 10 | * outside-gate job runs 24 (`src/nightly/jobs.ts`). A failure prints the | |
| 10 | 11 | * seed and the scenario as a script; `bun test/support/landsim.ts --from <seed> |
| 11 | 12 | * --seeds 1 --shrink` cuts it down to the pulls and edits that matter. |
| 12 | 13 | * |
| @@ -40,8 +41,11 @@ import { | ||
| 40 | 41 | type World, |
| 41 | 42 | } from "./support/landsim.ts"; |
| 42 | 43 | |
| 43 | /** Seeds in the quick run: what fits in the suite's budget on a loaded machine. */ | |
| 44 | const QUICK = 6; | |
| 44 | /** | |
| 45 | * Seeds in the quick run, all at once: 1 checks additive merges, 2 determinism, | |
| 46 | * 4 order independence. The longer corpus runs nightly. | |
| 47 | */ | |
| 48 | const QUICK = 4; | |
| 45 | 49 | |
| 46 | 50 | describe("the landing simulator", () => { |
| 47 | 51 | let world: World; |
| @@ -54,7 +58,7 @@ describe("the landing simulator", () => { | ||
| 54 | 58 | |
| 55 | 59 | test("a seeded corpus lands what was reviewed, apart from the violations an issue owns", async () => { |
| 56 | 60 | const n = seedCount(QUICK); |
| 57 | const corpus = await runCorpus(world, 1, n, 3); | |
| 61 | const corpus = await runCorpus(world, 1, n, QUICK); | |
| 58 | 62 | const unexpected = [...corpus.classes].filter( |
| 59 | 63 | ([cls]) => knownIssue(cls) === undefined, |
| 60 | 64 | ); |
| @@ -90,6 +94,13 @@ describe("the landing simulator", () => { | ||
| 90 | 94 | }); |
| 91 | 95 | |
| 92 | 96 | describe("the simulator's own parts", () => { |
| 97 | test("the quick seeds check every pair property at least once", () => { | |
| 98 | const seeds = Array.from({ length: QUICK }, (_, i) => i + 1); | |
| 99 | expect(seeds.some((seed) => seed % 4 === 0)).toBe(true); | |
| 100 | expect(seeds.some((seed) => seed % 4 === 1)).toBe(true); | |
| 101 | expect(seeds.some((seed) => seed % 16 === 2)).toBe(true); | |
| 102 | }); | |
| 103 | ||
| 93 | 104 | test("a seed is one scenario, and a scenario prints as a script that names it", () => { |
| 94 | 105 | expect(generate(31)).toEqual(generate(31)); |
| 95 | 106 | expect(generate(31)).not.toEqual(generate(32)); |
test/live-hold.test.ts +7 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A live region marked data-live-hold is not swapped while the reader is |
| 3 | 3 | * scrolled into it (#6693): the client says `forge:held` with the fresh markup. |
| 4 | * The client runs on a stubbed DOM and a fake clock (`later`, `clock`). | |
| 4 | 5 | */ |
| 5 | 6 | |
| 6 | 7 | import { describe, expect, test } from "bun:test"; |
| @@ -53,8 +54,13 @@ async function run(hold: boolean, top: number, bottom = 400) { | ||
| 53 | 54 | : { status: 200, ok: true, headers: { get: () => '"v1"' }, text: async () => "x" }; |
| 54 | 55 | class DP { parseFromString() { return { getElementById: () => fresh, querySelector: () => null }; } } |
| 55 | 56 | class CE { constructor(public type: string, init: Any = {}) { Object.assign(this, init); } } |
| 57 | // A fake clock: a timer moves it on by its delay and fires at once, so the | |
| 58 | // client's gaps between page pulls (1.5 s, 30 s) pass without being waited. | |
| 59 | let now = 1_000_000; | |
| 60 | const clock = { now: () => now }; | |
| 61 | const later = (f: () => void, ms = 0) => { now += Math.max(0, ms); queueMicrotask(f); return 1; }; | |
| 56 | 62 | new Function("document", "window", "EventSource", "CustomEvent", "fetch", "location", "setTimeout", "Date", "DOMParser", liveRegionsJs)( |
| 57 | doc, win, ES, CE, fetchFn, { href: "http://x/" }, (f: () => void) => { queueMicrotask(f); return 1; }, Date, DP, | |
| 63 | doc, win, ES, CE, fetchFn, { href: "http://x/" }, later, clock, DP, | |
| 58 | 64 | ); |
| 59 | 65 | const poke = async () => { |
| 60 | 66 | handlers.forge!({ lastEventId: "1", data: JSON.stringify({ kind: "run.done", repo: "a" }) }); |
test/nightly.test.ts +4 −1
| @@ -65,7 +65,7 @@ describe("nightly records", () => { | ||
| 65 | 65 | }); |
| 66 | 66 | |
| 67 | 67 | describe("nightly jobs", () => { |
| 68 | test("the hunt covers every slice once and ends in the quarantine; the outside run is one step", () => { | |
| 68 | test("the hunt covers every slice once and ends in the quarantine; the outside run is the excluded files, then the long soak, landing simulation and dead-end walks", () => { | |
| 69 | 69 | const hunt = JOBS.find((j) => j.name === "flake-hunt")!; |
| 70 | 70 | const slices = hunt.steps.filter((s) => s.label.startsWith("hunt slice")); |
| 71 | 71 | expect(slices).toHaveLength(HUNT_SLICES); |
| @@ -74,6 +74,9 @@ describe("nightly jobs", () => { | ||
| 74 | 74 | expect(JOBS.find((j) => j.name === "outside-gate")!.steps.map((s) => s.label)).toEqual([ |
| 75 | 75 | "install", |
| 76 | 76 | "run the excluded files", |
| 77 | "the long engine-split soak", | |
| 78 | "the long landing simulation", | |
| 79 | "the dead-end action walks", | |
| 77 | 80 | ]); |
| 78 | 81 | }); |
| 79 | 82 |
test/oauth-connect-browser.test.ts +4 −3
| @@ -20,7 +20,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 20 | 20 | import { createUser } from "../src/core/users.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 22 | import { findChromium } from "../src/looks/capture.ts"; |
| 23 | import { clickAndFollow } from "./helpers/chrome.ts"; | |
| 23 | import { chromiumRuns, clickAndFollow } from "./helpers/chrome.ts"; | |
| 24 | 24 | import { quietLogs, withoutEnv } from "./helpers/globals.ts"; |
| 25 | 25 | import { |
| 26 | 26 | newFakeOAuth, |
| @@ -33,10 +33,11 @@ quietLogs(); | ||
| 33 | 33 | withoutEnv("FORGE_URL"); |
| 34 | 34 | |
| 35 | 35 | const CLIENT_ID = "forge-browser-client"; |
| 36 | const chromium = findChromium(); | |
| 36 | const found = findChromium(); | |
| 37 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 37 | 38 | if (chromium === undefined) |
| 38 | 39 | console.log( |
| 39 | "SKIPPING: no chromium on this machine (FORGE_CHROMIUM), so the connect buttons cannot be clicked", | |
| 40 | "SKIPPING: no chromium that starts on this machine (FORGE_CHROMIUM), so the connect buttons cannot be clicked", | |
| 40 | 41 | ); |
| 41 | 42 | |
| 42 | 43 | describe.skipIf(chromium === undefined)("connecting an origin in a real browser", () => { |
test/support/deadends-checks.ts +6 −7
| @@ -367,13 +367,12 @@ export async function nearMissProblems( | ||
| 367 | 367 | const path = fillPattern(pattern); |
| 368 | 368 | if (path === undefined || /logout|signout/.test(path)) continue; |
| 369 | 369 | const asks = [path, ...(mutate ? mutations(path) : [])]; |
| 370 | for (const p of asks) { | |
| 371 | const a = await get( | |
| 372 | world, | |
| 373 | viewer, | |
| 374 | p, | |
| 375 | p.startsWith("/api") ? { accept: "application/json" } : {}, | |
| 376 | ); | |
| 370 | // A route's addresses are asked together (all GETs), and judged in order. | |
| 371 | const answers = await Promise.all( | |
| 372 | asks.map((p) => get(world, viewer, p, p.startsWith("/api") ? { accept: "application/json" } : {})), | |
| 373 | ); | |
| 374 | for (const [i, p] of asks.entries()) { | |
| 375 | const a = answers[i]!; | |
| 377 | 376 | if (a.status === 500) |
| 378 | 377 | out.push({ |
| 379 | 378 | class: "near-miss", |
test/support/deadends-crawl.ts +63 −52
| @@ -305,6 +305,9 @@ export function hasAnchor(html: string, id: string): boolean { | ||
| 305 | 305 | return new RegExp(`\\b(?:id|name)=["']${esc}["']`).test(html); |
| 306 | 306 | } |
| 307 | 307 | |
| 308 | /** How many pages a crawl asks for at once (`crawl`). */ | |
| 309 | const CRAWL_WAVE = 8; | |
| 310 | ||
| 308 | 311 | export interface CrawlOptions { |
| 309 | 312 | /** Distinct targets fetched per route pattern. */ |
| 310 | 313 | readonly perPattern?: number; |
| @@ -477,61 +480,69 @@ export async function crawl( | ||
| 477 | 480 | const queued = new Set(queue); |
| 478 | 481 | const anchors: Edge[] = []; |
| 479 | 482 | const json = viewer.name === "agent"; |
| 483 | // Breadth first, a wave at a time: the next `CRAWL_WAVE` paths of the | |
| 484 | // queue are fetched together and judged in queue order, so a crawl visits | |
| 485 | // what a one-at-a-time walk would, in the same order, without waiting on | |
| 486 | // each page before asking for the next. | |
| 480 | 487 | while (queue.length > 0 && result.fetched < maxFetches) { |
| 481 | const path = queue.shift()!; | |
| 482 | if (SKIP.test(path)) continue; | |
| 483 | const pattern = group(path); | |
| 484 | const n = perCount.get(pattern) ?? 0; | |
| 485 | if (n >= perPattern) { | |
| 486 | result.sampled++; | |
| 487 | continue; | |
| 488 | } | |
| 489 | perCount.set(pattern, n + 1); | |
| 490 | result.patterns.add(pattern); | |
| 491 | const answer = await get( | |
| 492 | world, | |
| 493 | viewer, | |
| 494 | path, | |
| 495 | json && path.startsWith("/api") ? { accept: "application/json" } : {}, | |
| 496 | ); | |
| 497 | result.fetched++; | |
| 498 | result.answers.set(path, answer); | |
| 499 | judge(result, viewer, path, answer, record); | |
| 500 | if (answer.status >= 300) continue; | |
| 501 | result.pages++; | |
| 502 | const edges = answer.type.includes("html") | |
| 503 | ? edgesOf(answer.final, answer.body, origin) | |
| 504 | : answer.type.includes("json") | |
| 505 | ? jsonEdges(answer.final, answer.body, origin) | |
| 506 | : []; | |
| 507 | for (const e of edges) { | |
| 508 | result.edges++; | |
| 509 | const [target, hash] = e.to.split("#") as [string, string | undefined]; | |
| 510 | ( | |
| 511 | result.seen.get(target) ?? result.seen.set(target, []).get(target)! | |
| 512 | ).push(e); | |
| 513 | if (e.method !== "GET") { | |
| 514 | // A form is checked against the route table; its submission is `offeredActionFindings`. | |
| 515 | if ( | |
| 516 | patternOf(target, e.method) === undefined && | |
| 517 | !SKIP.test(target) && | |
| 518 | !/^\/(?:mcp|git|_plat)/.test(target) | |
| 519 | ) | |
| 520 | record({ | |
| 521 | kind: "no-route", | |
| 522 | viewer: viewer.name, | |
| 523 | from: path, | |
| 524 | edge: e.kind, | |
| 525 | to: `${e.method} ${target}`, | |
| 526 | text: e.text, | |
| 527 | why: `no ${e.method} route holds this path`, | |
| 528 | }); | |
| 488 | const wave: string[] = []; | |
| 489 | while (queue.length > 0 && wave.length < CRAWL_WAVE && result.fetched + wave.length < maxFetches) { | |
| 490 | const path = queue.shift()!; | |
| 491 | if (SKIP.test(path)) continue; | |
| 492 | const pattern = group(path); | |
| 493 | const n = perCount.get(pattern) ?? 0; | |
| 494 | if (n >= perPattern) { | |
| 495 | result.sampled++; | |
| 529 | 496 | continue; |
| 530 | 497 | } |
| 531 | if (hash !== undefined && hash !== "") anchors.push(e); | |
| 532 | if (!queued.has(target)) { | |
| 533 | queued.add(target); | |
| 534 | queue.push(target); | |
| 498 | perCount.set(pattern, n + 1); | |
| 499 | result.patterns.add(pattern); | |
| 500 | wave.push(path); | |
| 501 | } | |
| 502 | const answers = await Promise.all( | |
| 503 | wave.map((path) => get(world, viewer, path, json && path.startsWith("/api") ? { accept: "application/json" } : {})), | |
| 504 | ); | |
| 505 | for (const [i, path] of wave.entries()) { | |
| 506 | const answer = answers[i]!; | |
| 507 | result.fetched++; | |
| 508 | result.answers.set(path, answer); | |
| 509 | judge(result, viewer, path, answer, record); | |
| 510 | if (answer.status >= 300) continue; | |
| 511 | result.pages++; | |
| 512 | const edges = answer.type.includes("html") | |
| 513 | ? edgesOf(answer.final, answer.body, origin) | |
| 514 | : answer.type.includes("json") | |
| 515 | ? jsonEdges(answer.final, answer.body, origin) | |
| 516 | : []; | |
| 517 | for (const e of edges) { | |
| 518 | result.edges++; | |
| 519 | const [target, hash] = e.to.split("#") as [string, string | undefined]; | |
| 520 | ( | |
| 521 | result.seen.get(target) ?? result.seen.set(target, []).get(target)! | |
| 522 | ).push(e); | |
| 523 | if (e.method !== "GET") { | |
| 524 | // A form is checked against the route table; its submission is `offeredActionFindings`. | |
| 525 | if ( | |
| 526 | patternOf(target, e.method) === undefined && | |
| 527 | !SKIP.test(target) && | |
| 528 | !/^\/(?:mcp|git|_plat)/.test(target) | |
| 529 | ) | |
| 530 | record({ | |
| 531 | kind: "no-route", | |
| 532 | viewer: viewer.name, | |
| 533 | from: path, | |
| 534 | edge: e.kind, | |
| 535 | to: `${e.method} ${target}`, | |
| 536 | text: e.text, | |
| 537 | why: `no ${e.method} route holds this path`, | |
| 538 | }); | |
| 539 | continue; | |
| 540 | } | |
| 541 | if (hash !== undefined && hash !== "") anchors.push(e); | |
| 542 | if (!queued.has(target)) { | |
| 543 | queued.add(target); | |
| 544 | queue.push(target); | |
| 545 | } | |
| 535 | 546 | } |
| 536 | 547 | } |
| 537 | 548 | } |
test/support/deadends-worker.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * One job of `test/deadends.test.ts` on a thread of its own: seed a forge, | |
| 3 | * walk it as the job says, and hand back what was found as plain data. A | |
| 4 | * walk spends its time rendering pages on the thread that serves them, so | |
| 5 | * the file's walks (every seed's crawl, the near misses, each viewer's | |
| 6 | * offered actions) run side by side in workers rather than one after | |
| 7 | * another. What each job does is what the test did in line before. | |
| 8 | * | |
| 9 | * Message in: a `DeadendsJob`. Message out: `{ ok: true, done }` with a | |
| 10 | * `DeadendsDone`, or `{ ok: false, error }`. | |
| 11 | */ | |
| 12 | ||
| 13 | // The forge's modules load in the order the test's own imports load them | |
| 14 | // (`helpers/globals.ts` first): some of them read each other's constants at load. | |
| 15 | import "../../src/origins/mirror.ts"; | |
| 16 | import { setLogLevel } from "../../src/kernel/log.ts"; | |
| 17 | import { | |
| 18 | crawl, | |
| 19 | nearMissProblems, | |
| 20 | offeredActionProblems, | |
| 21 | partsFor, | |
| 22 | seedWorld, | |
| 23 | VIEWERS, | |
| 24 | type Finding, | |
| 25 | type Problem, | |
| 26 | type ViewerName, | |
| 27 | } from "./deadends.ts"; | |
| 28 | ||
| 29 | export type DeadendsJob = | |
| 30 | /** Every viewer's crawl of one seed's forge. */ | |
| 31 | | { readonly kind: "crawl"; readonly seed: number; readonly perPattern: number } | |
| 32 | /** Every GET route filled in (and mutated, when `mutate`) as visitor, admin, member. */ | |
| 33 | | { readonly kind: "near-miss"; readonly seed: number; readonly mutate: boolean } | |
| 34 | /** Each POST form a viewer is offered on seed 1, submitted; a fresh forge each. */ | |
| 35 | | { readonly kind: "offered"; readonly viewer: ViewerName }; | |
| 36 | ||
| 37 | export interface DeadendsDone { | |
| 38 | readonly seed: number; | |
| 39 | readonly findings: Finding[]; | |
| 40 | readonly problems: Problem[]; | |
| 41 | /** One line per crawl: what it fetched. */ | |
| 42 | readonly lines: string[]; | |
| 43 | } | |
| 44 | ||
| 45 | export async function runJob(job: DeadendsJob): Promise<DeadendsDone> { | |
| 46 | const seed = job.kind === "offered" ? 1 : job.seed; | |
| 47 | const world = await seedWorld(seed, job.kind === "offered" ? undefined : partsFor(seed)); | |
| 48 | const done: DeadendsDone = { seed, findings: [], problems: [], lines: [] }; | |
| 49 | try { | |
| 50 | if (job.kind === "crawl") | |
| 51 | for (const v of VIEWERS) { | |
| 52 | const c = await crawl(world, world.viewers[v], { perPattern: job.perPattern }); | |
| 53 | done.lines.push(`seed ${seed} ${v}: ${c.fetched} fetched, ${c.pages} pages, ${c.edges} edges, ${c.patterns.size} routes`); | |
| 54 | done.findings.push(...c.findings); | |
| 55 | } | |
| 56 | else if (job.kind === "near-miss") | |
| 57 | for (const v of ["visitor", "admin", "member"] as const) | |
| 58 | done.problems.push(...(await nearMissProblems(world, world.viewers[v], job.mutate))); | |
| 59 | else { | |
| 60 | const c = await crawl(world, world.viewers[job.viewer], { perPattern: 2 }); | |
| 61 | done.problems.push(...(await offeredActionProblems(world, world.viewers[job.viewer], c))); | |
| 62 | } | |
| 63 | } finally { | |
| 64 | world.close(); | |
| 65 | } | |
| 66 | return done; | |
| 67 | } | |
| 68 | ||
| 69 | declare const self: Worker; | |
| 70 | // Only when loaded as a worker: the test imports the types. | |
| 71 | if (!Bun.isMainThread) { | |
| 72 | setLogLevel("error"); | |
| 73 | self.onmessage = async (event: MessageEvent<DeadendsJob>) => { | |
| 74 | try { | |
| 75 | postMessage({ ok: true, done: await runJob(event.data) }); | |
| 76 | } catch (err) { | |
| 77 | postMessage({ ok: false, error: err instanceof Error ? (err.stack ?? err.message) : String(err) }); | |
| 78 | } | |
| 79 | }; | |
| 80 | } |
new above
offline