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/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 = LAUNCHERS[browserName];
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.3.2",
3
+ "version": "0.5.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",