waygraph 0.3.2 → 0.4.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,4 +1,4 @@
1
- import type { BrowserContext, Page } from "@playwright/test";
1
+ import type { BrowserContext, Page, Locator } 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";
@@ -68,6 +68,24 @@ export interface RunGraphOptions {
68
68
  * }
69
69
  */
70
70
  export declare function spawnTab<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, page: Page, mem: MemPage, maxSteps?: number): Promise<TOut>;
71
+ /**
72
+ * Wraps one `act()` interaction with a caption and highlight, for a Block
73
+ * author who wants to explicitly narrate a specific step rather than rely
74
+ * on the generic per-fill/per-click narration `waygraph chain
75
+ * WAYGRAPH_STEP=1` already does automatically -
76
+ * `await narrate(submitButton, "confirms the order", () => submitButton.click())`
77
+ * reads at the call site instead of being inferred.
78
+ *
79
+ * A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
80
+ * for a `window.__wgPositionRing` hook the step-mode overlay installs on
81
+ * the page before doing anything, so a headless/CI/automated run (nobody
82
+ * watching, per-character delays and highlight pauses would just be wasted
83
+ * time) pays zero extra cost - not even the boundingBox() lookup runs. The
84
+ * `action` itself always runs regardless; only the narration is
85
+ * conditional.
86
+ * @example await narrate(submitButton, "confirms the order", () => submitButton.click())
87
+ */
88
+ export declare function narrate<T>(locator: Locator, caption: string, action: () => Promise<T>): Promise<T>;
71
89
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
72
90
  export declare const start: unique symbol;
73
91
  export declare const end: unique symbol;
@@ -117,6 +135,14 @@ export interface BlockInfo {
117
135
  name: string;
118
136
  /** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
119
137
  routes?: Readonly<Record<string, string | null>>;
138
+ /**
139
+ * True when {@link chainFlow} inserted a session-reset boundary
140
+ * immediately before this Block - i.e. this is the first Block of a
141
+ * sub-flow that was wrapped in {@link withSessionReset}, and it isn't
142
+ * the very first sub-flow in the chain. Absent (not just false) from
143
+ * `Flow.blocks()` on an ordinary, non-chained Flow.
144
+ */
145
+ resetSessionBefore?: boolean;
120
146
  /**
121
147
  * The actual Block, re-runnable on its own via
122
148
  * `engine.defineFlow([start, info.block, end])` - e.g. step-through
@@ -127,6 +153,22 @@ export interface BlockInfo {
127
153
  block: Block<any, any>;
128
154
  }
129
155
  export interface Flow<Out extends Checkpoint<string>> {
156
+ /**
157
+ * True once this Flow has been wrapped in {@link withSessionReset} - a
158
+ * hint for `chainFlow`, not something `run()` itself checks: it marks
159
+ * this Flow as one that expects to start from a genuinely fresh,
160
+ * unauthenticated browser state (e.g. a login flow meant to be
161
+ * re-entered later in a longer chain). `chainFlow` clears cookies and
162
+ * storage right before this Flow's first Block runs, but only when this
163
+ * Flow isn't the very first thing in the chain (nothing to reset yet).
164
+ */
165
+ readonly resetSession: boolean;
166
+ /**
167
+ * Set once this Flow has been wrapped in {@link withTitle} - a display
168
+ * hint for a step-through overlay/showcase (e.g. "Ticket #123"), not
169
+ * something `run()` itself uses.
170
+ */
171
+ readonly title?: string;
130
172
  /**
131
173
  * This Flow's constituent Blocks, in order between `start`/`end`, as plain
132
174
  * data - for introspection/visualization tools, without running anything.
@@ -170,6 +212,49 @@ export interface Flow<Out extends Checkpoint<string>> {
170
212
  */
171
213
  modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
172
214
  }
