waygraph 0.0.1 → 0.1.0

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 CHANGED
@@ -1,12 +1,58 @@
1
1
  # Waygraph
2
2
 
3
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.
4
+ flat pile of ad hoc helper functions.
6
5
 
7
6
  This repo is the `waygraph` npm package itself. Consumer projects proving it against real
8
7
  sites/apps (saucedemo.com, zsign-app) live in the sibling `projects_waygraph/` repo, not here.
9
8
 
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install waygraph @playwright/test
13
+ ```
14
+
15
+ `@playwright/test` is a peer dependency, not bundled - you bring your own version (`^1.40.0`
16
+ or later) since it's also what drives the rest of your test suite. Requires Node 22+ (uses
17
+ `URL`/`URLPattern` from `@types/node` for `Trait.url`'s type checking).
18
+
19
+ ## Quick start
20
+
21
+ A Block wraps one `act`/`resolve` step (add `observe`/`verify` once you need them); a `Flow`
22
+ chains several into a run:
23
+
24
+ ```typescript
25
+ import { Engine, start, end, MemPage, key, checkpoint, Trait } from "waygraph";
26
+ import type { Block, Checkpoint, Start } from "waygraph";
27
+
28
+ type LoggedIn = Checkpoint<"LoggedIn">;
29
+
30
+ const Username = key<string>("username");
31
+
32
+ const Login: Block<Start, LoggedIn> = {
33
+ name: "login",
34
+ instruction: {
35
+ async act(page, _input, mem) {
36
+ await page.goto("https://example.com/login");
37
+ await page.getByLabel("Username").fill(mem.get(Username));
38
+ await page.getByRole("button", { name: "Sign in" }).click();
39
+ },
40
+ resolve: () => checkpoint("LoggedIn"),
41
+ verify: [Trait.url({ pathname: "/dashboard" })],
42
+ },
43
+ };
44
+
45
+ const mem = new MemPage();
46
+ mem.set(Username, "alice");
47
+
48
+ const flow = new Engine().defineFlow([start, Login, end]);
49
+ const result = await flow.run(mem); // Engine launches its own browser
50
+ // result === { __state: "LoggedIn" }
51
+ ```
52
+
53
+ Inside an existing `@playwright/test` file, reuse its `context` fixture instead of having the
54
+ Engine launch its own browser: `await flow.run(context, mem)`.
55
+
10
56
  **Status:** `Checkpoint`, `MemPage`, `Instruction`/`Block` (`act`/`observe`/`resolve`/`verify`),
11
57
  `connect()`, `Trait`, `Engine.defineFlow([start, ...blocks, end])`, and `branch()`/self-loops
12
58
  with a `maxSteps` safety cap. A Block's output type is the only contract the next Block can
@@ -34,17 +80,33 @@ components you leave out default to "match anything," so `Trait.url({ pathname:
34
80
  separate flag needed for that. Once Blocks are composed into a `Flow`, `flow.withBlockVerify`/
35
81
  `flow.modBlockVerify` patch one Block's verify from outside - a spec that only imports the
36
82
  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):
83
+ position in the flow - without editing the flow's own file. `composeBlock(name, steps)`
84
+ chains several Blocks into one named unit the same way `connect()` already does - the
85
+ multi-step-form case (a government form with several stepper pages, say), which is
86
+ naturally "one feature" but still wants each step's own real `verify` independently
87
+ overridable, not one giant Block with no per-step confirmation. `composedBlock.withStepVerify`/
88
+ `modStepVerify` patch one step from outside, addressed the same three ways
89
+ `withBlockVerify`/`modBlockVerify` already are - a `composeBlock` result is a plain Block,
90
+ so it drops straight into `defineFlow([start, ..., composed, ..., end])` like any other.
91
+ `spawnTab(entry, page, mem)` drives a genuinely separate second tab through its own
92
+ Block/Flow, in the same browser context an existing `page` already belongs to - the
93
+ pattern `observe()` could already reach for (it's the only phase allowed to touch
94
+ `page.context()`), now a named, discoverable, tested primitive instead of something
95
+ you'd have to already know to hand-roll. `flow.run(context, mem, options)` also accepts a
96
+ trailing optional options object - additively, so `flow.run(context, mem)` still behaves
97
+ byte-for-byte as before: `options.page` drives that already-open page instead of opening
98
+ a fresh tab (the run won't close a page you handed it unless you say so), and
99
+ `options.closeOnFinish: false` leaves the run's page open and returns `{ result, page }`
100
+ so the caller can keep driving it - hands a run tab back instead of losing it. See
101
+ "Recipes" for capture-on-popup, which stays a documented pattern until a second real use
102
+ case promotes it to an engine API.
103
+
104
+ [`create-waygraph`](https://github.com/deviate-dv8/create-waygraph) scaffolds a new
105
+ project (package.json, tsconfig, playwright.config, one working example flow) in one
106
+ command - built and proven end-to-end, not yet published to npm.
107
+
108
+ Deliberately **not yet implemented** (tracked on the project board):
41
109
  - 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
110
 
49
111
  ## Example
50
112
 
@@ -54,9 +116,8 @@ real page:
54
116
 
55
117
  ```typescript
56
118
  import { Engine, start, end, MemPage, key, checkpoint } from "waygraph";
57
- import type { Block, Checkpoint } from "waygraph";
119
+ import type { Block, Checkpoint, Start } from "waygraph";
58
120
 
59
- type Start = Checkpoint<"__start__">;
60
121
  type FormLoaded = Checkpoint<"FormLoaded">;
61
122
  type Submitted = Checkpoint<"Submitted">;
62
123
 
@@ -110,9 +171,52 @@ for real. The lower-level `connect()`/`runGraph()` still exist and are what `def
110
171
  builds on - reach for them directly only if you need a shape `defineFlow`'s array can't
111
172
  express yet.
112
173
 
113
- ## Commands
174
+ ## Recipes
175
+
176
+ ### Detect / drive the surviving tab after a run
177
+
178
+ The default run closes the page it opened, and a page you hand in via `options.page`
179
+ survives. For a flow that must BE the surviving tab after it returns (an interactive demo,
180
+ a hand-back to a caller), ask for the page back explicitly:
181
+
182
+ ```typescript
183
+ const { result, page } = await registerFlow.run(context, mem, { closeOnFinish: false });
184
+ // `result.__state` reached the terminal Checkpoint; `page` is still open where the
185
+ // flow left it - the caller keeps driving it, and closes it whenever IT is done.
186
+ ```
187
+
188
+ ### Capture a popup (`target=_blank` click)
189
+
190
+ Frameworks and email UIs routinely rewrite links to `target="_blank"`, so a click that
191
+ "should navigate" actually opens a second, engine-invisible tab. Until a second real use
192
+ case promotes this to an engine API, capture it in userland with a context-level page
193
+ event armed before the click that is expected to open exactly one popup:
194
+
195
+ ```typescript
196
+ let popup: Page | undefined;
197
+ context.once("page", (p) => { popup = p; });
198
+ await linkHandle.click(); // the block that causes the popup (e.g. the
199
+ // MailHog email's real verify link)
200
+ if (popup) {
201
+ await popup.waitForLoadState(); // fully loaded before calling into the engine
202
+ await verifyLinkFlow.run(context, mem, { page: popup, closeOnFinish: false });
203
+ // popup is now the caller's page - close it when done, or leave it as the
204
+ // surviving tab of a demo.
205
+ }
206
+ ```
207
+
208
+ `context.once` (not `.on`) guarantees a second stray popup won't re-fire the handler and
209
+ clobber your handle; if a page truly can open more than one popup, this recipe needs to
210
+ collect them instead. The popup is a normal Playwright `Page` from the same context, so
211
+ anything `Flow.run(context, mem, { page })` can drive is available to keep driving here,
212
+ and `closeOnFinish: false` hands it back into your ownership instead of the run closing
213
+ it out from under you.
214
+
215
+ ## Developing this package
114
216
 
115
217
  ```bash
218
+ git clone git@github.com:deviate-dv8/waygraph.git
219
+ cd waygraph
116
220
  npm install
117
221
  npm run typecheck # tsc --noEmit
118
222
  npm run test # playwright test
@@ -127,13 +231,12 @@ src/
127
231
  types.ts Checkpoint, Instruction, Block, connect()
128
232
  mem-page.ts MemKey, key(), MemPage
129
233
  trait.ts Trait (type + discoverable Trait.url/.text/.visible), runVerify()
130
- engine.ts runGraph(), Engine, start, end, Flow (+ withBlockVerify/modBlockVerify)
234
+ engine.ts runGraph(), spawnTab(), composeBlock(), Engine, start, end, Flow
131
235
  index.ts public barrel
132
236
  typecheck/ compile-time-only fixtures (assignability, @ts-expect-error cases)
133
237
  tests/ runtime unit + integration tests (@playwright/test)
134
238
  tsconfig.json base config - typecheck script includes src/ + typecheck/ + tests/
135
239
  tsconfig.build.json extends base, src/ only, emits to dist/
136
- openspec/ the OpenSpec change history for this repo (propose -> apply per change)
137
240
  ```
138
241
 
139
242
  Consumer projects (saucedemo.com proof, zsign-app integration) live in the sibling
package/bin/waygraph ADDED
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ # Two cwd/symlink pitfalls this resolves, both real, both hit in practice:
3
+ # - `--import tsx/esm` is a bare specifier - Node resolves it as if from an
4
+ # unnamed module at the CALLER's cwd, not this script's install
5
+ # directory, so it broke when invoked from anywhere else.
6
+ # - `npm link` makes this script reachable via a SYMLINK (e.g.
7
+ # ~/.nvm/.../bin/waygraph) - `dirname "$0"` alone follows argv[0], not the
8
+ # symlink target, so it resolved to the nvm bin dir instead of this
9
+ # package. `readlink -f` follows the symlink to the real file first.
10
+ SOURCE="$(readlink -f "$0")"
11
+ DIR="$(cd "$(dirname "$SOURCE")/.." && pwd)"
12
+ TSX_ESM="$(cd "$DIR" && node -e "console.log(require.resolve('tsx/esm'))")"
13
+ exec node --import "$TSX_ESM" "$DIR/dist/cli.js" "$@"
package/dist/cli.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * waygraph CLI -- tooling around this same package's engine.
4
+ *
5
+ * Commands:
6
+ * list [project] List discovered flows in a project
7
+ * nav [flow] [project] Print flow chain + navigation-only steps (goto / Trait.url)
8
+ * validate [project] Import and validate all flows
9
+ * run <flow> [project] Run a named flow with a fresh browser
10
+ * chain <spec> [project] Run one or more Blocks by name, ad hoc, no flow file needed
11
+ *
12
+ * "project" defaults to the current directory.
13
+ * A project is any directory containing *.flow.ts files (typically under src/flows/).
14
+ */
15
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * waygraph CLI -- tooling around this same package's engine.
4
+ *
5
+ * Commands:
6
+ * list [project] List discovered flows in a project
7
+ * nav [flow] [project] Print flow chain + navigation-only steps (goto / Trait.url)
8
+ * validate [project] Import and validate all flows
9
+ * run <flow> [project] Run a named flow with a fresh browser
10
+ * chain <spec> [project] Run one or more Blocks by name, ad hoc, no flow file needed
11
+ *
12
+ * "project" defaults to the current directory.
13
+ * A project is any directory containing *.flow.ts files (typically under src/flows/).
14
+ */
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ import { resolve, relative, join, basename } from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { MemPage } from "./mem-page.js";
19
+ // ---------------------------------------------------------------------------
20
+ // Filesystem
21
+ // ---------------------------------------------------------------------------
22
+ function walkDir(dir, pattern) {
23
+ const results = [];
24
+ let entries;
25
+ try {
26
+ entries = readdirSync(dir, { withFileTypes: true });
27
+ }
28
+ catch {
29
+ return results;
30
+ }
31
+ for (const entry of entries) {
32
+ const full = join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git")
35
+ continue;
36
+ results.push(...walkDir(full, pattern));
37
+ }
38
+ else if (pattern.test(entry.name)) {
39
+ results.push(full);
40
+ }
41
+ }
42
+ return results;
43
+ }
44
+ function discoverFlows(projectDir) {
45
+ return walkDir(projectDir, /\.flow\.ts$/);
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Static analysis for `list`/`nav` (no import, regex only)
49
+ // ---------------------------------------------------------------------------
50
+ function extractFlowNames(filePath) {
51
+ const src = readFileSync(filePath, "utf-8");
52
+ const names = [];
53
+ const re = /export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*[=:]/g;
54
+ let m;
55
+ while ((m = re.exec(src)) !== null) {
56
+ const name = m[1];
57
+ if (name !== undefined)
58
+ names.push(name);
59
+ }
60
+ return names;
61
+ }
62
+ /** `LoginBlock` -> `"login"`; `OverviewMetricsBlock` -> `"overview-metrics"`. */
63
+ function blockExportToLabel(exportId) {
64
+ const base = exportId.replace(/Block$/, "");
65
+ return base
66
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
67
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
68
+ .toLowerCase();
69
+ }
70
+ /** Every `...Block` identifier a `.flow.ts` file imports, in source order. */
71
+ function parseBlockImports(filePath) {
72
+ const src = readFileSync(filePath, "utf-8");
73
+ const names = [];
74
+ const re = /import\s*\{([^}]*)\}\s*from\s*["'][^"']*\.block(?:\.js)?["']/g;
75
+ let m;
76
+ while ((m = re.exec(src)) !== null) {
77
+ for (const part of m[1].split(",")) {
78
+ const name = part.trim().split(/\s+as\s+/)[0]?.trim();
79
+ if (name)
80
+ names.push(name);
81
+ }
82
+ }
83
+ return names;
84
+ }
85
+ /** Every `page.goto("...")` string literal in a file, in source order. */
86
+ function extractGotos(filePath) {
87
+ const src = readFileSync(filePath, "utf-8");
88
+ const results = [];
89
+ const re = /\.goto\(\s*["'`]([^"'`]*)["'`]/g;
90
+ let m;
91
+ while ((m = re.exec(src)) !== null) {
92
+ const url = m[1];
93
+ if (url !== undefined)
94
+ results.push(url);
95
+ }
96
+ return results;
97
+ }
98
+ /** Every `Trait.url({ pathname: "..." })` target in a file, in source order. */
99
+ function extractUrlTraits(filePath) {
100
+ const src = readFileSync(filePath, "utf-8");
101
+ const results = [];
102
+ const re = /Trait\.url\(\s*\{[^}]*pathname:\s*["'`]([^"'`]*)["'`]/g;
103
+ let m;
104
+ while ((m = re.exec(src)) !== null) {
105
+ const url = m[1];
106
+ if (url !== undefined)
107
+ results.push(url);
108
+ }
109
+ return results;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // `list`
113
+ // ---------------------------------------------------------------------------
114
+ function listCommand(projectDir) {
115
+ const files = discoverFlows(projectDir);
116
+ if (files.length === 0) {
117
+ console.log(`waygraph: no flows found under ${projectDir}`);
118
+ return;
119
+ }
120
+ for (const file of files) {
121
+ const rel = relative(projectDir, file);
122
+ for (const name of extractFlowNames(file)) {
123
+ console.log(`${rel} ${name}`);
124
+ }
125
+ }
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // `nav`
129
+ // ---------------------------------------------------------------------------
130
+ function navCommand(projectDir, flowName) {
131
+ const files = discoverFlows(projectDir);
132
+ const targets = flowName
133
+ ? files.filter((f) => extractFlowNames(f).includes(flowName))
134
+ : files;
135
+ if (targets.length === 0) {
136
+ console.error(flowName
137
+ ? `waygraph nav: no flow named "${flowName}" found under ${projectDir}`
138
+ : `waygraph: no flows found under ${projectDir}`);
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ for (const file of targets) {
143
+ for (const name of extractFlowNames(file)) {
144
+ console.log(`${name} (${relative(projectDir, file)})`);
145
+ const blockDir = projectDir;
146
+ for (const blockExport of parseBlockImports(file)) {
147
+ console.log(` -> ${blockExportToLabel(blockExport)}`);
148
+ const blockFile = walkDir(blockDir, /\.block\.ts$/).find((f) => readFileSync(f, "utf-8").includes(`export const ${blockExport}`));
149
+ if (!blockFile)
150
+ continue;
151
+ for (const url of extractGotos(blockFile))
152
+ console.log(` goto ${url}`);
153
+ for (const url of extractUrlTraits(blockFile))
154
+ console.log(` verify url ~ ${url}`);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Dynamic import + validation
161
+ // ---------------------------------------------------------------------------
162
+ async function importModule(filePath) {
163
+ const url = new URL(`file://${resolve(filePath)}`);
164
+ return (await import(url.href));
165
+ }
166
+ /** Kept as a named alias - `run`/`validate` below were written against this name first. */
167
+ const importFlowFile = importModule;
168
+ function isFlowLike(val) {
169
+ if (val === null || typeof val !== "object")
170
+ return false;
171
+ const obj = val;
172
+ return typeof obj.run === "function";
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // `chain`
176
+ // ---------------------------------------------------------------------------
177
+ /**
178
+ * `chain` cannot resolve Blocks by importing them straight into THIS
179
+ * process: this CLI's own `waygraph` (this same package) already loaded -
180
+ * and its engine does a top-level `import { chromium } from "playwright"`.
181
+ * A target project like zsign-all installs its OWN real, separate copy of
182
+ * both `waygraph` and `playwright` (never a symlink - see
183
+ * `install-links=true`), so importing one of its `.block.ts` files pulls in
184
+ * a second, physically different `playwright` package - which Playwright's
185
+ * own runtime refuses outright ("Requiring @playwright/test second time"),
186
+ * crashing every single import. Confirmed empirically against all 117 real
187
+ * zsign-all Blocks - 100% failure rate, not a corner case.
188
+ *
189
+ * The fix: never let the two copies share a process. This script is
190
+ * written into the TARGET project directory and run as its own child
191
+ * process, so every bare `import("waygraph")` / `import("playwright")` it
192
+ * does resolves relative to ITS OWN location on disk - the target
193
+ * project's own `node_modules`, exclusively. This CLI's own copy never
194
+ * loads in that process at all.
195
+ */
196
+ const CHAIN_RUNNER_SCRIPT = `
197
+ import { readdirSync } from "node:fs";
198
+ import { join } from "node:path";
199
+
200
+ function walkDir(dir, pattern) {
201
+ const results = [];
202
+ let entries;
203
+ try {
204
+ entries = readdirSync(dir, { withFileTypes: true });
205
+ } catch {
206
+ return results;
207
+ }
208
+ for (const entry of entries) {
209
+ const full = join(dir, entry.name);
210
+ if (entry.isDirectory()) {
211
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
212
+ results.push(...walkDir(full, pattern));
213
+ } else if (pattern.test(entry.name)) {
214
+ results.push(full);
215
+ }
216
+ }
217
+ return results;
218
+ }
219
+
220
+ function isBlockLike(val) {
221
+ if (val === null || typeof val !== "object") return false;
222
+ if (typeof val.name !== "string") return false;
223
+ if (val.instruction === null || typeof val.instruction !== "object") return false;
224
+ return typeof val.instruction.act === "function";
225
+ }
226
+
227
+ async function findBlock(projectDir, ref) {
228
+ const files = walkDir(projectDir, /\\.block\\.ts$/);
229
+ const byRuntimeName = [];
230
+ for (const file of files) {
231
+ let mod;
232
+ try {
233
+ mod = await import("file://" + file);
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const [exportName, exported] of Object.entries(mod)) {
238
+ if (!isBlockLike(exported)) continue;
239
+ if (exportName === ref) return { block: exported, exportName, file };
240
+ if (exported.name === ref) byRuntimeName.push({ block: exported, exportName, file });
241
+ }
242
+ }
243
+ if (byRuntimeName.length === 1) return byRuntimeName[0];
244
+ if (byRuntimeName.length > 1) {
245
+ throw new Error(
246
+ "waygraph chain: \\"" + ref + "\\" matches " + byRuntimeName.length + " Blocks by name - " +
247
+ byRuntimeName.map((b) => b.exportName + " (" + b.file + ")").join(", ") +
248
+ ". Use the export name to disambiguate.",
249
+ );
250
+ }
251
+ throw new Error("waygraph chain: no Block named \\"" + ref + "\\" found under " + projectDir);
252
+ }
253
+
254
+ function parseChainSpec(spec) {
255
+ return spec
256
+ .split(/\\bthen\\b/)
257
+ .map((s) => s.trim())
258
+ .filter((s) => s.length > 0)
259
+ .map((s) => {
260
+ const m = /^([A-Za-z_$][\\w-]*)\\s*(?:\\(([\\s\\S]*)\\))?$/.exec(s);
261
+ if (!m) {
262
+ throw new Error(
263
+ "waygraph chain: could not parse segment \\"" + s + "\\" - expected \\"blockName\\" or \\"blockName({...json...})\\"",
264
+ );
265
+ }
266
+ const ref = m[1];
267
+ const json = m[2] ? m[2].trim() : undefined;
268
+ return json ? { ref, json } : { ref };
269
+ });
270
+ }
271
+
272
+ function seedMemForBlock(mem, resolved, json) {
273
+ const requires = resolved.block.requires ?? [];
274
+ if (requires.length === 0) {
275
+ if (json !== undefined) {
276
+ throw new Error(
277
+ "waygraph chain: \\"" + resolved.exportName + "\\" takes no input (empty requires) but got a payload: " + json,
278
+ );
279
+ }
280
+ return;
281
+ }
282
+ if (json === undefined) {
283
+ throw new Error(
284
+ "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.map((k) => k.name).join(", ") +
285
+ " - give a JSON payload, e.g. " + resolved.exportName + "({...})",
286
+ );
287
+ }
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(json);
291
+ } catch (err) {
292
+ throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is not valid JSON - " + String(err));
293
+ }
294
+ if (requires.length === 1) {
295
+ mem.set(requires[0], parsed);
296
+ return;
297
+ }
298
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
299
+ throw new Error(
300
+ "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.length + " keys (" +
301
+ requires.map((k) => k.name).join(", ") + ") - payload must be an object keyed by each key's name",
302
+ );
303
+ }
304
+ for (const k of requires) {
305
+ if (!(k.name in parsed)) {
306
+ throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is missing required key \\"" + k.name + "\\"");
307
+ }
308
+ mem.set(k, parsed[k.name]);
309
+ }
310
+ }
311
+
312
+ async function main() {
313
+ const projectDir = process.argv[2];
314
+ const spec = process.argv[3];
315
+ const segments = parseChainSpec(spec);
316
+ if (segments.length === 0) {
317
+ throw new Error("waygraph chain: empty spec - give at least one block name");
318
+ }
319
+ const { connect, MemPage, Engine, start, end } = await import("waygraph");
320
+ const mem = new MemPage();
321
+ const resolved = [];
322
+ for (const seg of segments) {
323
+ const r = await findBlock(projectDir, seg.ref);
324
+ seedMemForBlock(mem, r, seg.json);
325
+ resolved.push(r);
326
+ }
327
+ console.log(
328
+ "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
329
+ " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
330
+ );
331
+ const blocks = resolved.map((r) => r.block);
332
+ const chained = blocks.reduce((a, b) => connect(a, b));
333
+ const headed = process.env.WAYGRAPH_HEADED === "1";
334
+ const slowMo = process.env.WAYGRAPH_SLOWMO ? Number(process.env.WAYGRAPH_SLOWMO) : undefined;
335
+ const baseURL = process.env.WAYGRAPH_BASE_URL;
336
+ const engine = new Engine({ headless: !headed, slowMo });
337
+ const flow = engine.defineFlow([start, chained, end]);
338
+ let result;
339
+ if (baseURL) {
340
+ const { chromium } = await import("playwright");
341
+ const browser = await chromium.launch({ headless: !headed, slowMo });
342
+ const context = await browser.newContext({ baseURL, viewport: { width: 1280, height: 720 } });
343
+ try {
344
+ result = await flow.run(context, mem);
345
+ } finally {
346
+ await browser.close();
347
+ }
348
+ } else {
349
+ result = await flow.run(mem);
350
+ }
351
+ console.log("waygraph: chain finished -- " + JSON.stringify(result));
352
+ }
353
+
354
+ main().catch((err) => {
355
+ console.error(err && err.stack ? err.stack : String(err));
356
+ process.exitCode = 1;
357
+ });
358
+ `;
359
+ /**
360
+ * Runs the chain runner script as a child process rooted at `projectDir` -
361
+ * see {@link CHAIN_RUNNER_SCRIPT} for why this can't just be imported
362
+ * in-process. `WAYGRAPH_BASE_URL` (relative `page.goto()` targets, like
363
+ * every real zsign-all Block uses), `WAYGRAPH_HEADED=1`, and
364
+ * `WAYGRAPH_SLOWMO=<ms>` pass straight through from this process's own env.
365
+ */
366
+ async function runChain(projectDir, spec) {
367
+ const tsxEsm = import.meta.resolve("tsx/esm");
368
+ const scriptPath = join(projectDir, `.waygraph-chain-${process.pid}.mjs`);
369
+ writeFileSync(scriptPath, CHAIN_RUNNER_SCRIPT);
370
+ try {
371
+ const code = await new Promise((res, rej) => {
372
+ const child = spawn(process.execPath, ["--import", tsxEsm, scriptPath, projectDir, spec], {
373
+ stdio: "inherit",
374
+ env: process.env,
375
+ });
376
+ child.on("error", rej);
377
+ child.on("exit", (code) => res(code ?? 1));
378
+ });
379
+ if (code !== 0) {
380
+ process.exitCode = code;
381
+ }
382
+ }
383
+ finally {
384
+ rmSync(scriptPath, { force: true });
385
+ }
386
+ }
387
+ async function validateFlows(projectDir) {
388
+ const files = discoverFlows(projectDir);
389
+ const results = [];
390
+ for (const file of files) {
391
+ try {
392
+ const mod = await importFlowFile(file);
393
+ for (const [exportName, exported] of Object.entries(mod)) {
394
+ if (isFlowLike(exported)) {
395
+ results.push({ file, name: exportName, valid: true });
396
+ }
397
+ }
398
+ }
399
+ catch (err) {
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ results.push({ file, name: basename(file, ".ts"), valid: false, error: msg });
402
+ }
403
+ }
404
+ return results;
405
+ }
406
+ // ---------------------------------------------------------------------------
407
+ // `run`
408
+ // ---------------------------------------------------------------------------
409
+ async function runFlow(projectDir, flowName) {
410
+ const files = discoverFlows(projectDir);
411
+ for (const file of files) {
412
+ let mod;
413
+ try {
414
+ mod = await importFlowFile(file);
415
+ }
416
+ catch {
417
+ continue;
418
+ }
419
+ for (const [exportName, exported] of Object.entries(mod)) {
420
+ if (!isFlowLike(exported) || exportName !== flowName)
421
+ continue;
422
+ console.log(`waygraph: running flow "${flowName}" from ${relative(projectDir, file)}`);
423
+ const result = await exported.run(new MemPage());
424
+ console.log(`waygraph: flow finished -- ${JSON.stringify(result)}`);
425
+ return;
426
+ }
427
+ }
428
+ console.error(`waygraph: no flow named "${flowName}" found under ${projectDir}`);
429
+ process.exitCode = 1;
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // CLI plumbing
433
+ // ---------------------------------------------------------------------------
434
+ const args = process.argv.slice(2);
435
+ const command = args[0];
436
+ function usage() {
437
+ console.log(`waygraph -- graph project tool + engine CLI
438
+
439
+ Usage:
440
+ waygraph list [project] List discovered flows
441
+ waygraph nav [flow] [project] Print flow chain + navigation steps
442
+ waygraph validate [project] Import and validate all flows
443
+ waygraph run <flow> [project] Run a named flow
444
+ waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
445
+
446
+ "chain" needs no .flow.ts file - reference Blocks (by their own
447
+ runtime .name, or their export identifier) straight from the command
448
+ line, chained with "then", each optionally given a JSON payload for
449
+ whatever it requires:
450
+
451
+ waygraph chain "login({\\"email\\":\\"a@b.com\\",\\"password\\":\\"x\\"}) then overview-metrics"
452
+
453
+ A Block with no requires needs no payload. One with several required
454
+ keys takes one JSON object keyed by each key's own name instead of a
455
+ flat payload.
456
+
457
+ Runs in a child process rooted at "project" (its own waygraph/playwright,
458
+ never this CLI's) so a project's real Blocks resolve against its own
459
+ node_modules. Env vars, all optional:
460
+ WAYGRAPH_BASE_URL base URL for Blocks using relative page.goto()
461
+ WAYGRAPH_HEADED=1 show the browser instead of headless
462
+ WAYGRAPH_SLOWMO=ms slow down each Playwright action, for watching a run
463
+
464
+ "project" defaults to the current directory.
465
+ `);
466
+ process.exit(0);
467
+ }
468
+ async function main() {
469
+ if (!command || command === "--help" || command === "-h") {
470
+ usage();
471
+ }
472
+ switch (command) {
473
+ case "list": {
474
+ const proj = resolve(args[1] ?? process.cwd());
475
+ if (!existsSync(proj)) {
476
+ console.error(`waygraph: no such directory: ${proj}`);
477
+ process.exit(1);
478
+ }
479
+ listCommand(proj);
480
+ break;
481
+ }
482
+ case "nav": {
483
+ const flowName = args[1] && args[1] !== "." ? args[1] : undefined;
484
+ const proj = resolve(args[2] ?? process.cwd());
485
+ navCommand(proj, flowName);
486
+ break;
487
+ }
488
+ case "validate": {
489
+ const proj = resolve(args[1] ?? process.cwd());
490
+ const results = await validateFlows(proj);
491
+ let failed = 0;
492
+ for (const r of results) {
493
+ const rel = relative(proj, r.file);
494
+ if (r.valid) {
495
+ console.log(`OK ${rel} ${r.name}`);
496
+ }
497
+ else {
498
+ failed++;
499
+ console.log(`FAIL ${rel} ${r.name} -- ${r.error}`);
500
+ }
501
+ }
502
+ if (failed > 0)
503
+ process.exitCode = 1;
504
+ break;
505
+ }
506
+ case "run": {
507
+ const flowName = args[1];
508
+ if (!flowName) {
509
+ console.error("waygraph run: missing <flow>, e.g. waygraph run LoginFlow");
510
+ process.exit(1);
511
+ }
512
+ const proj = resolve(args[2] ?? process.cwd());
513
+ await runFlow(proj, flowName);
514
+ break;
515
+ }
516
+ case "chain": {
517
+ const spec = args[1];
518
+ if (!spec) {
519
+ console.error('waygraph chain: missing <spec>, e.g. waygraph chain "login({...}) then overview-metrics"');
520
+ process.exit(1);
521
+ }
522
+ const proj = resolve(args[2] ?? process.cwd());
523
+ await runChain(proj, spec);
524
+ break;
525
+ }
526
+ default:
527
+ console.error(`waygraph: unknown command "${command}"`);
528
+ process.exit(1);
529
+ }
530
+ }
531
+ main().catch((err) => {
532
+ console.error(err instanceof Error ? err.message : String(err));
533
+ process.exitCode = 1;
534
+ });
package/dist/engine.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BrowserContext } from "@playwright/test";
1
+ import type { BrowserContext, Page } from "@playwright/test";
2
2
  import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
3
3
  import { MemPage } from "./mem-page.js";
4
4
  import type { Trait } from "./trait.js";
@@ -29,7 +29,45 @@ export declare function preflight(mem: MemPage, block: Block<any, any>): void;
29
29
  * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
30
30
  * no routing at all) - there is nothing to validate against.
31
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>;
32
+ export declare function runGraph<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, terminals: ReadonlySet<string> | undefined, context: BrowserContext, mem: MemPage, maxSteps?: number, options?: RunGraphOptions): Promise<TOut>;
33
+ /** Options for `runGraph`'s trailing `options` arg and for {@link Flow.run}'s `options` overload. */
34
+ export interface RunGraphOptions {
35
+ /**
36
+ * Drive this already-open page instead of opening a new one via
37
+ * `context.newPage()`. Lets a run continue on a tab the caller already
38
+ * has open (e.g. one that's already logged in), instead of every run
39
+ * being forced to start its own fresh tab and lose access to whatever was
40
+ * already there.
41
+ */
42
+ page?: Page;
43
+ /**
44
+ * `Flow.run` defaults this to `true` only when it opened the page itself
45
+ * (no `page` given) - matching every call from before this option
46
+ * existed. If you pass your own `page`, it defaults to `false` instead:
47
+ * a page you opened is yours to close, not this run's to close out from
48
+ * under you. Either way an explicit value here always wins. `runGraph`
49
+ * itself has no such page-aware default (its caller already decided
50
+ * `page` and `closeOnFinish` together) - it just does what you ask.
51
+ */
52
+ closeOnFinish?: boolean;
53
+ }
54
+ /**
55
+ * Drives a genuinely separate second tab through its own Block/Flow, in the
56
+ * same browser context an existing `page` already belongs to - the primitive
57
+ * `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
58
+ * since `act`/`resolve` can't touch `page.context()` at all. A thin, named
59
+ * wrapper around `runGraph` (which already opens its own page from whatever
60
+ * context it's given) rather than a new mechanism - the point is making the
61
+ * pattern discoverable and giving it a real name, not inventing new tab-lifecycle
62
+ * logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
63
+ * resolves; the original `page` is never touched.
64
+ * @example
65
+ * async observe(page, mem) {
66
+ * const receipt = await spawnTab(EmailReceiptFlow, page, mem);
67
+ * mem.set(ReceiptCode, receipt.__state);
68
+ * }
69
+ */
70
+ export declare function spawnTab<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, page: Page, mem: MemPage, maxSteps?: number): Promise<TOut>;
33
71
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
34
72
  export declare const start: unique symbol;
35
73
  export declare const end: unique symbol;
@@ -69,6 +107,24 @@ type EndMarker = typeof end;
69
107
  */
70
108
  export interface Flow<Out extends Checkpoint<string>> {
71
109
  run(context: BrowserContext, mem: MemPage): Promise<Out>;
110
+ /**
111
+ * `options.closeOnFinish: false` hands the driven page back instead of
112
+ * closing it - use this overload (a literal `false`, not a `boolean`) to
113
+ * get the `{ result, page }` shape typed correctly.
114
+ * @example const { result, page } = await loginFlow.run(context, mem, { closeOnFinish: false });
115
+ */
116
+ run(context: BrowserContext, mem: MemPage, options: RunGraphOptions & {
117
+ closeOnFinish: false;
118
+ }): Promise<{
119
+ result: Out;
120
+ page: Page;
121
+ }>;
122
+ /**
123
+ * `options.page` drives that already-open page instead of opening a new
124
+ * one - see {@link RunGraphOptions}.
125
+ * @example await loginFlow.run(context, mem, { page: existingPage });
126
+ */
127
+ run(context: BrowserContext, mem: MemPage, options?: RunGraphOptions): Promise<Out>;
72
128
  run(mem: MemPage, config?: EngineConfig): Promise<Out>;
73
129
  /**
74
130
  * Replaces the given Block's whole verify list, wherever it sits in this
@@ -87,6 +143,50 @@ export interface Flow<Out extends Checkpoint<string>> {
87
143
  */
88
144
  modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
89
145
  }
146
+ /**
147
+ * A Block guaranteed to have `withStepVerify`/`modStepVerify` attached - what
148
+ * {@link composeBlock} returns. Extends plain `Block`, so it drops straight into
149
+ * `defineFlow([start, ..., composed, ..., end])` like any other Block; no special
150
+ * casing needed anywhere that only expects a `Block`.
151
+ */
152
+ export interface ComposedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> extends Block<In, Out> {
153
+ /**
154
+ * Replaces one step's whole verify list, wherever it sits in this composed
155
+ * Block - same rules as {@link withVerify}. Throws if no step matches.
156
+ * @example GovFormBlock.withStepVerify("step-2-address", [])
157
+ */
158
+ withStepVerify(step: Block<any, any> | string | number, verify: Trait[] | ((out: any) => Trait[])): ComposedBlock<In, Out>;
159
+ /**
160
+ * Replaces one Trait inside one step's verify list - same rules as
161
+ * {@link modVerify}. Throws if no step matches, or that step has no verify
162
+ * trait at the given name/index.
163
+ * @example GovFormBlock.modStepVerify("step-2-address", "zip-visible", Trait.visible("#zip-confirmed"))
164
+ */
165
+ modStepVerify(step: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): ComposedBlock<In, Out>;
166
+ }
167
+ /**
168
+ * Chains several Blocks into one named, exported unit - what a naturally
169
+ * multi-step feature (a stepper form with several pages, say) wants instead of
170
+ * either one giant Block with no per-step confirmation, or several separately
171
+ * exported Blocks the caller has to wire together by hand every time. Each
172
+ * step keeps its own real `act`/`resolve`/`verify`, chained the same way
173
+ * `connect()` already chains any two Blocks - `composeBlock` is not a new
174
+ * execution mechanism, just `connect()` reduced over the list plus a name and
175
+ * the same step-list closure `Flow` already keeps for its own Block list, so
176
+ * `withStepVerify`/`modStepVerify` can patch one step from outside without the
177
+ * caller needing to hand-rebuild the chain. Resolution is by `.name`, not
178
+ * object identity, so a step reference stays valid even after an earlier patch
179
+ * swapped that slot for a new object - see {@link Flow.withBlockVerify} for
180
+ * the identical guarantee one level up. Each call returns a new composed
181
+ * Block; the original is untouched.
182
+ * @example composeBlock("gov-form", [step1, step2, step3, step4])
183
+ */
184
+ export declare function composeBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, Out>]): ComposedBlock<In, Out>;
185
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, Out>]): ComposedBlock<In, Out>;
186
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, Out>]): ComposedBlock<In, Out>;
187
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, Out>]): ComposedBlock<In, Out>;
188
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, Out>]): ComposedBlock<In, Out>;
189
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, F>, Block<F, Out>]): ComposedBlock<In, Out>;
90
190
  /** 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
191
  export interface EngineConfig {
92
192
  /** Default `true`, matching Playwright's own default. */
