Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389import { 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<unknown>; checkpoint?: boolean; asked: Error;}
// reading .stack is slow, so --list and steps that never run skip itconst 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 promiseclass 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<RepoOptions, "spindle" | "importFrom"> = {}) { 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<string, string>, 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<string>) { 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<Record<string, unknown>>, 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<string>, 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<Reply>, 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<unknown>) { 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 thereconst 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);};