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 +120 -17
- package/bin/waygraph +13 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +534 -0
- package/dist/engine.d.ts +102 -2
- package/dist/engine.js +116 -27
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/types.d.ts +10 -0
- package/package.json +9 -2
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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",
|