package/dist/engine.js CHANGED
@@ -37,9 +37,10 @@ export function preflight(mem, block) {
37
37
  * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
38
38
  * no routing at all) - there is nothing to validate against.
39
39
  */
40
- export async function runGraph(entry, terminals, context, mem, maxSteps = 5000) {
40
+ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000, options) {
41
41
  preflight(mem, entry);
42
- const page = await context.newPage();
42
+ const page = options?.page ?? (await context.newPage());
43
+ const closeOnFinish = options?.closeOnFinish ?? true;
43
44
  try {
44
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
46
  let current = entry;
@@ -67,9 +68,30 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000)
67
68
  }
68
69
  }
69
70
  finally {
70
- await page.close();
71
+ if (closeOnFinish) {
72
+ await page.close();
73
+ }
71
74
  }
72
75
  }
76
+ /**
77
+ * Drives a genuinely separate second tab through its own Block/Flow, in the
78
+ * same browser context an existing `page` already belongs to - the primitive
79
+ * `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
80
+ * since `act`/`resolve` can't touch `page.context()` at all. A thin, named
81
+ * wrapper around `runGraph` (which already opens its own page from whatever
82
+ * context it's given) rather than a new mechanism - the point is making the
83
+ * pattern discoverable and giving it a real name, not inventing new tab-lifecycle
84
+ * logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
85
+ * resolves; the original `page` is never touched.
86
+ * @example
87
+ * async observe(page, mem) {
88
+ * const receipt = await spawnTab(EmailReceiptFlow, page, mem);
89
+ * mem.set(ReceiptCode, receipt.__state);
90
+ * }
91
+ */
92
+ export async function spawnTab(entry, page, mem, maxSteps = 5000) {
93
+ return runGraph(entry, undefined, page.context(), mem, maxSteps);
94
+ }
73
95
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
74
96
  export const start = Symbol("waygraph.start");
75
97
  export const end = Symbol("waygraph.end");
@@ -89,31 +111,76 @@ function findBlockIndex(middle, block) {
89
111
  }
90
112
  function buildFlow(middle, engineConfig) {
91
113
  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
+ // Declared separately (not as the object-literal method below) so the
115
+ // three-overload `Flow.run` signature can be checked against the actual
116
+ // per-call-shape behavior, then attached with a cast - object-literal
117
+ // methods can't satisfy TS's per-overload implementation check the way a
118
+ // standalone function assigned to the property can.
119
+ const run = async function run(contextOrMem, memOrConfig, options) {
120
+ if (contextOrMem instanceof MemPage) {
121
+ const mem = contextOrMem;
122
+ const config = { ...engineConfig, ...memOrConfig };
123
+ // Checked again inside runGraph too, but doing it here first means a
124
+ // missing key fails before a browser process even launches, not just
125
+ // before a page opens - the same "fail loud, cheaply" guarantee
126
+ // run(context, mem) already has via an existing context.
127
+ preflight(mem, chain);
128
+ const browserName = config.browserName ?? "chromium";
129
+ const launch = LAUNCHERS[browserName];
130
+ // If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
131
+ // or Flatpak Chromium), launch that instead of Playwright's own bundled
132
+ // binary - only for chromium (the env var names a Chromium build, not a
133
+ // Firefox/WebKit one), and a no-op default anywhere that env var isn't set.
134
+ const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
135
+ const browser = await launch.launch({
136
+ headless: config.headless ?? true,
137
+ ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
138
+ ...(executablePath ? { executablePath } : {}),
139
+ });
140
+ try {
141
+ const context = await browser.newContext();
142
+ return await runGraph(chain, undefined, context, mem);
114
143
  }
115
- return runGraph(chain, undefined, contextOrMem, memOrConfig);
116
- },
144
+ finally {
145
+ await browser.close();
146
+ }
147
+ }
148
+ const context = contextOrMem;
149
+ const mem = memOrConfig;
150
+ // Open (or reuse) the page here, not inside runGraph, so this method -
151
+ // not runGraph - decides whether to close it once the run is over; the
152
+ // page reference has to survive past runGraph's own return either way.
153
+ const gotOwnPage = options?.page === undefined;
154
+ const page = options?.page ?? (await context.newPage());
155
+ // Defaulting close-on-finish to `true` unconditionally would close a
156
+ // page the *caller* opened and handed in via `options.page` - the exact
157
+ // thing this option exists to let a caller keep driving. Only default to
158
+ // closing when this call opened the page itself (no `options.page`
159
+ // given), which is the one case that matches every `run(context, mem)`
160
+ // call from before this option existed. An explicit `closeOnFinish`
161
+ // always wins either way.
162
+ const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
163
+ const result = await runGraph(chain, undefined, context, mem, 5000, {
164
+ page,
165
+ closeOnFinish: false,
166
+ });
167
+ if (closeOnFinish) {
168
+ await page.close();
169
+ }
170
+ // The return *shape* only changes on an explicit `closeOnFinish: false`
171
+ // (the literal the `{ result, page }` overload is typed against) - not
172
+ // on the computed `closeOnFinish` default above. A caller who passed
173
+ // their own `page` and left `closeOnFinish` unset already holds that
174
+ // page reference; handing it back again in a different return shape
175
+ // would silently break the plain-`Out` overload their call actually
176
+ // matched at the type level.
177
+ if (options?.closeOnFinish === false) {
178
+ return { result, page };
179
+ }
180
+ return result;
181
+ };
182
+ return {
183
+ run: run,
117
184
  withBlockVerify(block, verify) {
118
185
  const index = findBlockIndex(middle, block);
119
186
  const patched = [...middle];
@@ -128,6 +195,28 @@ function buildFlow(middle, engineConfig) {
128
195
  },
129
196
  };
130
197
  }
198
+ export function composeBlock(name, steps) {
199
+ if (steps.length === 0) {
200
+ throw new Error(`composeBlock: "${name}" needs at least one step`);
201
+ }
202
+ const chain = steps.reduce((a, b) => connect(a, b));
203
+ return {
204
+ ...chain,
205
+ name,
206
+ withStepVerify(step, verify) {
207
+ const index = findBlockIndex(steps, step);
208
+ const patched = [...steps];
209
+ patched[index] = withVerify(steps[index], verify);
210
+ return composeBlock(name, patched);
211
+ },
212
+ modStepVerify(step, nameOrIndex, newCheck) {
213
+ const index = findBlockIndex(steps, step);
214
+ const patched = [...steps];
215
+ patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
216
+ return composeBlock(name, patched);
217
+ },
218
+ };
219
+ }
131
220
  /**
132
221
  * Holds engine-level configuration shared across every flow defined from it -
133
222
  * for now, just how to launch a browser when a Flow owns its own
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type { Checkpoint, Instruction, Block, DefinedBlock } from "./types.js";
1
+ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./types.js";
2
2
  export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
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";
5
+ export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions } from "./engine.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
- export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
4
+ export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
package/dist/types.d.ts CHANGED
@@ -13,6 +13,16 @@ export type Checkpoint<Tag extends string> = {
13
13
  * @example resolve: () => checkpoint("LoggedIn")
14
14
  */
