waygraph 0.3.2 → 0.5.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 +19 -0
- package/dist/cli.js +721 -79
- package/dist/engine.d.ts +138 -3
- package/dist/engine.js +312 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/types.d.ts +23 -0
- package/package.json +1 -1
package/dist/engine.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { BrowserContext, Page } from "@playwright/test";
|
|
2
|
-
import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
|
|
1
|
+
import type { BrowserContext, BrowserType, Page, Locator } from "@playwright/test";
|
|
2
|
+
import type { Block, Checkpoint, Instruction, DefinedBlock, ActionPage, WaygraphHighlight } from "./types.js";
|
|
3
3
|
import { MemPage } from "./mem-page.js";
|
|
4
|
+
import type { MemKey } from "./mem-page.js";
|
|
4
5
|
import type { Trait } from "./trait.js";
|
|
5
6
|
/**
|
|
6
7
|
* Checks `block.requires` against `mem` and throws, naming every missing key at
|
|
@@ -68,6 +69,24 @@ export interface RunGraphOptions {
|
|
|
68
69
|
* }
|
|
69
70
|
*/
|
|
70
71
|
export declare function spawnTab<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, page: Page, mem: MemPage, maxSteps?: number): Promise<TOut>;
|
|
72
|
+
/**
|
|
73
|
+
* Wraps one `act()` interaction with a caption and highlight, for a Block
|
|
74
|
+
* author who wants to explicitly narrate a specific step rather than rely
|
|
75
|
+
* on the generic per-fill/per-click narration `waygraph chain
|
|
76
|
+
* WAYGRAPH_STEP=1` already does automatically -
|
|
77
|
+
* `await narrate(submitButton, "confirms the order", () => submitButton.click())`
|
|
78
|
+
* reads at the call site instead of being inferred.
|
|
79
|
+
*
|
|
80
|
+
* A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
|
|
81
|
+
* for a `window.__wgPositionRing` hook the step-mode overlay installs on
|
|
82
|
+
* the page before doing anything, so a headless/CI/automated run (nobody
|
|
83
|
+
* watching, per-character delays and highlight pauses would just be wasted
|
|
84
|
+
* time) pays zero extra cost - not even the boundingBox() lookup runs. The
|
|
85
|
+
* `action` itself always runs regardless; only the narration is
|
|
86
|
+
* conditional.
|
|
87
|
+
* @example await narrate(submitButton, "confirms the order", () => submitButton.click())
|
|
88
|
+
*/
|
|
89
|
+
export declare function narrate<T>(locator: Locator, caption: string, action: () => Promise<T>): Promise<T>;
|
|
71
90
|
/** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
|
|
72
91
|
export declare const start: unique symbol;
|
|
73
92
|
export declare const end: unique symbol;
|
|
@@ -117,6 +136,14 @@ export interface BlockInfo {
|
|
|
117
136
|
name: string;
|
|
118
137
|
/** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
|
|
119
138
|
routes?: Readonly<Record<string, string | null>>;
|
|
139
|
+
/**
|
|
140
|
+
* True when {@link chainFlow} inserted a session-reset boundary
|
|
141
|
+
* immediately before this Block - i.e. this is the first Block of a
|
|
142
|
+
* sub-flow that was wrapped in {@link withSessionReset}, and it isn't
|
|
143
|
+
* the very first sub-flow in the chain. Absent (not just false) from
|
|
144
|
+
* `Flow.blocks()` on an ordinary, non-chained Flow.
|
|
145
|
+
*/
|
|
146
|
+
resetSessionBefore?: boolean;
|
|
120
147
|
/**
|
|
121
148
|
* The actual Block, re-runnable on its own via
|
|
122
149
|
* `engine.defineFlow([start, info.block, end])` - e.g. step-through
|
|
@@ -127,6 +154,22 @@ export interface BlockInfo {
|
|
|
127
154
|
block: Block<any, any>;
|
|
128
155
|
}
|
|
129
156
|
export interface Flow<Out extends Checkpoint<string>> {
|
|
157
|
+
/**
|
|
158
|
+
* True once this Flow has been wrapped in {@link withSessionReset} - a
|
|
159
|
+
* hint for `chainFlow`, not something `run()` itself checks: it marks
|
|
160
|
+
* this Flow as one that expects to start from a genuinely fresh,
|
|
161
|
+
* unauthenticated browser state (e.g. a login flow meant to be
|
|
162
|
+
* re-entered later in a longer chain). `chainFlow` clears cookies and
|
|
163
|
+
* storage right before this Flow's first Block runs, but only when this
|
|
164
|
+
* Flow isn't the very first thing in the chain (nothing to reset yet).
|
|
165
|
+
*/
|
|
166
|
+
readonly resetSession: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Set once this Flow has been wrapped in {@link withTitle} - a display
|
|
169
|
+
* hint for a step-through overlay/showcase (e.g. "Ticket #123"), not
|
|
170
|
+
* something `run()` itself uses.
|
|
171
|
+
*/
|
|
172
|
+
readonly title?: string;
|
|
130
173
|
/**
|
|
131
174
|
* This Flow's constituent Blocks, in order between `start`/`end`, as plain
|
|
132
175
|
* data - for introspection/visualization tools, without running anything.
|
|
@@ -170,6 +213,49 @@ export interface Flow<Out extends Checkpoint<string>> {
|
|
|
170
213
|
*/
|
|
171
214
|
modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
|
|
172
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
|
|
218
|
+
* browser state - e.g. a login flow meant to be re-entered later in a longer
|
|
219
|
+
* showcase. Non-destructive, same pattern as {@link withVerify}: returns a
|
|
220
|
+
* new Flow: the original is untouched, and the flag survives any further
|
|
221
|
+
* `withBlockVerify`/`modBlockVerify` patch applied to the result.
|
|
222
|
+
*
|
|
223
|
+
* By itself this does nothing - `Flow.run()` never reads `resetSession`.
|
|
224
|
+
* It's a hint {@link chainFlow} looks for: right before this Flow's first
|
|
225
|
+
* Block runs (skipped if it's the very first thing in the chain - nothing
|
|
226
|
+
* to reset yet), chainFlow clears cookies and storage so this Flow starts
|
|
227
|
+
* exactly as if a human had opened the app fresh, not wherever the
|
|
228
|
+
* previous Flow in the chain happened to leave the page.
|
|
229
|
+
* @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
|
|
230
|
+
*/
|
|
231
|
+
export declare function withSessionReset<Out extends Checkpoint<string>>(flow: Flow<Out>): Flow<Out>;
|
|
232
|
+
/**
|
|
233
|
+
* Names a Flow for display - a step-through overlay/showcase (waygraph's
|
|
234
|
+
* `chain --step`) shows this as the running banner while this Flow's own
|
|
235
|
+
* Blocks are executing, instead of one fixed title for the whole run.
|
|
236
|
+
* Non-destructive, same pattern as {@link withSessionReset}.
|
|
237
|
+
* @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
|
|
238
|
+
*/
|
|
239
|
+
export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
|
|
240
|
+
/**
|
|
241
|
+
* Runs several complete, independent Flows one after another, reusing the
|
|
242
|
+
* same page/context/mem in order - each remains its own validated
|
|
243
|
+
* start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
|
|
244
|
+
* untouched), this only sequences whole Flows. Deliberately NOT the same
|
|
245
|
+
* thing as {@link composeBlock}: composeBlock nests several Blocks into
|
|
246
|
+
* one Block that can drop back into another `defineFlow([...])` - a
|
|
247
|
+
* chainFlow result is a Flow, but not meant to be nested inside another
|
|
248
|
+
* Flow's own Block list. Recursive nesting stays composeBlock's job alone;
|
|
249
|
+
* chainFlow is a flat, one-level sequencing of complete showcases (e.g.
|
|
250
|
+
* "ticket #123 end to end, then ticket #456 end to end").
|
|
251
|
+
*
|
|
252
|
+
* A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
|
|
253
|
+
* cleared right before it runs (skipped for the very first flow in the
|
|
254
|
+
* chain - nothing to reset yet). The combined result is whatever the LAST
|
|
255
|
+
* flow resolved to.
|
|
256
|
+
* @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
|
|
257
|
+
*/
|
|
258
|
+
export declare function chainFlow(...flows: readonly Flow<any>[]): Flow<any>;
|
|
173
259
|
/**
|
|
174
260
|
* A Block guaranteed to have `withStepVerify`/`modStepVerify` attached - what
|
|
175
261
|
* {@link composeBlock} returns. Extends plain `Block`, so it drops straight into
|
|
@@ -228,6 +314,18 @@ export interface EngineConfig {
|
|
|
228
314
|
browserName?: "chromium" | "firefox" | "webkit";
|
|
229
315
|
/** Milliseconds Playwright pauses before each operation - for watching a non-headless run with your own eyes, not for real runs. Default `0`. */
|
|
230
316
|
slowMo?: number;
|
|
317
|
+
/**
|
|
318
|
+
* Overrides which `BrowserType` actually launches, per browser name -
|
|
319
|
+
* default is the real `@playwright/test` export for whichever one
|
|
320
|
+
* `browserName` picks. Accepts anything shaped like Playwright's own
|
|
321
|
+
* `chromium`/`firefox`/`webkit` (same `.launch()` signature) - a
|
|
322
|
+
* stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
|
|
323
|
+
* plus a stealth plugin) drops in here unmodified. Puppeteer is a
|
|
324
|
+
* different `Page`/`BrowserContext` shape entirely and isn't supported by
|
|
325
|
+
* this seam.
|
|
326
|
+
* @example new Engine({ browsers: { chromium: stealthChromium } })
|
|
327
|
+
*/
|
|
328
|
+
browsers?: Partial<Record<"chromium" | "firefox" | "webkit", BrowserType>>;
|
|
231
329
|
}
|
|
232
330
|
type S = Checkpoint<"__start__">;
|
|
233
331
|
/**
|
|
@@ -300,11 +398,48 @@ export declare function branch<In extends Checkpoint<string>, Out extends Checkp
|
|
|
300
398
|
export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
|
|
301
399
|
name: string;
|
|
302
400
|
description?: Block<In, Out>["description"];
|
|
303
|
-
instruction: Instruction<In, Out, any
|
|
401
|
+
instruction: Omit<Instruction<In, Out, any>, "act"> & {
|
|
402
|
+
act(page: ActionPage, input: In, mem: MemPage): Promise<void>;
|
|
403
|
+
};
|
|
304
404
|
next?: Block<In, Out>["next"];
|
|
305
405
|
requires?: Block<In, Out>["requires"];
|
|
306
406
|
routes?: Block<In, Out>["routes"];
|
|
307
407
|
}): DefinedBlock<In, Out>;
|
|
408
|
+
/**
|
|
409
|
+
* A Block whose only possible action is navigating to a URL - built via
|
|
410
|
+
* {@link defineNavBlock}, never with a hand-authored `act()`. Extends `Block`
|
|
411
|
+
* (same shape `DefinedBlock`/`ComposedBlock` already use), so it drops into
|
|
412
|
+
* `defineFlow([...])`, `connect()`, `composeBlock()` exactly like any other
|
|
413
|
+
* Block - no special-casing needed anywhere that only expects a `Block`.
|
|
414
|
+
* See `openspec/changes/nav-block-and-check/` for why this exists.
|
|
415
|
+
*/
|
|
416
|
+
export type NavBlock<Out extends Checkpoint<string>> = DefinedBlock<Checkpoint<string>, Out>;
|
|
417
|
+
/**
|
|
418
|
+
* Builds a {@link NavBlock}. `url` accepts a plain string or a function
|
|
419
|
+
* `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
|
|
420
|
+
* with a real id read from mem). `checkpoint` is the tag this NavBlock
|
|
421
|
+
* resolves to once navigation completes - a NavBlock never branches on
|
|
422
|
+
* observed evidence the way a regular Block can, so there's no `resolve`
|
|
423
|
+
* logic to author; it always just declares "arrived."
|
|
424
|
+
*
|
|
425
|
+
* The generated `act()` is always exactly `page.goto(url)` - never anything
|
|
426
|
+
* else, never author-supplied. Callers get a real `Page` here internally
|
|
427
|
+
* (the one legitimate place navigation belongs); `defineBlock`'s own
|
|
428
|
+
* `ActionPage` narrowing is what discourages navigation everywhere else,
|
|
429
|
+
* and never applies to this generated function since no author ever writes
|
|
430
|
+
* it by hand.
|
|
431
|
+
* @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
|
|
432
|
+
* @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
|
|
433
|
+
*/
|
|
434
|
+
export declare function defineNavBlock<Out extends Checkpoint<string>>(options: {
|
|
435
|
+
name: string;
|
|
436
|
+
description?: string;
|
|
437
|
+
checkpoint: Out["__state"];
|
|
438
|
+
url: string | ((mem: MemPage) => string);
|
|
439
|
+
requires?: readonly MemKey<any>[];
|
|
440
|
+
verify?: Trait[] | ((out: Out) => Trait[]);
|
|
441
|
+
highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
|
|
442
|
+
}): NavBlock<Out>;
|
|
308
443
|
/**
|
|
309
444
|
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
|
310
445
|
* same routing - but with `verify` replaced. For giving one flow stricter (or
|
package/dist/engine.js
CHANGED
|
@@ -3,6 +3,20 @@ import { connect, checkpoint } from "./types.js";
|
|
|
3
3
|
import { MemPage } from "./mem-page.js";
|
|
4
4
|
import { runVerify } from "./trait.js";
|
|
5
5
|
const LAUNCHERS = { chromium, firefox, webkit };
|
|
6
|
+
/**
|
|
7
|
+
* Resolves which `BrowserType` actually launches for a `mem`-only run (no
|
|
8
|
+
* caller-supplied context) - `config.browsers[browserName]` if given,
|
|
9
|
+
* otherwise the real `@playwright/test` export. Waygraph is deliberately
|
|
10
|
+
* "just an opinionated Playwright" - it doesn't own browser automation
|
|
11
|
+
* itself, so it shouldn't force every consumer onto the vanilla launcher.
|
|
12
|
+
* A stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
|
|
13
|
+
* plus a stealth plugin) is a drop-in replacement for `chromium`/`firefox`/
|
|
14
|
+
* `webkit` - same `.launch()` shape - so accepting one here costs nothing for
|
|
15
|
+
* a consumer who never sets it.
|
|
16
|
+
*/
|
|
17
|
+
function resolveLauncher(browserName, config) {
|
|
18
|
+
return config?.browsers?.[browserName] ?? LAUNCHERS[browserName];
|
|
19
|
+
}
|
|
6
20
|
/**
|
|
7
21
|
* Checks `block.requires` against `mem` and throws, naming every missing key at
|
|
8
22
|
* once, if any are unset. `connect()` unions each Block's `requires` into the
|
|
@@ -92,6 +106,93 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
|
|
|
92
106
|
export async function spawnTab(entry, page, mem, maxSteps = 5000) {
|
|
93
107
|
return runGraph(entry, undefined, page.context(), mem, maxSteps);
|
|
94
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Wraps one `act()` interaction with a caption and highlight, for a Block
|
|
111
|
+
* author who wants to explicitly narrate a specific step rather than rely
|
|
112
|
+
* on the generic per-fill/per-click narration `waygraph chain
|
|
113
|
+
* WAYGRAPH_STEP=1` already does automatically -
|
|
114
|
+
* `await narrate(submitButton, "confirms the order", () => submitButton.click())`
|
|
115
|
+
* reads at the call site instead of being inferred.
|
|
116
|
+
*
|
|
117
|
+
* A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
|
|
118
|
+
* for a `window.__wgPositionRing` hook the step-mode overlay installs on
|
|
119
|
+
* the page before doing anything, so a headless/CI/automated run (nobody
|
|
120
|
+
* watching, per-character delays and highlight pauses would just be wasted
|
|
121
|
+
* time) pays zero extra cost - not even the boundingBox() lookup runs. The
|
|
122
|
+
* `action` itself always runs regardless; only the narration is
|
|
123
|
+
* conditional.
|
|
124
|
+
* @example await narrate(submitButton, "confirms the order", () => submitButton.click())
|
|
125
|
+
*/
|
|
126
|
+
export async function narrate(locator, caption, action) {
|
|
127
|
+
const page = locator.page();
|
|
128
|
+
const active = await page
|
|
129
|
+
.evaluate(() => typeof globalThis.__wgPositionRing === "function")
|
|
130
|
+
.catch(() => false);
|
|
131
|
+
if (!active)
|
|
132
|
+
return action();
|
|
133
|
+
let owning = false;
|
|
134
|
+
try {
|
|
135
|
+
const box = await locator.boundingBox();
|
|
136
|
+
if (box) {
|
|
137
|
+
await page
|
|
138
|
+
.evaluate(({ box, caption }) => {
|
|
139
|
+
const w = globalThis;
|
|
140
|
+
if (w.__wgPositionRing)
|
|
141
|
+
w.__wgPositionRing(box, caption);
|
|
142
|
+
// The CLI's own automatic per-fill/per-click narration checks
|
|
143
|
+
// this before showing (and overwriting) its own guess - an
|
|
144
|
+
// explicitly authored caption should win, not get immediately
|
|
145
|
+
// replaced a moment later by the generic one.
|
|
146
|
+
w.__wgLastNarrate = Date.now();
|
|
147
|
+
// Also claim the ring itself: the auto-highlight click/fill
|
|
148
|
+
// patches each hide the ring right after THEIR own step
|
|
149
|
+
// finishes, which - if action() below does more than one thing
|
|
150
|
+
// (e.g. a click then a wait for a toast) - would wipe this
|
|
151
|
+
// caption after just the first sub-step instead of the whole
|
|
152
|
+
// narrated action. Their hideRing() calls no-op while this is
|
|
153
|
+
// true; narrate() below is the one that actually clears it.
|
|
154
|
+
w.__wgNarrateOwnsRing = true;
|
|
155
|
+
}, { box, caption })
|
|
156
|
+
.catch(() => { });
|
|
157
|
+
owning = true;
|
|
158
|
+
// The ring alone is pointless if action() fires on the very next
|
|
159
|
+
// tick - a click that navigates (e.g. a form submit) wipes the ring
|
|
160
|
+
// before a human can read the caption at all. Give it the same
|
|
161
|
+
// "pop for a few seconds" dwell as the CLI's own auto-highlight
|
|
162
|
+
// clicks before the real action runs.
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
164
|
+
// Re-stamp __wgLastNarrate right before handing off to action() - the
|
|
165
|
+
// CLI's auto-highlight patches only treat a narrate() as "just
|
|
166
|
+
// handled" within a short window (500ms), which the dwell above
|
|
167
|
+
// already burned through. Without this, action()'s own click/fill
|
|
168
|
+
// (same patched Locator prototype) would stomp this caption with its
|
|
169
|
+
// own generic one a moment after it finally became visible.
|
|
170
|
+
await page
|
|
171
|
+
.evaluate(() => {
|
|
172
|
+
globalThis.__wgLastNarrate = Date.now();
|
|
173
|
+
})
|
|
174
|
+
.catch(() => { });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// best-effort - the real action below still runs either way
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return await action();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (owning) {
|
|
185
|
+
await page
|
|
186
|
+
.evaluate(() => {
|
|
187
|
+
const w = globalThis;
|
|
188
|
+
w.__wgNarrateOwnsRing = false;
|
|
189
|
+
if (w.__wgHideRing)
|
|
190
|
+
w.__wgHideRing();
|
|
191
|
+
})
|
|
192
|
+
.catch(() => { });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
95
196
|
/** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
|
|
96
197
|
export const start = Symbol("waygraph.start");
|
|
97
198
|
export const end = Symbol("waygraph.end");
|
|
@@ -110,6 +211,11 @@ function findBlockIndex(middle, block) {
|
|
|
110
211
|
return index;
|
|
111
212
|
}
|
|
112
213
|
function buildFlow(middle, engineConfig) {
|
|
214
|
+
// resetSession/title start false/unset here - defineFlow itself carries
|
|
215
|
+
// no such options anymore. They're set by wrapping the result in
|
|
216
|
+
// withSessionReset()/withTitle() instead (non-destructive decorators,
|
|
217
|
+
// same pattern as withVerify), which also re-apply themselves after any
|
|
218
|
+
// further withBlockVerify/modBlockVerify patch so the flag survives.
|
|
113
219
|
const chain = middle.reduce((a, b) => connect(a, b));
|
|
114
220
|
// Declared separately (not as the object-literal method below) so the
|
|
115
221
|
// three-overload `Flow.run` signature can be checked against the actual
|
|
@@ -126,7 +232,7 @@ function buildFlow(middle, engineConfig) {
|
|
|
126
232
|
// run(context, mem) already has via an existing context.
|
|
127
233
|
preflight(mem, chain);
|
|
128
234
|
const browserName = config.browserName ?? "chromium";
|
|
129
|
-
const launch =
|
|
235
|
+
const launch = resolveLauncher(browserName, config);
|
|
130
236
|
// If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
|
|
131
237
|
// or Flatpak Chromium), launch that instead of Playwright's own bundled
|
|
132
238
|
// binary - only for chromium (the env var names a Chromium build, not a
|
|
@@ -180,6 +286,7 @@ function buildFlow(middle, engineConfig) {
|
|
|
180
286
|
return result;
|
|
181
287
|
};
|
|
182
288
|
return {
|
|
289
|
+
resetSession: false,
|
|
183
290
|
blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
|
|
184
291
|
run: run,
|
|
185
292
|
withBlockVerify(block, verify) {
|
|
@@ -196,6 +303,166 @@ function buildFlow(middle, engineConfig) {
|
|
|
196
303
|
},
|
|
197
304
|
};
|
|
198
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
|
|
308
|
+
* browser state - e.g. a login flow meant to be re-entered later in a longer
|
|
309
|
+
* showcase. Non-destructive, same pattern as {@link withVerify}: returns a
|
|
310
|
+
* new Flow: the original is untouched, and the flag survives any further
|
|
311
|
+
* `withBlockVerify`/`modBlockVerify` patch applied to the result.
|
|
312
|
+
*
|
|
313
|
+
* By itself this does nothing - `Flow.run()` never reads `resetSession`.
|
|
314
|
+
* It's a hint {@link chainFlow} looks for: right before this Flow's first
|
|
315
|
+
* Block runs (skipped if it's the very first thing in the chain - nothing
|
|
316
|
+
* to reset yet), chainFlow clears cookies and storage so this Flow starts
|
|
317
|
+
* exactly as if a human had opened the app fresh, not wherever the
|
|
318
|
+
* previous Flow in the chain happened to leave the page.
|
|
319
|
+
* @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
|
|
320
|
+
*/
|
|
321
|
+
export function withSessionReset(flow) {
|
|
322
|
+
return {
|
|
323
|
+
...flow,
|
|
324
|
+
resetSession: true,
|
|
325
|
+
withBlockVerify: (block, verify) => withSessionReset(flow.withBlockVerify(block, verify)),
|
|
326
|
+
modBlockVerify: (block, nameOrIndex, newCheck) => withSessionReset(flow.modBlockVerify(block, nameOrIndex, newCheck)),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Names a Flow for display - a step-through overlay/showcase (waygraph's
|
|
331
|
+
* `chain --step`) shows this as the running banner while this Flow's own
|
|
332
|
+
* Blocks are executing, instead of one fixed title for the whole run.
|
|
333
|
+
* Non-destructive, same pattern as {@link withSessionReset}.
|
|
334
|
+
* @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
|
|
335
|
+
*/
|
|
336
|
+
export function withTitle(flow, title) {
|
|
337
|
+
return {
|
|
338
|
+
...flow,
|
|
339
|
+
title,
|
|
340
|
+
withBlockVerify: (block, verify) => withTitle(flow.withBlockVerify(block, verify), title),
|
|
341
|
+
modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function locateInChain(flows, block) {
|
|
345
|
+
if (typeof block === "number") {
|
|
346
|
+
let remaining = block;
|
|
347
|
+
for (let fi = 0; fi < flows.length; fi++) {
|
|
348
|
+
const len = flows[fi].blocks().length;
|
|
349
|
+
if (remaining < len)
|
|
350
|
+
return { flowIndex: fi, localIndex: remaining };
|
|
351
|
+
remaining -= len;
|
|
352
|
+
}
|
|
353
|
+
throw new Error(`chainFlow: no Block at index ${block} across ${flows.length} chained flows`);
|
|
354
|
+
}
|
|
355
|
+
const blockName = typeof block === "string" ? block : block.name;
|
|
356
|
+
for (let fi = 0; fi < flows.length; fi++) {
|
|
357
|
+
const localIndex = flows[fi].blocks().findIndex((bi) => bi.name === blockName);
|
|
358
|
+
if (localIndex !== -1)
|
|
359
|
+
return { flowIndex: fi, localIndex };
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`chainFlow: no Block named "${blockName}" in any chained flow`);
|
|
362
|
+
}
|
|
363
|
+
async function clearSessionState(context, page) {
|
|
364
|
+
await context.clearCookies().catch(() => { });
|
|
365
|
+
await page
|
|
366
|
+
.evaluate(() => {
|
|
367
|
+
try {
|
|
368
|
+
localStorage.clear();
|
|
369
|
+
sessionStorage.clear();
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
/* storage blocked (e.g. about:blank) - nothing to clear anyway */
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
.catch(() => { });
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Runs several complete, independent Flows one after another, reusing the
|
|
379
|
+
* same page/context/mem in order - each remains its own validated
|
|
380
|
+
* start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
|
|
381
|
+
* untouched), this only sequences whole Flows. Deliberately NOT the same
|
|
382
|
+
* thing as {@link composeBlock}: composeBlock nests several Blocks into
|
|
383
|
+
* one Block that can drop back into another `defineFlow([...])` - a
|
|
384
|
+
* chainFlow result is a Flow, but not meant to be nested inside another
|
|
385
|
+
* Flow's own Block list. Recursive nesting stays composeBlock's job alone;
|
|
386
|
+
* chainFlow is a flat, one-level sequencing of complete showcases (e.g.
|
|
387
|
+
* "ticket #123 end to end, then ticket #456 end to end").
|
|
388
|
+
*
|
|
389
|
+
* A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
|
|
390
|
+
* cleared right before it runs (skipped for the very first flow in the
|
|
391
|
+
* chain - nothing to reset yet). The combined result is whatever the LAST
|
|
392
|
+
* flow resolved to.
|
|
393
|
+
* @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
|
|
394
|
+
*/
|
|
395
|
+
export function chainFlow(...flows) {
|
|
396
|
+
if (flows.length === 0) {
|
|
397
|
+
throw new Error("chainFlow: give at least one Flow");
|
|
398
|
+
}
|
|
399
|
+
const run = async function run(contextOrMem, memOrConfig, options) {
|
|
400
|
+
if (contextOrMem instanceof MemPage) {
|
|
401
|
+
const mem = contextOrMem;
|
|
402
|
+
const config = memOrConfig;
|
|
403
|
+
// One browser for the WHOLE chain - each sub-flow's own run(mem,
|
|
404
|
+
// config) would otherwise each launch a separate browser, defeating
|
|
405
|
+
// the point of sequencing them (a fresh browser is itself a full
|
|
406
|
+
// session reset, silently making withSessionReset a no-op).
|
|
407
|
+
const browserName = config?.browserName ?? "chromium";
|
|
408
|
+
const launch = resolveLauncher(browserName, config);
|
|
409
|
+
const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
|
|
410
|
+
const browser = await launch.launch({
|
|
411
|
+
headless: config?.headless ?? true,
|
|
412
|
+
...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
|
|
413
|
+
...(executablePath ? { executablePath } : {}),
|
|
414
|
+
});
|
|
415
|
+
try {
|
|
416
|
+
const context = await browser.newContext();
|
|
417
|
+
return await run(context, mem, { closeOnFinish: true });
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
await browser.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const context = contextOrMem;
|
|
424
|
+
const mem = memOrConfig;
|
|
425
|
+
const gotOwnPage = options?.page === undefined;
|
|
426
|
+
const page = options?.page ?? (await context.newPage());
|
|
427
|
+
const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
|
|
428
|
+
let result;
|
|
429
|
+
for (let i = 0; i < flows.length; i++) {
|
|
430
|
+
const flow = flows[i];
|
|
431
|
+
if (i > 0 && flow.resetSession) {
|
|
432
|
+
await clearSessionState(context, page);
|
|
433
|
+
}
|
|
434
|
+
const outcome = (await flow.run(context, mem, { page, closeOnFinish: false }));
|
|
435
|
+
result = outcome.result;
|
|
436
|
+
}
|
|
437
|
+
if (closeOnFinish) {
|
|
438
|
+
await page.close();
|
|
439
|
+
}
|
|
440
|
+
if (options?.closeOnFinish === false) {
|
|
441
|
+
return { result, page };
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
resetSession: false,
|
|
447
|
+
blocks: () => flows.flatMap((flow, idx) => flow.blocks().map((bi, i) => ({
|
|
448
|
+
...bi,
|
|
449
|
+
...(i === 0 && idx > 0 && flow.resetSession ? { resetSessionBefore: true } : {}),
|
|
450
|
+
}))),
|
|
451
|
+
run: run,
|
|
452
|
+
withBlockVerify(block, verify) {
|
|
453
|
+
const { flowIndex, localIndex } = locateInChain(flows, block);
|
|
454
|
+
const patched = [...flows];
|
|
455
|
+
patched[flowIndex] = patched[flowIndex].withBlockVerify(localIndex, verify);
|
|
456
|
+
return chainFlow(...patched);
|
|
457
|
+
},
|
|
458
|
+
modBlockVerify(block, nameOrIndex, newCheck) {
|
|
459
|
+
const { flowIndex, localIndex } = locateInChain(flows, block);
|
|
460
|
+
const patched = [...flows];
|
|
461
|
+
patched[flowIndex] = patched[flowIndex].modBlockVerify(localIndex, nameOrIndex, newCheck);
|
|
462
|
+
return chainFlow(...patched);
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
199
466
|
export function composeBlock(name, steps) {
|
|
200
467
|
if (steps.length === 0) {
|
|
201
468
|
throw new Error(`composeBlock: "${name}" needs at least one step`);
|
|
@@ -315,6 +582,50 @@ export function defineBlock(base) {
|
|
|
315
582
|
modVerifyAll: (patches) => modVerifyAll(plain, patches),
|
|
316
583
|
};
|
|
317
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Builds a {@link NavBlock}. `url` accepts a plain string or a function
|
|
587
|
+
* `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
|
|
588
|
+
* with a real id read from mem). `checkpoint` is the tag this NavBlock
|
|
589
|
+
* resolves to once navigation completes - a NavBlock never branches on
|
|
590
|
+
* observed evidence the way a regular Block can, so there's no `resolve`
|
|
591
|
+
* logic to author; it always just declares "arrived."
|
|
592
|
+
*
|
|
593
|
+
* The generated `act()` is always exactly `page.goto(url)` - never anything
|
|
594
|
+
* else, never author-supplied. Callers get a real `Page` here internally
|
|
595
|
+
* (the one legitimate place navigation belongs); `defineBlock`'s own
|
|
596
|
+
* `ActionPage` narrowing is what discourages navigation everywhere else,
|
|
597
|
+
* and never applies to this generated function since no author ever writes
|
|
598
|
+
* it by hand.
|
|
599
|
+
* @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
|
|
600
|
+
* @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
|
|
601
|
+
*/
|
|
602
|
+
export function defineNavBlock(options) {
|
|
603
|
+
const built = defineBlock({
|
|
604
|
+
name: options.name,
|
|
605
|
+
...(options.description ? { description: options.description } : {}),
|
|
606
|
+
...(options.requires ? { requires: options.requires } : {}),
|
|
607
|
+
instruction: {
|
|
608
|
+
async act(page, _input, mem) {
|
|
609
|
+
const url = typeof options.url === "function" ? options.url(mem) : options.url;
|
|
610
|
+
await page.goto(url);
|
|
611
|
+
},
|
|
612
|
+
resolve: () => checkpoint(options.checkpoint),
|
|
613
|
+
...(options.verify ? { verify: options.verify } : {}),
|
|
614
|
+
...(options.highlights ? { highlights: options.highlights } : {}),
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
// Non-enumerable so it never shows up in Object.keys/JSON/autocomplete -
|
|
618
|
+
// purely a runtime marker `waygraph check` (or anything else walking a
|
|
619
|
+
// project's Blocks) can test for to tell a NavBlock apart from a regular
|
|
620
|
+
// one, since the TypeScript type alone (NavBlock = DefinedBlock) doesn't
|
|
621
|
+
// survive to runtime.
|
|
622
|
+
Object.defineProperty(built, "__waygraphKind", {
|
|
623
|
+
value: "nav",
|
|
624
|
+
enumerable: false,
|
|
625
|
+
configurable: false,
|
|
626
|
+
});
|
|
627
|
+
return built;
|
|
628
|
+
}
|
|
318
629
|
/**
|
|
319
630
|
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
|
320
631
|
* same routing - but with `verify` replaced. For giving one flow stricter (or
|
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./type
|
|
|
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, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
|
|
6
|
-
export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } from "./engine.js";
|
|
5
|
+
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
|
|
6
|
+
export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock } from "./engine.js";
|
|
7
|
+
export type { ActionPage } from "./types.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, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
|
|
4
|
+
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,29 @@ export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag
|
|
|
23
23
|
* @example export const LoginBlock: Block<Start, LoggedIn> = { ... }
|
|
24
24
|
*/
|
|
25
25
|
export type Start = Checkpoint<"__start__">;
|
|
26
|
+
/**
|
|
27
|
+
* The `page` a regular Block's `act()` is typed against via `defineBlock` -
|
|
28
|
+
* structurally identical to Playwright's own `Page` (the same real object is
|
|
29
|
+
* passed at runtime; this is a type-only narrowing), except `goto`, `reload`,
|
|
30
|
+
* `goBack`, and `goForward` are re-declared under `@deprecated`. Nothing is
|
|
31
|
+
* blocked - every method is still fully present and callable, and this never
|
|
32
|
+
* causes a build to fail - but a TypeScript-aware editor shows the call
|
|
33
|
+
* struck through with a hover warning the instant it's typed, pointing at
|
|
34
|
+
* `defineNavBlock` instead. `NavBlock`'s own generated `act()` (the one place
|
|
35
|
+
* navigation actually belongs) uses the real `Page`, not this type - that
|
|
36
|
+
* distinction lives entirely inside `defineNavBlock` and never reaches a
|
|
37
|
+
* NavBlock's own author, who never writes its `act()` by hand.
|
|
38
|
+
*/
|
|
39
|
+
export type ActionPage = Omit<Page, "goto" | "reload" | "goBack" | "goForward"> & {
|
|
40
|
+
/** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
|
|
41
|
+
goto: Page["goto"];
|
|
42
|
+
/** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
|
|
43
|
+
reload: Page["reload"];
|
|
44
|
+
/** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
|
|
45
|
+
goBack: Page["goBack"];
|
|
46
|
+
/** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
|
|
47
|
+
goForward: Page["goForward"];
|
|
48
|
+
};
|
|
26
49
|
/**
|
|
27
50
|
* The four-phase pipeline every Block runs. Most Blocks only need `act` + a
|
|
28
51
|
* one-line `resolve` - add `observe` only once a Block genuinely branches, add
|