waygraph 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # Waygraph
2
+
3
+ A typed graph of reusable Blocks for driving a browser through E2E flows, in place of a
4
+ flat pile of ad hoc helper functions - see
5
+ `openspec/changes/archive/2026-08-27-core-spine/proposal.md` for the full motivation.
6
+
7
+ This repo is the `waygraph` npm package itself. Consumer projects proving it against real
8
+ sites/apps (saucedemo.com, zsign-app) live in the sibling `projects_waygraph/` repo, not here.
9
+
10
+ **Status:** `Checkpoint`, `MemPage`, `Instruction`/`Block` (`act`/`observe`/`resolve`/`verify`),
11
+ `connect()`, `Trait`, `Engine.defineFlow([start, ...blocks, end])`, and `branch()`/self-loops
12
+ with a `maxSteps` safety cap. A Block's output type is the only contract the next Block can
13
+ rely on, `resolve` is structurally incapable of touching the browser or shared memory,
14
+ `verify` runs only after `resolve` has already decided (confirms or fails loud, never
15
+ redirects), `branch()` attaches routing to a Block - including back to itself - without ever
16
+ touching that Block's own phases, a Block's `requires: MemKey[]` gets checked by
17
+ `preflight()` (built into `runGraph`) before a tab even opens - a missing credential fails
18
+ in milliseconds, not after several real browser actions already ran - and `new Engine({ headless, browserName, slowMo })` configures a browser the Engine
19
+ launches and owns itself: `flow.run(mem)` (no context) uses it, and `flow.run(mem, {
20
+ ...overrides })` overrides it per call - so one module-level `Flow`, imported by many
21
+ specs, doesn't force every caller into the same settings. `flow.run(context, mem)` still
22
+ works exactly as before and ignores all of this, since that context is already launched.
23
+ `withVerify(block, verify)` decorates any existing Block with different confirmation - same
24
+ `act`/`resolve`, same routing - without that Block needing to have been authored as a
25
+ parameterized factory (`withVerify(LoginBlock, [])` is the "I only care that I navigated
26
+ this far, no DOM check" case). Built-in Trait factories are reachable two ways - as free
27
+ functions (`urlMatches`, `textEquals`, `visible`) or, for autocomplete discoverability,
28
+ off `Trait.` itself (`Trait.url`, `Trait.text`, `Trait.visible` - the exact same functions,
29
+ just findable by typing `Trait.` without needing to already know their names). `Trait.url`
30
+ takes a structured `URLPatternInit` (`{ pathname, hostname, search, hash, ... }`, the same
31
+ shape the standard `URLPattern` Web API takes), not a hand-written regex - whichever
32
+ components you leave out default to "match anything," so `Trait.url({ pathname:
33
+ "/inventory.html" })` already ignores whatever query params a hybrid SPA tacks on, with no
34
+ separate flag needed for that. Once Blocks are composed into a `Flow`, `flow.withBlockVerify`/
35
+ `flow.modBlockVerify` patch one Block's verify from outside - a spec that only imports the
36
+ finished flow, addressed by the Block reference (preferred), its name, or its numeric
37
+ position in the flow - without editing the flow's own file.
38
+
39
+ Deliberately **not yet implemented** (each tracked as its own future OpenSpec change,
40
+ building on this one):
41
+ - Interstitials/Watchers - background overlay handling (cookie banners, popups).
42
+ - `spawnTab` - a second browser tab.
43
+ - A patchable way to compose several Blocks into one named unit with per-step verify
44
+ addressable from outside (`connect()`-composed chains don't get `withVerify`/`modVerify`
45
+ attached the way `defineBlock()`/`branch()`/`Flow` outputs do) - useful for a multi-step
46
+ form (e.g. a government form with several stepper pages) that's naturally "one feature" but
47
+ still wants each step's own confirmation independently overridable.
48
+
49
+ ## Example
50
+
51
+ Two Tier-0 Blocks (only `act` + `resolve`, no branching), each confirmed with `verify`
52
+ after the fact rather than asserting mid-`observe`, defined as a flow and run against a
53
+ real page:
54
+
55
+ ```typescript
56
+ import { Engine, start, end, MemPage, key, checkpoint } from "waygraph";
57
+ import type { Block, Checkpoint } from "waygraph";
58
+
59
+ type Start = Checkpoint<"__start__">;
60
+ type FormLoaded = Checkpoint<"FormLoaded">;
61
+ type Submitted = Checkpoint<"Submitted">;
62
+
63
+ const EmailKey = key<string>("form.email");
64
+ const PasswordKey = key<string>("form.password");
65
+
66
+ const GotoForm: Block<Start, FormLoaded> = {
67
+ name: "goto-form",
68
+ instruction: {
69
+ async act(page) {
70
+ await page.goto("https://example.com/login");
71
+ },
72
+ resolve: () => checkpoint("FormLoaded"),
73
+ },
74
+ };
75
+
76
+ const FillAndSubmit: Block<FormLoaded, Submitted> = {
77
+ name: "fill-and-submit",
78
+ instruction: {
79
+ async act(page, _input, mem) {
80
+ await page.getByPlaceholder("Email").fill(mem.get(EmailKey));
81
+ await page.getByPlaceholder("Password").fill(mem.get(PasswordKey));
82
+ await page.getByRole("button", { name: "Submit" }).click();
83
+ },
84
+ resolve: () => checkpoint("Submitted"),
85
+ verify: [
86
+ {
87
+ name: "submitted-heading-shown",
88
+ check: async (page) => {
89
+ await page.getByRole("heading", { name: "Submitted" }).waitFor();
90
+ return true;
91
+ },
92
+ },
93
+ ],
94
+ },
95
+ };
96
+
97
+ const mem = new MemPage();
98
+ mem.set(EmailKey, "alice@test.com");
99
+ mem.set(PasswordKey, "hunter2");
100
+
101
+ const engine = new Engine();
102
+ const flow = engine.defineFlow([start, GotoForm, FillAndSubmit, end]);
103
+ const result = await flow.run(context, mem);
104
+ // result === { __state: "Submitted" }
105
+ ```
106
+
107
+ See `tests/define-flow.spec.ts` here, and `saucedemo/tests/checkout-flow.spec.ts` in
108
+ `projects_waygraph/` (a real 4-Block flow against the live saucedemo.com), for this running
109
+ for real. The lower-level `connect()`/`runGraph()` still exist and are what `defineFlow`
110
+ builds on - reach for them directly only if you need a shape `defineFlow`'s array can't
111
+ express yet.
112
+
113
+ ## Commands
114
+
115
+ ```bash
116
+ npm install
117
+ npm run typecheck # tsc --noEmit
118
+ npm run test # playwright test
119
+ npm run build # tsc -p tsconfig.build.json, emits dist/
120
+ ```
121
+
122
+ ## Layout
123
+
124
+ ```
125
+ package.json the "waygraph" npm package itself
126
+ src/
127
+ types.ts Checkpoint, Instruction, Block, connect()
128
+ mem-page.ts MemKey, key(), MemPage
129
+ trait.ts Trait (type + discoverable Trait.url/.text/.visible), runVerify()
130
+ engine.ts runGraph(), Engine, start, end, Flow (+ withBlockVerify/modBlockVerify)
131
+ index.ts public barrel
132
+ typecheck/ compile-time-only fixtures (assignability, @ts-expect-error cases)
133
+ tests/ runtime unit + integration tests (@playwright/test)
134
+ tsconfig.json base config - typecheck script includes src/ + typecheck/ + tests/
135
+ tsconfig.build.json extends base, src/ only, emits to dist/
136
+ openspec/ the OpenSpec change history for this repo (propose -> apply per change)
137
+ ```
138
+
139
+ Consumer projects (saucedemo.com proof, zsign-app integration) live in the sibling
140
+ `projects_waygraph/` repo, depending on this package via `"waygraph": "file:../../waygraph"`
141
+ during local dev (see its own `.npmrc` - `install-links=true` is required there, or npm will
142
+ symlink instead of copy and pull this package's own `node_modules` in through the symlink,
143
+ causing a duplicate-Playwright-installation error).
@@ -0,0 +1,214 @@
1
+ import type { BrowserContext } from "@playwright/test";
2
+ import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
3
+ import { MemPage } from "./mem-page.js";
4
+ import type { Trait } from "./trait.js";
5
+ /**
6
+ * Checks `block.requires` against `mem` and throws, naming every missing key at
7
+ * once, if any are unset. `connect()` unions each Block's `requires` into the
8
+ * composed Block, so calling this on a `defineFlow`-built Flow's entry - which
9
+ * `runGraph` does automatically, before opening a tab - covers every Block in
10
+ * that flow. It does not see Blocks only reachable through `branch()` routing,
11
+ * since those depend on a runtime decision that hasn't happened yet.
12
+ */
13
+ export declare function preflight(mem: MemPage, block: Block<any, any>): void;
14
+ /**
15
+ * Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
16
+ * The engine owns the tab's lifecycle, not any individual Block: the tab is
17
+ * created before the first `act` runs and closed in `finally`, so it closes even
18
+ * if a step throws.
19
+ *
20
+ * After each Block resolves, its `next` (if any, from `branch()`) is followed to
21
+ * the following Block, in the same tab - a Block with no `next` runs exactly once,
22
+ * unchanged from before `branch()` existed. `next` returning the same Block is a
23
+ * self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
24
+ * hanging.
25
+ *
26
+ * `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
27
+ * one of the tags the caller actually expects, or the run fails loud instead of
28
+ * silently returning an unexpected result. Omit it when there is only ever one
29
+ * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
30
+ * no routing at all) - there is nothing to validate against.
31
+ */
32
+ export declare function runGraph<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, terminals: ReadonlySet<string> | undefined, context: BrowserContext, mem: MemPage, maxSteps?: number): Promise<TOut>;
33
+ /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
34
+ export declare const start: unique symbol;
35
+ export declare const end: unique symbol;
36
+ type StartMarker = typeof start;
37
+ type EndMarker = typeof end;
38
+ /**
39
+ * A composed, runnable flow. Always begins from Checkpoint<"__start__">, same as
40
+ * runGraph's `entry`. `run(context, mem)` uses a context you already have (the
41
+ * usual case inside a `@playwright/test` file, via its `context` fixture) -
42
+ * there's no browser for this Flow to launch, so no config to pass. `run(mem,
43
+ * config?)` - no context - has the Engine launch and own its own browser for
44
+ * this one run instead, and closes it in `finally`; use this outside a
45
+ * Playwright test file, or when a flow genuinely needs its own browser instance
46
+ * rather than sharing the test's. `config` here overrides, per key, whatever
47
+ * `new Engine(...)` was constructed with - so one flow (module-level, imported
48
+ * by many specs) can still have every run choose its own `headless`/`slowMo`
49
+ * rather than being stuck with whatever the flow's own file happened to set.
50
+ *
51
+ * `withBlockVerify`/`modBlockVerify` do what {@link withVerify}/{@link modVerify}
52
+ * do for a single Block, one level up - a spec that only imports the finished
53
+ * Flow (not necessarily the individual Blocks it was built from) can still
54
+ * patch one Block's verify for just that call, without editing the flow's own
55
+ * file or hand-rebuilding its Block list. Addressed the same three ways
56
+ * `modVerify` already addresses a Trait - the Block reference itself
57
+ * (`LoginBlock`, imported like any other Block; typo-proof, refactor-safe,
58
+ * jump-to-definition works), a plain string name, or a numeric index into this
59
+ * Flow's Block list (position between `start`/`end`, 0-based) for a spec that
60
+ * genuinely doesn't have the Block imported and doesn't want to spell its
61
+ * name. A reference or string resolves by `.name` under the hood (a Block's
62
+ * own stable identity in this codebase, same as every error message elsewhere
63
+ * in this file already uses) rather than object identity - it stays
64
+ * resolvable even after an earlier patch already swapped that slot for a new
65
+ * object, since the name survives every `withVerify`/`modVerify` in the
66
+ * chain; an index is checked fresh against this Flow's current Block list
67
+ * each call, so it's still correct after a patch too. Returns a new Flow -
68
+ * the original is untouched, same as every other decorator in this module.
69
+ */
70
+ export interface Flow<Out extends Checkpoint<string>> {
71
+ run(context: BrowserContext, mem: MemPage): Promise<Out>;
72
+ run(mem: MemPage, config?: EngineConfig): Promise<Out>;
73
+ /**
74
+ * Replaces the given Block's whole verify list, wherever it sits in this
75
+ * Flow - same rules as {@link withVerify}. Throws if no Block in this Flow
76
+ * matches.
77
+ * @example checkoutFlow.withBlockVerify(LoginBlock, [])
78
+ * @example checkoutFlow.withBlockVerify("login", [])
79
+ * @example checkoutFlow.withBlockVerify(0, [])
80
+ */
81
+ withBlockVerify(block: Block<any, any> | string | number, verify: Trait[] | ((out: any) => Trait[])): Flow<Out>;
82
+ /**
83
+ * Replaces one Trait inside the given Block's verify list - same rules as
84
+ * {@link modVerify}. Throws if no Block in this Flow matches, or if that
85
+ * Block has no verify trait at the given name/index.
86
+ * @example checkoutFlow.modBlockVerify(LoginBlock, "url-matches", Trait.url({ pathname: "/inventory.html" }))
87
+ */
88
+ modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
89
+ }
90
+ /** Configures how an Engine launches its own browser - only used by `Flow.run(mem)` (no context given); ignored by `Flow.run(context, mem)`, since that context is already launched. */
91
+ export interface EngineConfig {
92
+ /** Default `true`, matching Playwright's own default. */
93
+ headless?: boolean;
94
+ /** Default `"chromium"`. */
95
+ browserName?: "chromium" | "firefox" | "webkit";
96
+ /** Milliseconds Playwright pauses before each operation - for watching a non-headless run with your own eyes, not for real runs. Default `0`. */
97
+ slowMo?: number;
98
+ }
99
+ type S = Checkpoint<"__start__">;
100
+ /**
101
+ * Holds engine-level configuration shared across every flow defined from it -
102
+ * for now, just how to launch a browser when a Flow owns its own
103
+ * (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
104
+ * in a later change.
105
+ * @example const engine = new Engine({ headless: false, slowMo: 250 });
106
+ */
107
+ export declare class Engine {
108
+ private readonly config;
109
+ constructor(config?: EngineConfig);
110
+ /**
111
+ * Builds a runnable {@link Flow} from `[start, ...Blocks, end]`, typechecked
112
+ * so each Block's `In` must match the previous Block's `Out` - the same
113
+ * check `connect()` does, just declared as a flat array instead of hand-nested
114
+ * calls. Overloaded for 2-7 array slots (1-6 real Blocks between `start`/`end`)
115
+ * rather than one fully-generic recursive tuple type, so each arity is as
116
+ * reliably checked as `connect<A,B,C>` itself.
117
+ * @example engine.defineFlow([start, LoginBlock, AddToCartBlock, end])
118
+ */
119
+ defineFlow<B extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, EndMarker]): Flow<B>;
120
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, EndMarker]): Flow<C>;
121
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, Block<C, D>, EndMarker]): Flow<D>;
122
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, Block<C, D>, Block<D, E>, EndMarker]): Flow<E>;
123
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>>(blocks: readonly [
124
+ StartMarker,
125
+ Block<S, B>,
126
+ Block<B, C>,
127
+ Block<C, D>,
128
+ Block<D, E>,
129
+ Block<E, F>,
130
+ EndMarker
131
+ ]): Flow<F>;
132
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, G extends Checkpoint<string>>(blocks: readonly [
133
+ StartMarker,
134
+ Block<S, B>,
135
+ Block<B, C>,
136
+ Block<C, D>,
137
+ Block<D, E>,
138
+ Block<E, F>,
139
+ Block<F, G>,
140
+ EndMarker
141
+ ]): Flow<G>;
142
+ }
143
+ /**
144
+ * Attaches routing to `block` without touching its act/observe/resolve/verify -
145
+ * `routes` must cover every tag of `block`'s output (a missing route is a compile
146
+ * error), and the returned Block runs exactly as `block` always did, just followed
147
+ * by whichever next Block the resolved tag routes to. `null` for a tag means that
148
+ * tag is terminal - the run stops there, same as a Block with no routing at all.
149
+ * A route pointing back at `block` itself is a self-loop; see `runGraph`'s
150
+ * `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
151
+ * a route can lead anywhere, including back through itself, so there is no fixed
152
+ * Out type to express; state the expected outcomes to `runGraph` via `terminals`
153
+ * instead.
154
+ */
155
+ export declare function branch<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, routes: {
156
+ [K in Out["__state"]]: Block<Extract<Out, Checkpoint<K>>, any> | null;
157
+ }): DefinedBlock<In, any>;
158
+ /**
159
+ * Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
160
+ * methods - `LoginBlock.withVerify([...])`, discoverable by typing
161
+ * `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
162
+ * if you already know it exists. A raw object literal is still a perfectly
163
+ * valid Block (every test fixture in this codebase is one) - it just won't have
164
+ * these attached. The methods are thin: they delegate to the identically-named
165
+ * free functions, which remain the one place this logic actually lives.
166
+ */
167
+ export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
168
+ name: string;
169
+ instruction: Instruction<In, Out, any>;
170
+ next?: Block<In, Out>["next"];
171
+ requires?: Block<In, Out>["requires"];
172
+ }): DefinedBlock<In, Out>;
173
+ /**
174
+ * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
175
+ * same routing - but with `verify` replaced. For giving one flow stricter (or
176
+ * looser, or just different) confirmation on a Block it didn't author, without
177
+ * that Block needing to have been written as a parameterized factory in
178
+ * anticipation. Same move as `branch()`: decorate an existing Block, don't ask
179
+ * every Block to pre-build its own override mechanism.
180
+ *
181
+ * Wrap the one Block you want to change, in place, wherever it already sits in
182
+ * a `defineFlow([...])` array - every other Block stays untouched:
183
+ * `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
184
+ * There is no separate positional array to keep in sync and no `null`
185
+ * placeholder for "skip this one" - `defineFlow` has no per-slot verify
186
+ * argument at all, only whatever `verify` each Block's own `instruction` (or
187
+ * a `withVerify(...)` wrapping it) carries with it.
188
+ */
189
+ export declare function withVerify<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, verify: Trait[] | ((out: Out) => Trait[])): DefinedBlock<In, Out>;
190
+ /**
191
+ * Replaces one Trait inside `block`'s verify list, leaving every other check
192
+ * untouched - the v1/v2/legacy-variant case, where one check needs a different
193
+ * selector or URL pattern but the rest of the Block is identical. Addressed by
194
+ * the Trait's own `name` (recommended - inserting or reordering other checks
195
+ * can't silently retarget it) or, if you genuinely know the position and don't
196
+ * need that safety, a numeric index. Either way a bad address fails loud
197
+ * immediately instead of quietly doing nothing. `newCheck` can be a bare
198
+ * `check` function (keeps the existing name) or a full `Trait` (to rename too).
199
+ * Only works on a flat `verify` array - a function-form `verify` (from
200
+ * `verify: (out) => Trait[]`) has no fixed list to address into, so this
201
+ * throws rather than guess; use `withVerify` to replace it wholesale instead.
202
+ */
203
+ export declare function modVerify<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): DefinedBlock<In, Out>;
204
+ /**
205
+ * `modVerify`, but for several named checks in one call - a v2 variant that
206
+ * differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
207
+ * modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
208
+ * Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
209
+ * fine here - unlike `MemPage`, there's no identity-collision reason to need
210
+ * `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
211
+ * same fail-loud-on-unknown-name behavior holds for every entry.
212
+ */
213
+ export declare function modVerifyAll<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, patches: Record<string, Trait["check"] | Trait>): DefinedBlock<In, Out>;
214
+ export {};
package/dist/engine.js ADDED
@@ -0,0 +1,282 @@
1
+ import { chromium, firefox, webkit } from "@playwright/test";
2
+ import { connect, checkpoint } from "./types.js";
3
+ import { MemPage } from "./mem-page.js";
4
+ import { runVerify } from "./trait.js";
5
+ const LAUNCHERS = { chromium, firefox, webkit };
6
+ /**
7
+ * Checks `block.requires` against `mem` and throws, naming every missing key at
8
+ * once, if any are unset. `connect()` unions each Block's `requires` into the
9
+ * composed Block, so calling this on a `defineFlow`-built Flow's entry - which
10
+ * `runGraph` does automatically, before opening a tab - covers every Block in
11
+ * that flow. It does not see Blocks only reachable through `branch()` routing,
12
+ * since those depend on a runtime decision that hasn't happened yet.
13
+ */
14
+ export function preflight(mem, block) {
15
+ const missing = (block.requires ?? []).filter((k) => !mem.has(k));
16
+ if (missing.length > 0) {
17
+ throw new Error(`preflight: MemPage is missing required key(s) before "${block.name}" can run: ${missing
18
+ .map((k) => `"${k.name}"`)
19
+ .join(", ")}`);
20
+ }
21
+ }
22
+ /**
23
+ * Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
24
+ * The engine owns the tab's lifecycle, not any individual Block: the tab is
25
+ * created before the first `act` runs and closed in `finally`, so it closes even
26
+ * if a step throws.
27
+ *
28
+ * After each Block resolves, its `next` (if any, from `branch()`) is followed to
29
+ * the following Block, in the same tab - a Block with no `next` runs exactly once,
30
+ * unchanged from before `branch()` existed. `next` returning the same Block is a
31
+ * self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
32
+ * hanging.
33
+ *
34
+ * `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
35
+ * one of the tags the caller actually expects, or the run fails loud instead of
36
+ * silently returning an unexpected result. Omit it when there is only ever one
37
+ * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
38
+ * no routing at all) - there is nothing to validate against.
39
+ */
40
+ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000) {
41
+ preflight(mem, entry);
42
+ const page = await context.newPage();
43
+ try {
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ let current = entry;
46
+ let input = checkpoint("__start__");
47
+ let steps = 0;
48
+ for (;;) {
49
+ if (++steps > maxSteps) {
50
+ throw new Error(`runGraph: exceeded ${maxSteps} steps - check for an unintended self-loop`);
51
+ }
52
+ await current.instruction.act(page, input, mem);
53
+ const observed = current.instruction.observe
54
+ ? await current.instruction.observe(page, mem)
55
+ : undefined;
56
+ const checkpoint = await current.instruction.resolve(observed);
57
+ await runVerify(current.instruction.verify, checkpoint, page, mem, current.name);
58
+ const next = current.next?.(checkpoint);
59
+ if (!next) {
60
+ if (terminals && !terminals.has(checkpoint.__state)) {
61
+ throw new Error(`runGraph: "${current.name}" resolved to "${checkpoint.__state}", which is not a registered terminal (expected one of: ${[...terminals].join(", ")})`);
62
+ }
63
+ return checkpoint;
64
+ }
65
+ current = next;
66
+ input = checkpoint;
67
+ }
68
+ }
69
+ finally {
70
+ await page.close();
71
+ }
72
+ }
73
+ /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
74
+ export const start = Symbol("waygraph.start");
75
+ export const end = Symbol("waygraph.end");
76
+ function findBlockIndex(middle, block) {
77
+ if (typeof block === "number") {
78
+ if (block < 0 || block >= middle.length) {
79
+ throw new Error(`Flow: no Block at index ${block} in this flow (has ${middle.length})`);
80
+ }
81
+ return block;
82
+ }
83
+ const blockName = typeof block === "string" ? block : block.name;
84
+ const index = middle.findIndex((b) => b.name === blockName);
85
+ if (index === -1) {
86
+ throw new Error(`Flow: no Block named "${blockName}" in this flow (has: ${middle.map((b) => b.name).join(", ")})`);
87
+ }
88
+ return index;
89
+ }
90
+ function buildFlow(middle, engineConfig) {
91
+ const chain = middle.reduce((a, b) => connect(a, b));
92
+ return {
93
+ async run(contextOrMem, memOrConfig) {
94
+ if (contextOrMem instanceof MemPage) {
95
+ const mem = contextOrMem;
96
+ const config = { ...engineConfig, ...memOrConfig };
97
+ // Checked again inside runGraph too, but doing it here first means a
98
+ // missing key fails before a browser process even launches, not just
99
+ // before a page opens - the same "fail loud, cheaply" guarantee
100
+ // run(context, mem) already has via an existing context.
101
+ preflight(mem, chain);
102
+ const launch = LAUNCHERS[config.browserName ?? "chromium"];
103
+ const browser = await launch.launch({
104
+ headless: config.headless ?? true,
105
+ ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
106
+ });
107
+ try {
108
+ const context = await browser.newContext();
109
+ return await runGraph(chain, undefined, context, mem);
110
+ }
111
+ finally {
112
+ await browser.close();
113
+ }
114
+ }
115
+ return runGraph(chain, undefined, contextOrMem, memOrConfig);
116
+ },
117
+ withBlockVerify(block, verify) {
118
+ const index = findBlockIndex(middle, block);
119
+ const patched = [...middle];
120
+ patched[index] = withVerify(middle[index], verify);
121
+ return buildFlow(patched, engineConfig);
122
+ },
123
+ modBlockVerify(block, nameOrIndex, newCheck) {
124
+ const index = findBlockIndex(middle, block);
125
+ const patched = [...middle];
126
+ patched[index] = modVerify(middle[index], nameOrIndex, newCheck);
127
+ return buildFlow(patched, engineConfig);
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * Holds engine-level configuration shared across every flow defined from it -
133
+ * for now, just how to launch a browser when a Flow owns its own
134
+ * (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
135
+ * in a later change.
136
+ * @example const engine = new Engine({ headless: false, slowMo: 250 });
137
+ */
138
+ export class Engine {
139
+ config;
140
+ constructor(config = {}) {
141
+ this.config = config;
142
+ }
143
+ defineFlow(blocks) {
144
+ return buildFlow(blocks.slice(1, -1), this.config);
145
+ }
146
+ }
147
+ /**
148
+ * Attaches routing to `block` without touching its act/observe/resolve/verify -
149
+ * `routes` must cover every tag of `block`'s output (a missing route is a compile
150
+ * error), and the returned Block runs exactly as `block` always did, just followed
151
+ * by whichever next Block the resolved tag routes to. `null` for a tag means that
152
+ * tag is terminal - the run stops there, same as a Block with no routing at all.
153
+ * A route pointing back at `block` itself is a self-loop; see `runGraph`'s
154
+ * `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
155
+ * a route can lead anywhere, including back through itself, so there is no fixed
156
+ * Out type to express; state the expected outcomes to `runGraph` via `terminals`
157
+ * instead.
158
+ */
159
+ export function branch(block, routes) {
160
+ const base = stripMethods(block);
161
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
+ let routed;
163
+ routed = defineBlock({
164
+ ...base,
165
+ next: (checkpoint) => {
166
+ const target = routes[checkpoint.__state];
167
+ if (target === undefined || target === null)
168
+ return undefined;
169
+ // A route naming `block` itself (the only self-reference in scope when
170
+ // `routes` was written) means "loop back with routing intact" - substitute
171
+ // the routed Block, not the original with no `next` of its own.
172
+ return target === block ? routed : target;
173
+ },
174
+ });
175
+ return routed;
176
+ }
177
+ /**
178
+ * Strips `withVerify`/`modVerify`/`modVerifyAll` off a Block, keeping everything
179
+ * else - `name`, `instruction`, `next`, `requires`. Every function below that
180
+ * returns a new Block calls this on its input first, then rebuilds through
181
+ * `defineBlock`. Without it, a Block's attached methods close over the shape it
182
+ * had *when they were attached* - call `.withVerify()` on a Block that
183
+ * `branch()` added routing to afterward, and without this step the result would
184
+ * silently drop that routing, since the stale closure never knew about it.
185
+ */
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ function stripMethods(block) {
188
+ return {
189
+ name: block.name,
190
+ instruction: block.instruction,
191
+ ...(block.next ? { next: block.next } : {}),
192
+ ...(block.requires ? { requires: block.requires } : {}),
193
+ };
194
+ }
195
+ /**
196
+ * Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
197
+ * methods - `LoginBlock.withVerify([...])`, discoverable by typing
198
+ * `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
199
+ * if you already know it exists. A raw object literal is still a perfectly
200
+ * valid Block (every test fixture in this codebase is one) - it just won't have
201
+ * these attached. The methods are thin: they delegate to the identically-named
202
+ * free functions, which remain the one place this logic actually lives.
203
+ */
204
+ export function defineBlock(base) {
205
+ const plain = {
206
+ name: base.name,
207
+ instruction: base.instruction,
208
+ ...(base.next ? { next: base.next } : {}),
209
+ ...(base.requires ? { requires: base.requires } : {}),
210
+ };
211
+ return {
212
+ ...plain,
213
+ withVerify: (verify) => withVerify(plain, verify),
214
+ modVerify: (nameOrIndex, newCheck) => modVerify(plain, nameOrIndex, newCheck),
215
+ modVerifyAll: (patches) => modVerifyAll(plain, patches),
216
+ };
217
+ }
218
+ /**
219
+ * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
220
+ * same routing - but with `verify` replaced. For giving one flow stricter (or
221
+ * looser, or just different) confirmation on a Block it didn't author, without
222
+ * that Block needing to have been written as a parameterized factory in
223
+ * anticipation. Same move as `branch()`: decorate an existing Block, don't ask
224
+ * every Block to pre-build its own override mechanism.
225
+ *
226
+ * Wrap the one Block you want to change, in place, wherever it already sits in
227
+ * a `defineFlow([...])` array - every other Block stays untouched:
228
+ * `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
229
+ * There is no separate positional array to keep in sync and no `null`
230
+ * placeholder for "skip this one" - `defineFlow` has no per-slot verify
231
+ * argument at all, only whatever `verify` each Block's own `instruction` (or
232
+ * a `withVerify(...)` wrapping it) carries with it.
233
+ */
234
+ export function withVerify(block, verify) {
235
+ const base = stripMethods(block);
236
+ return defineBlock({ ...base, instruction: { ...base.instruction, verify } });
237
+ }
238
+ /**
239
+ * Replaces one Trait inside `block`'s verify list, leaving every other check
240
+ * untouched - the v1/v2/legacy-variant case, where one check needs a different
241
+ * selector or URL pattern but the rest of the Block is identical. Addressed by
242
+ * the Trait's own `name` (recommended - inserting or reordering other checks
243
+ * can't silently retarget it) or, if you genuinely know the position and don't
244
+ * need that safety, a numeric index. Either way a bad address fails loud
245
+ * immediately instead of quietly doing nothing. `newCheck` can be a bare
246
+ * `check` function (keeps the existing name) or a full `Trait` (to rename too).
247
+ * Only works on a flat `verify` array - a function-form `verify` (from
248
+ * `verify: (out) => Trait[]`) has no fixed list to address into, so this
249
+ * throws rather than guess; use `withVerify` to replace it wholesale instead.
250
+ */
251
+ export function modVerify(block, nameOrIndex, newCheck) {
252
+ const verify = block.instruction.verify;
253
+ if (typeof verify === "function") {
254
+ throw new Error(`modVerify: "${block.name}" has a function-form verify, which has no fixed list to address into - use withVerify to replace it wholesale instead`);
255
+ }
256
+ const traits = verify ?? [];
257
+ const index = typeof nameOrIndex === "number" ? nameOrIndex : traits.findIndex((t) => t.name === nameOrIndex);
258
+ const existing = traits[index];
259
+ if (!existing) {
260
+ throw new Error(typeof nameOrIndex === "number"
261
+ ? `modVerify: "${block.name}" has no verify trait at index ${nameOrIndex} (has ${traits.length})`
262
+ : `modVerify: "${block.name}" has no verify trait named "${nameOrIndex}" (has: ${traits.map((t) => t.name).join(", ") || "none"})`);
263
+ }
264
+ const replacement = typeof newCheck === "function"
265
+ ? { name: typeof nameOrIndex === "string" ? nameOrIndex : existing.name, check: newCheck }
266
+ : newCheck;
267
+ const updated = [...traits];
268
+ updated[index] = replacement;
269
+ return withVerify(block, updated);
270
+ }
271
+ /**
272
+ * `modVerify`, but for several named checks in one call - a v2 variant that
273
+ * differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
274
+ * modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
275
+ * Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
276
+ * fine here - unlike `MemPage`, there's no identity-collision reason to need
277
+ * `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
278
+ * same fail-loud-on-unknown-name behavior holds for every entry.
279
+ */
280
+ export function modVerifyAll(block, patches) {
281
+ return Object.entries(patches).reduce((current, [name, newCheck]) => modVerify(current, name, newCheck), defineBlock(stripMethods(block)));
282
+ }
@@ -0,0 +1,6 @@
1
+ export type { Checkpoint, Instruction, Block, DefinedBlock } from "./types.js";
2
+ export { connect, checkpoint } from "./types.js";
3
+ export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
+ export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
+ export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
6
+ export type { Flow, EngineConfig } from "./engine.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { connect, checkpoint } from "./types.js";
2
+ export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
+ export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
+ export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A typed key into MemPage. The phantom `_phantom` field is never assigned - it
3
+ * exists only so TypeScript can infer `T` at each call site. The store is keyed by
4
+ * the MemKey *instance* (object identity), not by `name`, so two keys can share a
5
+ * debug name and never collide - `name` is for error messages only.
6
+ */
7
+ export declare class MemKey<T> {
8
+ readonly name: string;
9
+ private readonly _phantom;
10
+ constructor(name: string);
11
+ }
12
+ /**
13
+ * Builds a MemKey for a single typed value - the `T` you give here is what
14
+ * `mem.get`/`mem.set` enforce everywhere this key is used.
15
+ * @example const Username = key<string>("saucedemo.username");
16
+ */
17
+ export declare function key<T>(name: string): MemKey<T>;
18
+ /**
19
+ * A named shorthand for a single MemKey holding a whole shaped value (a
20
+ * credentials object, not two separate primitive keys). `LoginCreds({ username,
21
+ * password })` returns a `[key, value]` pair ready for `setAll`; `LoginCreds.key`
22
+ * is the underlying MemKey, for `mem.get(LoginCreds.key)` or `Block.requires`.
23
+ * `T` is given explicitly at the `keyGroup<T>(...)` call site, not inferred from
24
+ * how it's later called, so this doesn't carry the same inference risk `setAll`'s
25
+ * first attempt did.
26
+ */
27
+ export declare function keyGroup<T>(name: string): ((value: T) => readonly [MemKey<T>, T]) & {
28
+ key: MemKey<T>;
29
+ };
30
+ /**
31
+ * Typed, identity-keyed shared memory for one graph run. Reading a key that was
32
+ * never written throws immediately, naming the key, instead of returning
33
+ * `undefined` - a misuse fails loud at the read site, not silently downstream.
34
+ */
35
+ export declare class MemPage {
36
+ private readonly store;
37
+ /** `mem.set(key, value)`, or `mem.set(LoginCreds({ ... }))` with a `keyGroup` pair directly - no "All" for just one. */
38
+ set<T>(k: MemKey<T>, value: T): void;
39
+ set<T>(pair: readonly [MemKey<T>, T]): void;
40
+ /**
41
+ * Reads `k`'s value, throwing (naming `k`) if it was never `set` - never
42
+ * returns `undefined` for a genuinely missing key, so a misuse fails loud at
43
+ * the read site instead of surfacing as a confusing error deep in `act`.
44
+ * @example const { username, password } = mem.get(LoginCreds.key);
45
+ */
46
+ get<T>(k: MemKey<T>): T;
47
+ /** True once `k` has been `set` - use before a `get` that's genuinely optional. */
48
+ has<T>(k: MemKey<T>): boolean;
49
+ /** Covers add/remove/insert/nest generically - MemPage never needs to know the shape of T. */
50
+ update<T>(k: MemKey<T>, fn: (current: T) => T): void;
51
+ /**
52
+ * Sets several keys in one call - `mem.setAll([UsernameKey, "standard_user"], [PasswordKey, "secret_sauce"])`
53
+ * instead of one `.set()` per line. Can't take a plain `{ ... }` object literal:
54
+ * MemKey instances are the identity the store is keyed by, and object literals
55
+ * can only have string/symbol property names, not object instances - a `[key,
56
+ * value]` pair is the shape that keeps that identity-based guarantee.
57
+ *
58
+ * Overloaded per pair count (2-6, same reasoning as `Engine.defineFlow`'s
59
+ * overloads) rather than one fully-generic variadic signature - each pair binds
60
+ * its own type parameter independently, which TS checks reliably; a single
61
+ * self-referential mapped-tuple-over-a-rest-parameter type was tried first and
62
+ * silently failed to catch a mismatched pair, so it was not trusted over this.
63
+ */
64
+ setAll<A>(p1: readonly [MemKey<A>, A]): void;
65
+ setAll<A, B>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B]): void;
66
+ setAll<A, B, C>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C]): void;
67
+ setAll<A, B, C, D>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D]): void;
68
+ setAll<A, B, C, D, E>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D], p5: readonly [MemKey<E>, E]): void;
69
+ setAll<A, B, C, D, E, F>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D], p5: readonly [MemKey<E>, E], p6: readonly [MemKey<F>, F]): void;
70
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A typed key into MemPage. The phantom `_phantom` field is never assigned - it
3
+ * exists only so TypeScript can infer `T` at each call site. The store is keyed by
4
+ * the MemKey *instance* (object identity), not by `name`, so two keys can share a
5
+ * debug name and never collide - `name` is for error messages only.
6
+ */
7
+ export class MemKey {
8
+ name;
9
+ constructor(name) {
10
+ this.name = name;
11
+ }
12
+ }
13
+ /**
14
+ * Builds a MemKey for a single typed value - the `T` you give here is what
15
+ * `mem.get`/`mem.set` enforce everywhere this key is used.
16
+ * @example const Username = key<string>("saucedemo.username");
17
+ */
18
+ export function key(name) {
19
+ return new MemKey(name);
20
+ }
21
+ /**
22
+ * A named shorthand for a single MemKey holding a whole shaped value (a
23
+ * credentials object, not two separate primitive keys). `LoginCreds({ username,
24
+ * password })` returns a `[key, value]` pair ready for `setAll`; `LoginCreds.key`
25
+ * is the underlying MemKey, for `mem.get(LoginCreds.key)` or `Block.requires`.
26
+ * `T` is given explicitly at the `keyGroup<T>(...)` call site, not inferred from
27
+ * how it's later called, so this doesn't carry the same inference risk `setAll`'s
28
+ * first attempt did.
29
+ */
30
+ export function keyGroup(name) {
31
+ const k = key(name);
32
+ const factory = (value) => [k, value];
33
+ return Object.assign(factory, { key: k });
34
+ }
35
+ /**
36
+ * Typed, identity-keyed shared memory for one graph run. Reading a key that was
37
+ * never written throws immediately, naming the key, instead of returning
38
+ * `undefined` - a misuse fails loud at the read site, not silently downstream.
39
+ */
40
+ export class MemPage {
41
+ store = new Map();
42
+ set(kOrPair, value) {
43
+ const [k, v] = kOrPair instanceof MemKey ? [kOrPair, value] : kOrPair;
44
+ this.store.set(k, v);
45
+ }
46
+ /**
47
+ * Reads `k`'s value, throwing (naming `k`) if it was never `set` - never
48
+ * returns `undefined` for a genuinely missing key, so a misuse fails loud at
49
+ * the read site instead of surfacing as a confusing error deep in `act`.
50
+ * @example const { username, password } = mem.get(LoginCreds.key);
51
+ */
52
+ get(k) {
53
+ if (!this.store.has(k)) {
54
+ throw new Error(`MemPage: "${k.name}" read before it was set`);
55
+ }
56
+ return this.store.get(k);
57
+ }
58
+ /** True once `k` has been `set` - use before a `get` that's genuinely optional. */
59
+ has(k) {
60
+ return this.store.has(k);
61
+ }
62
+ /** Covers add/remove/insert/nest generically - MemPage never needs to know the shape of T. */
63
+ update(k, fn) {
64
+ this.set(k, fn(this.get(k)));
65
+ }
66
+ setAll(...pairs) {
67
+ for (const [k, v] of pairs) {
68
+ this.set(k, v);
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,62 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { MemPage } from "./mem-page.js";
3
+ /**
4
+ * A named, independently-reportable check. `check` never runs before `resolve` has
5
+ * already decided the Checkpoint, and it can only confirm or fail loud - it has no
6
+ * way to redirect the run.
7
+ */
8
+ export interface Trait {
9
+ /** Shows up in the error a failed check throws: `trait "${name}" failed after "${blockName}"`. */
10
+ name: string;
11
+ /** Returns `true` if the check passes. May wait (e.g. `page.waitForURL`) - just don't redirect the run. */
12
+ check(page: Page, mem: MemPage): Promise<boolean>;
13
+ }
14
+ /**
15
+ * Common, generic Trait factories - reusable across any Block, not one-off
16
+ * `{ name, check }` objects written inline per Block. Reach for one of these
17
+ * first; write a bespoke Trait only for a check that's genuinely app-specific.
18
+ */
19
+ /**
20
+ * Passes once the page's URL matches `pattern`, waiting for real navigation.
21
+ * `pattern` is a structured `URLPatternInit` (the same shape the standard
22
+ * `URLPattern` Web API takes) - `{ pathname, hostname, search, hash, ... }` -
23
+ * not a hand-written regex. Whichever components you don't specify default to
24
+ * "match anything" there, so a hybrid SPA tacking its own `?tab=cart&session=xyz`
25
+ * onto the URL never breaks a `pathname`-only check - there's no separate flag
26
+ * for that, omission already means "don't care." Reach for a bespoke Trait with
27
+ * a raw regex only if a route genuinely needs pattern power `URLPatternInit`'s
28
+ * fields can't express.
29
+ * @example Trait.url({ pathname: "/inventory.html" })
30
+ * @example Trait.url({ hostname: "checkout.example.com", pathname: "/pay" })
31
+ * @example Trait.url({ pathname: "/users/:id" }) // named path params, matched structurally
32
+ */
33
+ export declare function urlMatches(pattern: URLPatternInit): Trait;
34
+ /** Passes when `selector`'s text content is exactly `expected`. */
35
+ export declare function textEquals(selector: string, expected: string): Trait;
36
+ /** Passes once `selector` is visible on the page, waiting for it to appear. */
37
+ export declare function visible(selector: string): Trait;
38
+ /**
39
+ * Discoverable entry point for the built-in Trait factories - type `Trait.` in
40
+ * an editor to see `url`/`text`/`visible` offered, instead of needing to
41
+ * already know `urlMatches`/`textEquals`/`visible` exist as free functions to
42
+ * import by name. Same functions, just reachable off the type's own name -
43
+ * `urlMatches`/`textEquals`/`visible` stay exported too, so nothing that
44
+ * already imports them by name breaks.
45
+ * @example verify: [Trait.url({ pathname: "/inventory.html" })]
46
+ * @example verify: [Trait.text(".shopping_cart_badge", "1")]
47
+ */
48
+ export declare const Trait: {
49
+ url: typeof urlMatches;
50
+ text: typeof textEquals;
51
+ visible: typeof visible;
52
+ };
53
+ /**
54
+ * Runs every Trait for a Block that just resolved, throwing on the first failure
55
+ * with the failing Trait's own name and the Block's name - never called before
56
+ * resolve, never able to change what resolve already decided. `verify` may be a
57
+ * flat list or a function of the resolved Checkpoint (for a branching Block whose
58
+ * different outcomes need different checks). Called automatically by `runGraph`
59
+ * and `connect()` after every `resolve` - not something a Block normally calls
60
+ * directly.
61
+ */
62
+ export declare function runVerify<Out>(verify: Trait[] | ((out: Out) => Trait[]) | undefined, out: Out, page: Page, mem: MemPage, blockName: string): Promise<void>;
package/dist/trait.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Common, generic Trait factories - reusable across any Block, not one-off
3
+ * `{ name, check }` objects written inline per Block. Reach for one of these
4
+ * first; write a bespoke Trait only for a check that's genuinely app-specific.
5
+ */
6
+ /**
7
+ * Passes once the page's URL matches `pattern`, waiting for real navigation.
8
+ * `pattern` is a structured `URLPatternInit` (the same shape the standard
9
+ * `URLPattern` Web API takes) - `{ pathname, hostname, search, hash, ... }` -
10
+ * not a hand-written regex. Whichever components you don't specify default to
11
+ * "match anything" there, so a hybrid SPA tacking its own `?tab=cart&session=xyz`
12
+ * onto the URL never breaks a `pathname`-only check - there's no separate flag
13
+ * for that, omission already means "don't care." Reach for a bespoke Trait with
14
+ * a raw regex only if a route genuinely needs pattern power `URLPatternInit`'s
15
+ * fields can't express.
16
+ * @example Trait.url({ pathname: "/inventory.html" })
17
+ * @example Trait.url({ hostname: "checkout.example.com", pathname: "/pay" })
18
+ * @example Trait.url({ pathname: "/users/:id" }) // named path params, matched structurally
19
+ */
20
+ export function urlMatches(pattern) {
21
+ const urlPattern = new URLPattern(pattern);
22
+ return {
23
+ name: `url-matches(${JSON.stringify(pattern)})`,
24
+ async check(page) {
25
+ await page.waitForURL(urlPattern);
26
+ return true;
27
+ },
28
+ };
29
+ }
30
+ /** Passes when `selector`'s text content is exactly `expected`. */
31
+ export function textEquals(selector, expected) {
32
+ return {
33
+ name: `text-equals(${selector}, ${JSON.stringify(expected)})`,
34
+ async check(page) {
35
+ return (await page.locator(selector).textContent()) === expected;
36
+ },
37
+ };
38
+ }
39
+ /** Passes once `selector` is visible on the page, waiting for it to appear. */
40
+ export function visible(selector) {
41
+ return {
42
+ name: `visible(${selector})`,
43
+ async check(page) {
44
+ await page.locator(selector).waitFor({ state: "visible" });
45
+ return true;
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * Discoverable entry point for the built-in Trait factories - type `Trait.` in
51
+ * an editor to see `url`/`text`/`visible` offered, instead of needing to
52
+ * already know `urlMatches`/`textEquals`/`visible` exist as free functions to
53
+ * import by name. Same functions, just reachable off the type's own name -
54
+ * `urlMatches`/`textEquals`/`visible` stay exported too, so nothing that
55
+ * already imports them by name breaks.
56
+ * @example verify: [Trait.url({ pathname: "/inventory.html" })]
57
+ * @example verify: [Trait.text(".shopping_cart_badge", "1")]
58
+ */
59
+ export const Trait = {
60
+ url: urlMatches,
61
+ text: textEquals,
62
+ visible,
63
+ };
64
+ /**
65
+ * Runs every Trait for a Block that just resolved, throwing on the first failure
66
+ * with the failing Trait's own name and the Block's name - never called before
67
+ * resolve, never able to change what resolve already decided. `verify` may be a
68
+ * flat list or a function of the resolved Checkpoint (for a branching Block whose
69
+ * different outcomes need different checks). Called automatically by `runGraph`
70
+ * and `connect()` after every `resolve` - not something a Block normally calls
71
+ * directly.
72
+ */
73
+ export async function runVerify(verify, out, page, mem, blockName) {
74
+ const traits = typeof verify === "function" ? verify(out) : (verify ?? []);
75
+ for (const trait of traits) {
76
+ if (!(await trait.check(page, mem))) {
77
+ throw new Error(`trait "${trait.name}" failed after "${blockName}"`);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,124 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { MemPage, MemKey } from "./mem-page.js";
3
+ import type { Trait } from "./trait.js";
4
+ /**
5
+ * A Checkpoint is identified solely by its string tag - a phantom marker with no
6
+ * embedded data. Two Checkpoints with the same tag are the same state everywhere.
7
+ */
8
+ export type Checkpoint<Tag extends string> = {
9
+ readonly __state: Tag;
10
+ };
11
+ /**
12
+ * Builds a Checkpoint - spells `__state` for you so no call site does it by hand.
13
+ * @example resolve: () => checkpoint("LoggedIn")
14
+ */
15
+ export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag>;
16
+ /**
17
+ * The four-phase pipeline every Block runs. Most Blocks only need `act` + a
18
+ * one-line `resolve` - add `observe` only once a Block genuinely branches, add
19
+ * `verify` only once you want to confirm the reached state, not just classify it.
20
+ */
21
+ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoint<string>, Observed = void> {
22
+ /** Drives the browser only, this tab only. Never touches `context` - see `observe` for that. */
23
+ act(page: Page, input: In, mem: MemPage): Promise<void>;
24
+ /**
25
+ * Optional. Gathers evidence for `resolve` to classify, runs assertions,
26
+ * writes memory - the only phase allowed to look at the DOM before a
27
+ * decision is made, or open a second tab via `page.context()`.
28
+ */
29
+ observe?(page: Page, mem: MemPage): Promise<Observed>;
30
+ /**
31
+ * Mandatory, pure. Its only parameter is the evidence `observe` handed it -
32
+ * no `page`, no `mem` in scope, not by convention but because they're
33
+ * missing from the signature. Reproducible from `observed` alone.
34
+ */
35
+ resolve(observed: Observed): Out | Promise<Out>;
36
+ /**
37
+ * Optional. Runs once, only after `resolve` has already decided `Out` -
38
+ * confirms or fails loud, can never redirect what was decided. A function
39
+ * form picks different checks per branch (a failed login shouldn't be
40
+ * checked against "reached the dashboard"); see {@link Block.withVerify} and
41
+ * {@link Block.modVerify} for changing this from outside the Block's own file.
42
+ */
43
+ verify?: Trait[] | ((out: Out) => Trait[]);
44
+ }
45
+ /**
46
+ * A Block wraps one instruction. Its Observed type is an internal detail, erased here.
47
+ * `next` is optional routing attached by `branch()` - unset on a plain or
48
+ * `connect()`-composed Block, meaning "terminal after one step" (today's behavior,
49
+ * unchanged). Returning the Block itself from `next` is a self-loop. `requires` is
50
+ * MemKeys this Block's `act` reads from `mem` and needs already set - declaring it
51
+ * lets `preflight()` catch a missing value before any browser action runs, instead
52
+ * of failing loud deep inside `act` after several real steps already happened.
53
+ *
54
+ * `withVerify`/`modVerify`/`modVerifyAll` are optional - present (and discoverable
55
+ * by typing `someBlock.`) on any Block built via `defineBlock()`, absent on a raw
56
+ * object literal (which is still a perfectly valid Block, e.g. every test fixture
57
+ * in this codebase). They delegate to the identically-named free functions - same
58
+ * logic, chainable as methods for the common case, still callable as
59
+ * `withVerify(block, ...)` for a block that doesn't have them attached.
60
+ */
61
+ export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<string>> {
62
+ /** A short, stable label - shows up in every error this Block can produce ("trait X failed after Y"). */
63
+ name: string;
64
+ /** The act/observe/resolve/verify pipeline itself. See {@link Instruction}. */
65
+ instruction: Instruction<In, Out, any>;
66
+ /**
67
+ * Routing attached by {@link branch}. Unset on a plain or `connect()`-composed
68
+ * Block, meaning "terminal after one step". Returning the Block itself is a
69
+ * self-loop - see `runGraph`'s `maxSteps` for the safety net that needs.
70
+ */
71
+ next?(checkpoint: Out): Block<any, any> | null | undefined;
72
+ /**
73
+ * MemKeys this Block's `act` reads from `mem` and needs already set.
74
+ * Declaring it lets {@link preflight} (built into `runGraph`) catch a missing
75
+ * value before any browser action runs, instead of failing loud deep inside
76
+ * `act`.
77
+ * @example requires: [LoginCreds.key]
78
+ */
79
+ requires?: readonly MemKey<any>[];
80
+ /**
81
+ * Replaces this Block's whole `verify` list - the only way to clear it to
82
+ * empty (the "I only care that I navigated this far" case) or go from no
83
+ * verify to some. For tweaking one existing check without restating the
84
+ * rest, use {@link Block.modVerify} instead.
85
+ * @example LoginBlock.withVerify([]) // navigation-only, no DOM confirmation
86
+ * @example LoginBlock.withVerify([urlMatches(/dashboard/)])
87
+ */
88
+ withVerify?(verify: Trait[] | ((out: Out) => Trait[])): Block<In, Out>;
89
+ /**
90
+ * Replaces one existing Trait in this Block's `verify` list, by its `name`
91
+ * (recommended) or numeric index, leaving every other check untouched - the
92
+ * v1/v2/legacy-app case, where one check needs a different selector but the
93
+ * rest of the Block is identical. Throws loud if the name/index doesn't
94
+ * exist - use {@link Block.withVerify} if there's nothing there yet to replace.
95
+ * @example LoginBlock.modVerify("url-matches", urlMatches(/dashboard\.html/))
96
+ */
97
+ modVerify?(nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Block<In, Out>;
98
+ /**
99
+ * {@link Block.modVerify}, but for several named checks in one call - a v2
100
+ * variant that differs in five ways from v1 shouldn't need five nested calls.
101
+ * @example LoginBlock.modVerifyAll({ "url-matches": urlMatches(/v2/), "cart-badge": textEquals(".badge-v2", "1") })
102
+ */
103
+ modVerifyAll?(patches: Record<string, Trait["check"] | Trait>): Block<In, Out>;
104
+ }
105
+ /**
106
+ * A Block guaranteed to have `withVerify`/`modVerify`/`modVerifyAll` attached -
107
+ * what `defineBlock()` and, since they're built on it, `withVerify()`/`modVerify()`/
108
+ * `modVerifyAll()`/`branch()` all return. Lets `LoginBlock.withVerify(...)` be
109
+ * called directly, no `!`/`?.` needed - the type says the method is there, not
110
+ * "maybe". A plain `Block<In, Out>` (a raw object literal) doesn't get this;
111
+ * `DefinedBlock` is still assignable anywhere a `Block` is expected.
112
+ */
113
+ export type DefinedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> = Omit<Block<In, Out>, "withVerify" | "modVerify" | "modVerifyAll"> & {
114
+ withVerify(verify: Trait[] | ((out: Out) => Trait[])): DefinedBlock<In, Out>;
115
+ modVerify(nameOrIndex: string | number, newCheck: Trait["check"] | Trait): DefinedBlock<In, Out>;
116
+ modVerifyAll(patches: Record<string, Trait["check"] | Trait>): DefinedBlock<In, Out>;
117
+ };
118
+ /**
119
+ * Composes two Blocks into one, typechecked so this only compiles when A's output
120
+ * tag equals B's input tag. The composed Block's act runs A's full instruction
121
+ * (act -> observe? -> resolve) then hands the resulting Checkpoint into B's act;
122
+ * its own observe/resolve are simply B's, since B is what decides the final Out.
123
+ */
124
+ export declare function connect<A extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>>(a: Block<A, B>, b: Block<B, C>): Block<A, C>;
package/dist/types.js ADDED
@@ -0,0 +1,39 @@
1
+ import { runVerify } from "./trait.js";
2
+ /**
3
+ * Builds a Checkpoint - spells `__state` for you so no call site does it by hand.
4
+ * @example resolve: () => checkpoint("LoggedIn")
5
+ */
6
+ export function checkpoint(tag) {
7
+ return { __state: tag };
8
+ }
9
+ /**
10
+ * Composes two Blocks into one, typechecked so this only compiles when A's output
11
+ * tag equals B's input tag. The composed Block's act runs A's full instruction
12
+ * (act -> observe? -> resolve) then hands the resulting Checkpoint into B's act;
13
+ * its own observe/resolve are simply B's, since B is what decides the final Out.
14
+ */
15
+ export function connect(a, b) {
16
+ const bObserve = b.instruction.observe;
17
+ const bVerify = b.instruction.verify;
18
+ const requires = [...(a.requires ?? []), ...(b.requires ?? [])];
19
+ return {
20
+ name: `${a.name} -> ${b.name}`,
21
+ ...(requires.length > 0 ? { requires } : {}),
22
+ instruction: {
23
+ async act(page, input, mem) {
24
+ await a.instruction.act(page, input, mem);
25
+ const aObserved = a.instruction.observe
26
+ ? await a.instruction.observe(page, mem)
27
+ : undefined;
28
+ const mid = await a.instruction.resolve(aObserved);
29
+ await runVerify(a.instruction.verify, mid, page, mem, a.name);
30
+ await b.instruction.act(page, mid, mem);
31
+ },
32
+ resolve: (observed) => b.instruction.resolve(observed),
33
+ // Only attach optional fields when B actually has them - exactOptionalPropertyTypes
34
+ // forbids assigning `undefined` to an optional property directly.
35
+ ...(bObserve ? { observe: (page, mem) => bObserve(page, mem) } : {}),
36
+ ...(bVerify ? { verify: bVerify } : {}),
37
+ },
38
+ };
39
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "waygraph",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/deviate-dv8/waygraph.git"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "playwright test"
25
+ },
26
+ "peerDependencies": {
27
+ "@playwright/test": "^1.40.0"
28
+ },
29
+ "devDependencies": {
30
+ "@playwright/test": "^1.55.0",
31
+ "@types/node": "^26.0.0",
32
+ "typescript": "^5.6.0"
33
+ }
34
+ }