15
15
  export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag>;
16
+ /**
17
+ * The type every entry Block's `In` must be - `runGraph` always seeds a fresh
18
+ * run with `checkpoint("__start__")` as the fake initial input, so a Block
19
+ * meant to lead off a Flow needs exactly this type. Exported so no project
20
+ * has to know or hand-type the `"__start__"` literal itself - the same reason
21
+ * `checkpoint()` exists instead of every call site spelling `{ __state: tag }`
22
+ * by hand.
23
+ * @example export const LoginBlock: Block<Start, LoggedIn> = { ... }
24
+ */
25
+ export type Start = Checkpoint<"__start__">;
16
26
  /**
17
27
  * The four-phase pipeline every Block runs. Most Blocks only need `act` + a
18
28
  * one-line `resolve` - add `observe` only once a Block genuinely branches, add
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",
@@ -11,8 +11,12 @@
11
11
  "default": "./dist/index.js"
12
12
  }
13
13
  },
14
+ "bin": {
15
+ "waygraph": "bin/waygraph"
16
+ },
14
17
  "files": [
15
- "dist"
18
+ "dist",
19
+ "bin"
16
20
  ],
17
21
  "repository": {
18
22
  "type": "git",
@@ -26,6 +30,9 @@
26
30
  "peerDependencies": {
27
31
  "@playwright/test": "^1.40.0"
28
32
  },
33
+ "dependencies": {
34
+ "tsx": "^4.0.0"
35
+ },
29
36
  "devDependencies": {
30
37
  "@playwright/test": "^1.55.0",
31
38
  "@types/node": "^26.0.0",