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

Merged opened this

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

Merged

mason/forge:mason/12382-the-engine-split-soak-dead-end-live-hold → main 8 commits fork

  • Merged. mason/12382-the-engine-split-soak-dead-end-live-hold was merged into main as 5431a5f.

Review

Sign in to review this change.
14 files changed +277 −130
docs/features/nightly.md +1 −1
@@ -3,7 +3,7 @@
33 The forge runs two jobs itself, at a fixed UTC slot each (`src/nightly/jobs.ts`):
44
55 - **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).
77
88 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`.
99
scripts/gate.ts +4 −5
@@ -95,12 +95,12 @@ export const SLOW: readonly Exclusion[] = [
9595 { 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 },
9696 { 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 },
9797 { 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 },
9999 { 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 },
100100 { 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 },
101101 { 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 },
102102 { 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 },
104104 { 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 },
105105 { 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 },
106106 { 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[] = [
112112 { 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 },
113113 { 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 },
114114 { 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 },
116116 { 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 },
118117 { 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 },
119118 { 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 },
120119 { 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[] = [
124123 { 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 },
125124 { 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 },
126125 { 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 },
128127 { 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 },
129128 { 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 },
130129 { 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[] = [
7070 timeoutMs: SLICE_MS,
7171 reports: true,
7272 },
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 },
7395 ],
7496 },
7597 {
test/deadends.test.ts +50 −49
@@ -5,8 +5,11 @@
55 * and holds the findings that are not fixed yet as KNOWN, so a new dead link
66 * fails the build and a fixed one asks to be removed from the list.
77 *
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:
1013 *
1114 * DEADENDS_LONG=1 DEADENDS_SEEDS=60 nice -n 19 bun test ./test/deadends.test.ts
1215 *
@@ -28,26 +31,24 @@
2831 * `crew/forge_budget.ts:80`, `crew/runner.ts:284`, `core/capacity.ts:439`,
2932 * `packs/up.ts:67`, `flow/alarm_release_quiet.ts:30`.
3033 */
31 import { expect, test } from "bun:test";
34 import { beforeAll, expect, test } from "bun:test";
3235 import { quietLogs } from "./helpers/globals.ts";
3336 import {
3437 crawl,
3538 group,
3639 instructions,
37 nearMissProblems,
38 offeredActionProblems,
3940 partsFor,
4041 returnAddressProblems,
4142 seedWorld,
4243 shrink,
4344 staleLinkProblems,
4445 VIEWERS,
45 type Crawl,
4646 type Finding,
4747 type Problem,
4848 type SeedParts,
4949 type ViewerName,
5050 } from "./support/deadends.ts";
51 import type { DeadendsDone, DeadendsJob } from "./support/deadends-worker.ts";
5152
5253 quietLogs();
5354
@@ -57,6 +58,8 @@ const SEEDS = Array.from(
5758 (_, i) => i + 1,
5859 );
5960 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";
6063
6164 /** A finding without the ids that differ run to run, for the known list. */
6265 const fold = (s: string): string =>
@@ -91,8 +94,8 @@ const noted = (key: string, where: string) => {
9194 if (!seen.has(key)) seen.set(key, where);
9295 };
9396
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) {
9699 // An entry the viewer is not meant to open (a private repository, a page for another role) is not a link.
97100 if (f.from === "(entry)") continue;
98101 noted(
@@ -102,26 +105,39 @@ function judgeCrawl(seed: number, c: Crawl): void {
102105 }
103106 }
104107
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
105136 test("a crawl of the seeded forge, as every viewer, ends at no dead link", async () => {
106137 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"));
125141 if (!LONG) expect(Date.now() - t0).toBeLessThan(90_000);
126142 }, 900_000);
127143
@@ -145,31 +161,16 @@ test("a link to a deleted repository is the forge's own 404, never a 500", async
145161 }
146162 }, 120_000);
147163
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}`);
159168 }, 600_000);
160169
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}`);
173174 }, 300_000);
174175
175176 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 @@
55 * hold on every round trip; the stream must show the outbox's `seq` strictly
66 * increasing and without a gap; the database must come out clean.
77 *
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`
914 * runs the same writer on the serving thread instead, `SPLIT_DATA` names a
1015 * data directory to run on (a copy of a real forge's), and `SPLIT_REPORT=1`
1116 * prints the numbers.
@@ -27,7 +32,7 @@ import startWriter from "./helpers/engine_writer.ts";
2732
2833 quietLogs();
2934
30 const TRIPS = Number(process.env["SPLIT_ROUND_TRIPS"] ?? 1000);
35 const TRIPS = Number(process.env["SPLIT_ROUND_TRIPS"] ?? 48);
3136 const CLIENTS = 16;
3237 const INLINE = process.env["SPLIT_ENGINE"] === "inline";
3338 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", () => {
270270 const engine = startEngine(serve.config, tail, {
271271 extra: [WRITER],
272272 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,
274276 });
275277 engines.push(engine);
276278 // 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);
278280 await engine.ready();
279281 expect(
280282 serve.db
test/helpers/chrome.ts +9 −2
@@ -358,6 +358,11 @@ export async function retryStep(
358358 }
359359 }
360360
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
361366 /**
362367 * Load `url` with a session cookie, then run each of `steps` (an
363368 * expression evaluated in the page — a click) after the page before it has
@@ -461,9 +466,11 @@ export async function clickAndFollow(
461466 },
462467 deadline,
463468 );
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)]);
465472 }
466 while (!reached() && Date.now() < deadline) await Bun.sleep(100);
473 await waitFor(reached, deadline);
467474 // Late reports of what the last navigation refused.
468475 await Bun.sleep(300);
469476 const where = await cdp
test/landsim.test.ts +16 −5
@@ -5,8 +5,9 @@
55 * landing promises: what was reviewed is what lands, nothing is lost silently,
66 * what was checked is what landed.
77 *
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
1011 * seed and the scenario as a script; `bun test/support/landsim.ts --from <seed>
1112 * --seeds 1 --shrink` cuts it down to the pulls and edits that matter.
1213 *
@@ -40,8 +41,11 @@ import {
4041 type World,
4142 } from "./support/landsim.ts";
4243
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;
4549
4650 describe("the landing simulator", () => {
4751 let world: World;
@@ -54,7 +58,7 @@ describe("the landing simulator", () => {
5458
5559 test("a seeded corpus lands what was reviewed, apart from the violations an issue owns", async () => {
5660 const n = seedCount(QUICK);
57 const corpus = await runCorpus(world, 1, n, 3);
61 const corpus = await runCorpus(world, 1, n, QUICK);
5862 const unexpected = [...corpus.classes].filter(
5963 ([cls]) => knownIssue(cls) === undefined,
6064 );
@@ -90,6 +94,13 @@ describe("the landing simulator", () => {
9094 });
9195
9296 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
93104 test("a seed is one scenario, and a scenario prints as a script that names it", () => {
94105 expect(generate(31)).toEqual(generate(31));
95106 expect(generate(31)).not.toEqual(generate(32));
test/live-hold.test.ts +7 −1
@@ -1,6 +1,7 @@
11 /**
22 * A live region marked data-live-hold is not swapped while the reader is
33 * 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`).
45 */
56
67 import { describe, expect, test } from "bun:test";
@@ -53,8 +54,13 @@ async function run(hold: boolean, top: number, bottom = 400) {
5354 : { status: 200, ok: true, headers: { get: () => '"v1"' }, text: async () => "x" };
5455 class DP { parseFromString() { return { getElementById: () => fresh, querySelector: () => null }; } }
5556 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; };
5662 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,
5864 );
5965 const poke = async () => {
6066 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", () => {
6565 });
6666
6767 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", () => {
6969 const hunt = JOBS.find((j) => j.name === "flake-hunt")!;
7070 const slices = hunt.steps.filter((s) => s.label.startsWith("hunt slice"));
7171 expect(slices).toHaveLength(HUNT_SLICES);
@@ -74,6 +74,9 @@ describe("nightly jobs", () => {
7474 expect(JOBS.find((j) => j.name === "outside-gate")!.steps.map((s) => s.label)).toEqual([
7575 "install",
7676 "run the excluded files",
77 "the long engine-split soak",
78 "the long landing simulation",
79 "the dead-end action walks",
7780 ]);
7881 });
7982
test/oauth-connect-browser.test.ts +4 −3
@@ -20,7 +20,7 @@ import { createSession } from "../src/core/sessions.ts";
2020 import { createUser } from "../src/core/users.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
2222 import { findChromium } from "../src/looks/capture.ts";
23 import { clickAndFollow } from "./helpers/chrome.ts";
23 import { chromiumRuns, clickAndFollow } from "./helpers/chrome.ts";
2424 import { quietLogs, withoutEnv } from "./helpers/globals.ts";
2525 import {
2626 newFakeOAuth,
@@ -33,10 +33,11 @@ quietLogs();
3333 withoutEnv("FORGE_URL");
3434
3535 const CLIENT_ID = "forge-browser-client";
36 const chromium = findChromium();
36 const found = findChromium();
37 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
3738 if (chromium === undefined)
3839 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",
4041 );
4142
4243 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(
367367 const path = fillPattern(pattern);
368368 if (path === undefined || /logout|signout/.test(path)) continue;
369369 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]!;
377376 if (a.status === 500)
378377 out.push({
379378 class: "near-miss",
test/support/deadends-crawl.ts +63 −52
@@ -305,6 +305,9 @@ export function hasAnchor(html: string, id: string): boolean {
305305 return new RegExp(`\\b(?:id|name)=["']${esc}["']`).test(html);
306306 }
307307
308 /** How many pages a crawl asks for at once (`crawl`). */
309 const CRAWL_WAVE = 8;
310
308311 export interface CrawlOptions {
309312 /** Distinct targets fetched per route pattern. */
310313 readonly perPattern?: number;
@@ -477,61 +480,69 @@ export async function crawl(
477480 const queued = new Set(queue);
478481 const anchors: Edge[] = [];
479482 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.
480487 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++;
529496 continue;
530497 }
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 }
535546 }
536547 }
537548 }
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs