import { expect, test, type Browser, type TestStepInfo } from "@playwright/test"; import { fileURLToPath } from "node:url"; import { setPolicy, type Policy, type RepoOptions } from "./api"; import { claimAttestation, isAttested } from "./attestations"; import { browserLaunch, config, type Account } from "./config"; import type { Reply } from "./net"; import { recorded } from "./record"; import { Run, openPull, openTicket, pushFiles, traceNewRepo, waitForMirror } from "./run"; import { bobbin, hydrant, knot, lookBefore, settle, type Hop } from "./services"; import { watcher } from "./stream"; import { knotFirehose, pdsFirehose } from "./firehose"; import type { Held } from "./tabs"; import { replacing, resolve, through, type Ctx, type Lazy, type Slot, type Target, type Where } from "./vocabulary"; export * from "./vocabulary"; export { bobbin, deliberi, git, hydrant, knot, knotCopy, knotmirror, pds } from "./services"; export { note } from "./record"; interface Place { file: string; line: number; column: number; } interface Step { name: string; run: (run: Run) => Promise; checkpoint?: boolean; asked: Error; } // reading .stack is slow, so --list and steps that never run skip it const askedHere = () => { const limit = Error.stackTraceLimit; Error.stackTraceLimit = 30; const asked = new Error(); Error.stackTraceLimit = limit; return asked; }; const placeOf = (frame: string): Place | undefined => { const found = /\(?([^()\s]+):(\d+):(\d+)\)?$/.exec(frame); if (!found) return undefined; // specs load as modules, so their frames come as file:// urls const file = found[1].startsWith("file:") ? fileURLToPath(found[1]) : found[1]; return { file, line: +found[2], column: +found[3] }; }; const placesOf = (asked: Error) => { const places = (asked.stack ?? "") .split("\n") .slice(1) .map(placeOf) .filter((place): place is Place => !!place && !place.file.endsWith("harness/flow.ts")); const at = places.find((place) => place.file.includes("/specs/")) ?? places[0]; const via = places[0] === at ? undefined : places[0]; return { at, via }; }; const onlyPresent = (verb: string, what: Target) => { if (what.absent) throw new Error(`can't ${verb} ${what.describe}, a not() target is only for .sees()`); }; const route = (hops: Hop[]) => hops.map((hop) => hop.name).join(", "); // never add a then() here, or playwright treats the chain as a promise class Flow { constructor(private steps: Step[]) {} private add(name: string, run: Step["run"], checkpoint?: boolean) { this.steps.push({ name, run, checkpoint, asked: askedHere() }); return this; } actor(who: string | Account, options: { login?: "ui" } = {}) { if (typeof who === "string") { const kind = `new account${options.login ? ", login page" : ""}`; return this.add(`actor ${who} (${kind})`, (run) => run.switchTo(who, options)); } if (options.login) throw new Error( `${who.name} is seeded, it logged in through the login page before the run` ); return this.add(`actor ${who.name} (seeded)`, (run) => run.switchTo(who, options)); } repo(options: Pick = {}) { return this.add("repo", (run) => run.own(options)); } fork() { return this.add("fork", async (run) => { const upstream = run.repo; await run.own({ forkOf: upstream.did }); run.upstream = upstream; }); } push( files: Record, options: { branch?: string; base?: string; options?: string[] } = {} ) { return this.add("push", (run) => pushFiles(run, files, options)); } // a page that makes a repo can't make its actor a knot member member() { return this.add("member", (run) => run.becomeMember()); } joinedKnot() { return this.add("joined the knot", async (run) => run.oweMembership()); } mirrored() { return this.add("mirrored", waitForMirror); } pull(pull: Slot, patch: Slot, options: { title?: string; from: string; into: string }) { return this.add("pull", (run) => openPull(run, pull, patch, options)); } // for a record a service changed on its own, like a merge closing its ticket learn(slot: Slot) { return this.add(`learn ${slot.nsid}`, (run) => run.learn(slot, run.get(slot).uri, "learn")); } ticket(slot: Slot, options: { title?: string; state?: "open" | "closed" } = {}) { return this.add(`ticket ${options.state ?? "open"}`, (run) => openTicket(run, slot, options) ); } open(at: Where) { return this.add(`open ${at.describe}`, async (run) => { await run.openTab(); // the page is inert until it hydrates await run.page.goto(at.path(run)); await run.settle(); }); } window(name = "") { return this.add(name ? `window ${name}` : "first window", (run) => run.openWindow(name)); } device(name: string) { return this.add(`device ${name}`, (run) => run.openDevice(name)); } at(here: Where, timeout?: number) { return this.add(`at ${here.describe}`, async (run) => { await expect(run.page).toHaveURL(`${config.webUrl}${here.path(run)}`, { timeout }); }); } click(what: Target) { onlyPresent("click", what); return this.add(`click ${what.describe}`, (run) => what.locate(run.page, run).click()); } fill(what: Target, value: Lazy) { onlyPresent("fill", what); return this.add(`fill ${what.describe}`, (run) => what.locate(run.page, run).fill(resolve(value, run)) ); } press(key: string) { return this.add(`press ${key}`, async (run) => { await run.page.keyboard.press(key); await run.settle(); }); } reload() { return this.add("reload", async (run) => { await run.page.reload(); await run.settle(); }); } sees(what: Target, words?: string) { if (what.absent && words !== undefined) { throw new Error(`${what.describe} can't say "${words}", there is nothing to read`); } return this.add(`sees ${what.describe}${words ? ` saying ${words}` : ""}`, async (run) => { const found = what.locate(run.page, run); if (what.absent) await expect(found).toHaveCount(0); else if (words === undefined) await expect(found).toBeVisible(); else await expect(found).toHaveText(words); }); } // the write is held here, so the server hasn't seen it yet sends( slot: Slot, to: { name: string; host: string }, body: Lazy>, method?: Held["method"] ) { return this.add(`sends ${slot.nsid} to ${to.name}`, async (run) => { const held = await run.hold(slot); expect(held.host, `${slot.nsid} should be sent to the ${to.name}`).toBe(to.host); if (method) expect(held.method, `${slot.nsid} should be a ${method}`).toBe(method); expect(held.body).toMatchObject(resolve(body, run)); }); } traceNewRepo(name: Lazy, hops: Hop[]) { return this.add("trace the new repo", (run) => traceNewRepo(run, resolve(name, run), hops)); } sendsNothing(slot: Slot) { return this.add(`sends no ${slot.nsid}`, (run) => run.expectNoWrite(slot)); } trace(slot: Slot, hops: Hop[]) { return this.add(`trace ${slot.nsid} through ${route(hops)}`, async (run) => { const held = await run.hold(slot); const before = { ...(await lookBefore(hops, held)), released: Date.now() }; const written = await run.release(slot, held); await settle(written, hops, run.actor, slot.nsid, held.method, before, run.everyone); if (isAttested(held)) await claimAttestation(run, written); }); } follow(slot: Slot, hops: Hop[]) { return this.add(`follow ${slot.nsid} through ${route(hops)}`, (run) => settle(run.get(slot), hops, run.actor, slot.nsid, "settled", undefined, run.everyone) ); } // the knot's own copy of the record says this state, whatever the page shows knotStored(slot: Slot, state: string) { return this.add(`the knot stored it as ${state}`, async (run) => { expect((await knot.has(run.get(slot))).state).toBe(state); }); } // no page control for this, so it goes through the api policy(policy: Policy) { return this.add(`policy ${policy}`, (run) => setPolicy(run.actor, run.repo.did, policy)); } rejects(name: string, attempt: (ctx: Ctx) => Promise, status: number | number[]) { const allowed = [status].flat(); return this.add(`rejects ${name}`, async (run) => { const reply = await attempt(run); expect( allowed, `${name} answered ${reply.status} ${JSON.stringify(reply.body)}` ).toContain(reply.status); }); } refused(slot: Slot, status: number | number[]) { return this.add(`${slot.nsid} is refused with ${status}`, async (run) => { await run.answer(slot, await run.hold(slot), status); }); } // a grant write answers with an empty body, so there's no record to trace accepted(slot: Slot) { return this.add(`${slot.nsid} is accepted`, async (run) => { await run.answer(slot, await run.hold(slot), 200); }); } // the button only flips once bobbin has the new state, so a held write changes nothing changeState( slot: Slot, to: "open" | "closed", clicking: Target, then: Target, hops: Hop[] = through(knot, hydrant, bobbin) ) { return this.click(clicking) .sends(slot, knot, replacing(slot, { state: to }), "put") .sees(clicking) .trace(slot, hops) .knotStored(slot, to) .sees(then); } checkpoint(name: string) { return this.add(`checkpoint: ${name}`, async () => {}, true); } step(name: string, run: (ctx: Ctx) => Promise) { return this.add(name, run); } use(chain: (flow: Flow) => Flow) { return chain(this); } } export type { Flow }; // teardown still gets its own time after a test times out, which is why the world closes there const withWorld = test.extend<{ world: Run }, { browser: Browser; stream: void }>({ browser: [ async ({ playwright }, use) => { const shared = process.env.E2E_BROWSER_WS; const browser = shared ? await playwright.chromium.connect(shared) : await playwright.chromium.launch(browserLaunch); await use(browser); await browser.close(); }, { scope: "worker" } ], // open before the worker's first write so every hop has its frames stream: [ async ({}, use) => { try { await watcher.start(); await Promise.all([knotFirehose.start(), pdsFirehose.start()]); await use(); } finally { watcher.stop(); knotFirehose.stop(); pdsFirehose.stop(); } }, { scope: "worker", auto: true } ], world: async ({ browser }, use) => { const run = new Run(browser); await use(run); await run.close(); } }); export const flow = (name: string) => { const steps: Step[] = []; const asked = askedHere(); withWorld(name, async ({ world }) => { const recording = (run: Step["run"]) => (info: TestStepInfo) => recorded( info, () => run(world), () => world.pageUrl ); let reached = "no checkpoint"; try { for (const step of steps) { const { at, via } = placesOf(step.asked); const { actor, repo } = world.known; await test.step(step.name, recording(step.run), { location: at, params: { ...(via && { via: `${via.file}:${via.line}` }), ...(actor && { actor }), ...(repo && { repo }) }, subtitle: repo }); if (!step.checkpoint) continue; reached = step.name; test.info().annotations.push({ type: "checkpoint", description: step.name }); } await test.step( "nothing else was written", recording((run) => run.expectQuiet()), { location: placesOf(asked).at } ); } catch (cause) { test.info().annotations.push({ type: "stopped after", description: reached }); throw cause; } }); return new Flow(steps); };