215
+ /**
216
+ * Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
217
+ * browser state - e.g. a login flow meant to be re-entered later in a longer
218
+ * showcase. Non-destructive, same pattern as {@link withVerify}: returns a
219
+ * new Flow: the original is untouched, and the flag survives any further
220
+ * `withBlockVerify`/`modBlockVerify` patch applied to the result.
221
+ *
222
+ * By itself this does nothing - `Flow.run()` never reads `resetSession`.
223
+ * It's a hint {@link chainFlow} looks for: right before this Flow's first
224
+ * Block runs (skipped if it's the very first thing in the chain - nothing
225
+ * to reset yet), chainFlow clears cookies and storage so this Flow starts
226
+ * exactly as if a human had opened the app fresh, not wherever the
227
+ * previous Flow in the chain happened to leave the page.
228
+ * @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
229
+ */
230
+ export declare function withSessionReset<Out extends Checkpoint<string>>(flow: Flow<Out>): Flow<Out>;
231
+ /**
232
+ * Names a Flow for display - a step-through overlay/showcase (waygraph's
233
+ * `chain --step`) shows this as the running banner while this Flow's own
234
+ * Blocks are executing, instead of one fixed title for the whole run.
235
+ * Non-destructive, same pattern as {@link withSessionReset}.
236
+ * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
237
+ */
238
+ export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
239
+ /**
240
+ * Runs several complete, independent Flows one after another, reusing the
241
+ * same page/context/mem in order - each remains its own validated
242
+ * start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
243
+ * untouched), this only sequences whole Flows. Deliberately NOT the same
244
+ * thing as {@link composeBlock}: composeBlock nests several Blocks into
245
+ * one Block that can drop back into another `defineFlow([...])` - a
246
+ * chainFlow result is a Flow, but not meant to be nested inside another
247
+ * Flow's own Block list. Recursive nesting stays composeBlock's job alone;
248
+ * chainFlow is a flat, one-level sequencing of complete showcases (e.g.
249
+ * "ticket #123 end to end, then ticket #456 end to end").
250
+ *
251
+ * A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
252
+ * cleared right before it runs (skipped for the very first flow in the
253
+ * chain - nothing to reset yet). The combined result is whatever the LAST
254
+ * flow resolved to.
255
+ * @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
256
+ */
257
+ export declare function chainFlow(...flows: readonly Flow<any>[]): Flow<any>;
173
258
  /**
174
259
  * A Block guaranteed to have `withStepVerify`/`modStepVerify` attached - what
175
260
  * {@link composeBlock} returns. Extends plain `Block`, so it drops straight into
package/dist/engine.js CHANGED
@@ -92,6 +92,93 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
92
92
  export async function spawnTab(entry, page, mem, maxSteps = 5000) {
93
93
  return runGraph(entry, undefined, page.context(), mem, maxSteps);
94
94
  }
95
+ /**
96
+ * Wraps one `act()` interaction with a caption and highlight, for a Block
97
+ * author who wants to explicitly narrate a specific step rather than rely
98
+ * on the generic per-fill/per-click narration `waygraph chain
99
+ * WAYGRAPH_STEP=1` already does automatically -
100
+ * `await narrate(submitButton, "confirms the order", () => submitButton.click())`
101
+ * reads at the call site instead of being inferred.
102
+ *
103
+ * A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
104
+ * for a `window.__wgPositionRing` hook the step-mode overlay installs on
105
+ * the page before doing anything, so a headless/CI/automated run (nobody
106
+ * watching, per-character delays and highlight pauses would just be wasted
107
+ * time) pays zero extra cost - not even the boundingBox() lookup runs. The
108
+ * `action` itself always runs regardless; only the narration is
109
+ * conditional.
110
+ * @example await narrate(submitButton, "confirms the order", () => submitButton.click())
111
+ */
112
+ export async function narrate(locator, caption, action) {
113
+ const page = locator.page();
114
+ const active = await page
115
+ .evaluate(() => typeof globalThis.__wgPositionRing === "function")
116
+ .catch(() => false);
117
+ if (!active)
118
+ return action();
119
+ let owning = false;
120
+ try {
121
+ const box = await locator.boundingBox();
122
+ if (box) {
123
+ await page
124
+ .evaluate(({ box, caption }) => {
125
+ const w = globalThis;
126
+ if (w.__wgPositionRing)
127
+ w.__wgPositionRing(box, caption);
128
+ // The CLI's own automatic per-fill/per-click narration checks
129
+ // this before showing (and overwriting) its own guess - an
130
+ // explicitly authored caption should win, not get immediately
131
+ // replaced a moment later by the generic one.
132
+ w.__wgLastNarrate = Date.now();
133
+ // Also claim the ring itself: the auto-highlight click/fill
134
+ // patches each hide the ring right after THEIR own step
135
+ // finishes, which - if action() below does more than one thing
136
+ // (e.g. a click then a wait for a toast) - would wipe this
137
+ // caption after just the first sub-step instead of the whole
138
+ // narrated action. Their hideRing() calls no-op while this is
139
+ // true; narrate() below is the one that actually clears it.
140
+ w.__wgNarrateOwnsRing = true;
141
+ }, { box, caption })
142
+ .catch(() => { });
143
+ owning = true;
144
+ // The ring alone is pointless if action() fires on the very next
145
+ // tick - a click that navigates (e.g. a form submit) wipes the ring
146
+ // before a human can read the caption at all. Give it the same
147
+ // "pop for a few seconds" dwell as the CLI's own auto-highlight
148
+ // clicks before the real action runs.
149
+ await new Promise((resolve) => setTimeout(resolve, 650));
150
+ // Re-stamp __wgLastNarrate right before handing off to action() - the
151
+ // CLI's auto-highlight patches only treat a narrate() as "just
152
+ // handled" within a short window (500ms), which the dwell above
153
+ // already burned through. Without this, action()'s own click/fill
154
+ // (same patched Locator prototype) would stomp this caption with its
155
+ // own generic one a moment after it finally became visible.
156
+ await page
157
+ .evaluate(() => {
158
+ globalThis.__wgLastNarrate = Date.now();
159
+ })
160
+ .catch(() => { });
161
+ }
162
+ }
163
+ catch {
164
+ // best-effort - the real action below still runs either way
165
+ }
166
+ try {
167
+ return await action();
168
+ }
169
+ finally {
170
+ if (owning) {
171
+ await page
172
+ .evaluate(() => {
173
+ const w = globalThis;
174
+ w.__wgNarrateOwnsRing = false;
175
+ if (w.__wgHideRing)
176
+ w.__wgHideRing();
177
+ })
178
+ .catch(() => { });
179
+ }
180
+ }
181
+ }
95
182
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
96
183
  export const start = Symbol("waygraph.start");
97
184
  export const end = Symbol("waygraph.end");
@@ -110,6 +197,11 @@ function findBlockIndex(middle, block) {
110
197
  return index;
111
198
  }
112
199
  function buildFlow(middle, engineConfig) {
200
+ // resetSession/title start false/unset here - defineFlow itself carries
201
+ // no such options anymore. They're set by wrapping the result in
202
+ // withSessionReset()/withTitle() instead (non-destructive decorators,
203
+ // same pattern as withVerify), which also re-apply themselves after any
204
+ // further withBlockVerify/modBlockVerify patch so the flag survives.
113
205
  const chain = middle.reduce((a, b) => connect(a, b));
114
206
  // Declared separately (not as the object-literal method below) so the
115
207
  // three-overload `Flow.run` signature can be checked against the actual
@@ -180,6 +272,7 @@ function buildFlow(middle, engineConfig) {
180
272
  return result;
181
273
  };
182
274
  return {
275
+ resetSession: false,
183
276
  blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
184
277
  run: run,
185
278
  withBlockVerify(block, verify) {
@@ -196,6 +289,166 @@ function buildFlow(middle, engineConfig) {
196
289
  },
197
290
  };
198
291
  }
292
+ /**
293
+ * Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
294
+ * browser state - e.g. a login flow meant to be re-entered later in a longer
295
+ * showcase. Non-destructive, same pattern as {@link withVerify}: returns a
296
+ * new Flow: the original is untouched, and the flag survives any further
297
+ * `withBlockVerify`/`modBlockVerify` patch applied to the result.
298
+ *
299
+ * By itself this does nothing - `Flow.run()` never reads `resetSession`.
300
+ * It's a hint {@link chainFlow} looks for: right before this Flow's first
301
+ * Block runs (skipped if it's the very first thing in the chain - nothing
302
+ * to reset yet), chainFlow clears cookies and storage so this Flow starts
303
+ * exactly as if a human had opened the app fresh, not wherever the
304
+ * previous Flow in the chain happened to leave the page.
305
+ * @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
306
+ */
307
+ export function withSessionReset(flow) {
308
+ return {
309
+ ...flow,
310
+ resetSession: true,
311
+ withBlockVerify: (block, verify) => withSessionReset(flow.withBlockVerify(block, verify)),
312
+ modBlockVerify: (block, nameOrIndex, newCheck) => withSessionReset(flow.modBlockVerify(block, nameOrIndex, newCheck)),
313
+ };
314
+ }
315
+ /**
316
+ * Names a Flow for display - a step-through overlay/showcase (waygraph's
317
+ * `chain --step`) shows this as the running banner while this Flow's own
318
+ * Blocks are executing, instead of one fixed title for the whole run.
319
+ * Non-destructive, same pattern as {@link withSessionReset}.
320
+ * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
321
+ */
322
+ export function withTitle(flow, title) {
323
+ return {
324
+ ...flow,
325
+ title,
326
+ withBlockVerify: (block, verify) => withTitle(flow.withBlockVerify(block, verify), title),
327
+ modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
328
+ };
329
+ }
330
+ function locateInChain(flows, block) {
331
+ if (typeof block === "number") {
332
+ let remaining = block;
333
+ for (let fi = 0; fi < flows.length; fi++) {
334
+ const len = flows[fi].blocks().length;
335
+ if (remaining < len)
336
+ return { flowIndex: fi, localIndex: remaining };
337
+ remaining -= len;
338
+ }
339
+ throw new Error(`chainFlow: no Block at index ${block} across ${flows.length} chained flows`);
340
+ }
341
+ const blockName = typeof block === "string" ? block : block.name;
342
+ for (let fi = 0; fi < flows.length; fi++) {
343
+ const localIndex = flows[fi].blocks().findIndex((bi) => bi.name === blockName);
344
+ if (localIndex !== -1)
345
+ return { flowIndex: fi, localIndex };
346
+ }
347
+ throw new Error(`chainFlow: no Block named "${blockName}" in any chained flow`);
348
+ }
349
+ async function clearSessionState(context, page) {
350
+ await context.clearCookies().catch(() => { });
351
+ await page
352
+ .evaluate(() => {
353
+ try {
354
+ localStorage.clear();
355
+ sessionStorage.clear();
356
+ }
357
+ catch {
358
+ /* storage blocked (e.g. about:blank) - nothing to clear anyway */
359
+ }
360
+ })
361
+ .catch(() => { });
362
+ }
363
+ /**
364
+ * Runs several complete, independent Flows one after another, reusing the
365
+ * same page/context/mem in order - each remains its own validated
366
+ * start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
367
+ * untouched), this only sequences whole Flows. Deliberately NOT the same
368
+ * thing as {@link composeBlock}: composeBlock nests several Blocks into
369
+ * one Block that can drop back into another `defineFlow([...])` - a
370
+ * chainFlow result is a Flow, but not meant to be nested inside another
371
+ * Flow's own Block list. Recursive nesting stays composeBlock's job alone;
372
+ * chainFlow is a flat, one-level sequencing of complete showcases (e.g.
373
+ * "ticket #123 end to end, then ticket #456 end to end").
374
+ *
375
+ * A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
376
+ * cleared right before it runs (skipped for the very first flow in the
377
+ * chain - nothing to reset yet). The combined result is whatever the LAST
378
+ * flow resolved to.
379
+ * @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
380
+ */
381
+ export function chainFlow(...flows) {
382
+ if (flows.length === 0) {
383
+ throw new Error("chainFlow: give at least one Flow");
384
+ }
385
+ const run = async function run(contextOrMem, memOrConfig, options) {
386
+ if (contextOrMem instanceof MemPage) {
387
+ const mem = contextOrMem;
388
+ const config = memOrConfig;
389
+ // One browser for the WHOLE chain - each sub-flow's own run(mem,
390
+ // config) would otherwise each launch a separate browser, defeating
391
+ // the point of sequencing them (a fresh browser is itself a full
392
+ // session reset, silently making withSessionReset a no-op).
393
+ const browserName = config?.browserName ?? "chromium";
394
+ const launch = LAUNCHERS[browserName];
395
+ const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
396
+ const browser = await launch.launch({
397
+ headless: config?.headless ?? true,
398
+ ...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
399
+ ...(executablePath ? { executablePath } : {}),
400
+ });
401
+ try {
402
+ const context = await browser.newContext();
403
+ return await run(context, mem, { closeOnFinish: true });
404
+ }
405
+ finally {
406
+ await browser.close();
407
+ }
408
+ }
409
+ const context = contextOrMem;
410
+ const mem = memOrConfig;
411
+ const gotOwnPage = options?.page === undefined;
412
+ const page = options?.page ?? (await context.newPage());
413
+ const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
414
+ let result;
415
+ for (let i = 0; i < flows.length; i++) {
416
+ const flow = flows[i];
417
+ if (i > 0 && flow.resetSession) {
418
+ await clearSessionState(context, page);
419
+ }
420
+ const outcome = (await flow.run(context, mem, { page, closeOnFinish: false }));
421
+ result = outcome.result;
422
+ }
423
+ if (closeOnFinish) {
424
+ await page.close();
425
+ }
426
+ if (options?.closeOnFinish === false) {
427
+ return { result, page };
428
+ }
429
+ return result;
430
+ };
431
+ return {
432
+ resetSession: false,
433
+ blocks: () => flows.flatMap((flow, idx) => flow.blocks().map((bi, i) => ({
434
+ ...bi,
435
+ ...(i === 0 && idx > 0 && flow.resetSession ? { resetSessionBefore: true } : {}),
436
+ }))),
437
+ run: run,
438
+ withBlockVerify(block, verify) {
439
+ const { flowIndex, localIndex } = locateInChain(flows, block);
440
+ const patched = [...flows];
441
+ patched[flowIndex] = patched[flowIndex].withBlockVerify(localIndex, verify);
442
+ return chainFlow(...patched);
443
+ },
444
+ modBlockVerify(block, nameOrIndex, newCheck) {
445
+ const { flowIndex, localIndex } = locateInChain(flows, block);
446
+ const patched = [...flows];
447
+ patched[flowIndex] = patched[flowIndex].modBlockVerify(localIndex, nameOrIndex, newCheck);
448
+ return chainFlow(...patched);
449
+ },
450
+ };
451
+ }
199
452
  export function composeBlock(name, steps) {
200
453
  if (steps.length === 0) {
201
454
  throw new Error(`composeBlock: "${name}" needs at least one step`);
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@ 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";
5
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
6
6
  export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } 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, 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, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",