waygraph 0.15.24 → 0.15.25

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.js CHANGED
@@ -1,1550 +1,12 @@
1
- import { chromium, firefox, webkit } from "@playwright/test";
2
- import { connect, checkpoint } from "./types.js";
3
- import { MemPage } from "./mem-page.js";
4
- import { runVerify, runPrecondition } from "./trait.js";
5
- import { normalizeHighlightTone, normalizeHighlightSize, normalizeHighlightWeight, } from "./highlights.js";
6
- const LAUNCHERS = { chromium, firefox, webkit };
7
- /**
8
- * Resolves which `BrowserType` actually launches for a `mem`-only run (no
9
- * caller-supplied context) - `config.browsers[browserName]` if given,
10
- * otherwise the real `@playwright/test` export. Waygraph is deliberately
11
- * "just an opinionated Playwright" - it doesn't own browser automation
12
- * itself, so it shouldn't force every consumer onto the vanilla launcher.
13
- * A stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
14
- * plus a stealth plugin) is a drop-in replacement for `chromium`/`firefox`/
15
- * `webkit` - same `.launch()` shape - so accepting one here costs nothing for
16
- * a consumer who never sets it.
17
- */
18
- function resolveLauncher(browserName, config) {
19
- return config?.browsers?.[browserName] ?? LAUNCHERS[browserName];
20
- }
21
- /**
22
- * Checks `block.requires` against `mem` and throws, naming every missing key at
23
- * once, if any are unset. `connect()` unions each Block's `requires` into the
24
- * composed Block, so calling this on a `defineFlow`-built Flow's entry - which
25
- * `runGraph` does automatically, before opening a tab - covers every Block in
26
- * that flow. It does not see Blocks only reachable through `branch()` routing,
27
- * since those depend on a runtime decision that hasn't happened yet.
28
- */
29
- export function preflight(mem, block) {
30
- const missing = (block.requires ?? []).filter((k) => !mem.has(k));
31
- if (missing.length > 0) {
32
- throw new Error(`preflight: MemPage is missing required key(s) before "${block.name}" can run: ${missing
33
- .map((k) => `"${k.name}"`)
34
- .join(", ")}`);
35
- }
36
- }
37
- /**
38
- * @example
39
- * const AppShellLayout = defineLayout({
40
- * name: "app-shell",
41
- * appliesTo: ["AppHome", "Chats", "Notifications", "Settings"],
42
- * verify: [Trait.visible(SidebarSel.root)],
43
- * });
44
- * const engine = new Engine({ layouts: [AppShellLayout] });
45
- * // Now every Block resolving to one of those four Checkpoints, in any
46
- * // Flow this engine defines, also gets AppShellLayout's own verify run
47
- * // automatically - a page that silently drops the sidebar fails loud,
48
- * // even though no individual Block's own `verify` mentions it.
49
- */
50
- export function defineLayout(options) {
51
- const appliesTo = typeof options.appliesTo === "function"
52
- ? options.appliesTo
53
- : (tag) => options.appliesTo.includes(tag);
54
- return {
55
- name: options.name,
56
- ...(options.description ? { description: options.description } : {}),
57
- appliesTo,
58
- verify: options.verify,
59
- };
60
- }
61
- /**
62
- * Runs every registered layout whose `appliesTo(tag)` matches the just-
63
- * resolved Checkpoint, in registration order, right after the Block's own
64
- * `verify` already ran. A layout's own Trait failing throws the same way
65
- * any other `runVerify` failure does, naming the layout so it's clearly
66
- * distinguishable from the Block's own verify in the error message.
67
- */
68
- async function runLayouts(layouts, tag, out, page, mem, blockName) {
69
- if (!layouts)
70
- return;
71
- for (const layout of layouts) {
72
- if (!layout.appliesTo(tag))
73
- continue;
74
- await runVerify(layout.verify, out, page, mem, `${blockName} (layout: "${layout.name}")`);
75
- }
76
- }
77
- /**
78
- * Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
79
- * The engine owns the tab's lifecycle, not any individual Block: the tab is
80
- * created before the first `act` runs and closed in `finally`, so it closes even
81
- * if a step throws.
82
- *
83
- * After each Block resolves, its `next` (if any, from `branch()`) is followed to
84
- * the following Block, in the same tab - a Block with no `next` runs exactly once,
85
- * unchanged from before `branch()` existed. `next` returning the same Block is a
86
- * self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
87
- * hanging.
88
- *
89
- * `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
90
- * one of the tags the caller actually expects, or the run fails loud instead of
91
- * silently returning an unexpected result. Omit it when there is only ever one
92
- * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
93
- * no routing at all) - there is nothing to validate against.
94
- */
95
- export async function runGraph(entry, terminals, context, mem, maxSteps = 5000, options) {
96
- preflight(mem, entry);
97
- const page = options?.page ?? (await context.newPage());
98
- const closeOnFinish = options?.closeOnFinish ?? true;
99
- try {
100
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
- let current = entry;
102
- let input = checkpoint("__start__");
103
- let steps = 0;
104
- for (;;) {
105
- if (++steps > maxSteps) {
106
- throw new Error(`runGraph: exceeded ${maxSteps} steps - check for an unintended self-loop`);
107
- }
108
- await runPrecondition(current.instruction.precondition, input, page, mem, current.name);
109
- await current.instruction.act(page, input, mem);
110
- const observed = current.instruction.observe
111
- ? await current.instruction.observe(page, mem)
112
- : undefined;
113
- const checkpoint = await current.instruction.resolve(observed);
114
- await runVerify(current.instruction.verify, checkpoint, page, mem, current.name);
115
- await runLayouts(options?.layouts, checkpoint.__state, checkpoint, page, mem, current.name);
116
- const next = current.next?.(checkpoint);
117
- if (!next) {
118
- if (terminals && !terminals.has(checkpoint.__state)) {
119
- throw new Error(`runGraph: "${current.name}" resolved to "${checkpoint.__state}", which is not a registered terminal (expected one of: ${[...terminals].join(", ")})`);
120
- }
121
- return checkpoint;
122
- }
123
- current = next;
124
- input = checkpoint;
125
- }
126
- }
127
- finally {
128
- if (closeOnFinish) {
129
- await page.close();
130
- }
131
- }
132
- }
133
- /**
134
- * Drives a genuinely separate second tab through its own Block/Flow, in the
135
- * same browser context an existing `page` already belongs to - the primitive
136
- * `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
137
- * since `act`/`resolve` can't touch `page.context()` at all. A thin, named
138
- * wrapper around `runGraph` (which already opens its own page from whatever
139
- * context it's given) rather than a new mechanism - the point is making the
140
- * pattern discoverable and giving it a real name, not inventing new tab-lifecycle
141
- * logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
142
- * resolves; the original `page` is never touched.
143
- * @example
144
- * async observe(page, mem) {
145
- * const receipt = await spawnTab(EmailReceiptFlow, page, mem);
146
- * mem.set(ReceiptCode, receipt.__state);
147
- * }
148
- */
149
- export async function spawnTab(entry, page, mem, maxSteps = 5000) {
150
- return runGraph(entry, undefined, page.context(), mem, maxSteps);
151
- }
152
- /**
153
- * Wraps one `act()` interaction with a caption and highlight, for a Block
154
- * author who wants to explicitly narrate a specific step rather than rely
155
- * on the generic per-fill/per-click narration `waygraph chain
156
- * WAYGRAPH_STEP=1` already does automatically -
157
- * `await narrate(submitButton, "confirms the order", () => submitButton.click())`
158
- * reads at the call site instead of being inferred.
159
- *
160
- * A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
161
- * for a `window.__wgPositionRing` hook the step-mode overlay installs on
162
- * the page before doing anything, so a headless/CI/automated run (nobody
163
- * watching, per-character delays and highlight pauses would just be wasted
164
- * time) pays zero extra cost - not even the boundingBox() lookup runs. The
165
- * `action` itself always runs regardless; only the narration is
166
- * conditional.
167
- * @example await narrate(submitButton, "confirms the order", () => submitButton.click())
168
- */
169
- export async function narrate(locator, caption, action) {
170
- const page = locator.page();
171
- const active = await page
172
- .evaluate(() => typeof globalThis.__wgPositionRing === "function")
173
- .catch(() => false);
174
- if (!active)
175
- return action();
176
- let owning = false;
177
- try {
178
- const box = await locator.boundingBox();
179
- if (box) {
180
- await page
181
- .evaluate(({ box, caption }) => {
182
- const w = globalThis;
183
- if (w.__wgPositionRing)
184
- w.__wgPositionRing(box, caption, "planned");
185
- // The CLI's own automatic per-fill/per-click narration checks
186
- // this before showing (and overwriting) its own guess - an
187
- // explicitly authored caption should win, not get immediately
188
- // replaced a moment later by the generic one.
189
- w.__wgLastNarrate = Date.now();
190
- // Also claim the ring itself: the auto-highlight click/fill
191
- // patches each hide the ring right after THEIR own step
192
- // finishes, which - if action() below does more than one thing
193
- // (e.g. a click then a wait for a toast) - would wipe this
194
- // caption after just the first sub-step instead of the whole
195
- // narrated action. Their hideRing() calls no-op while this is
196
- // true; narrate() below is the one that actually clears it.
197
- w.__wgNarrateOwnsRing = true;
198
- }, { box, caption })
199
- .catch(() => { });
200
- owning = true;
201
- // The ring alone is pointless if action() fires on the very next
202
- // tick - a click that navigates (e.g. a form submit) wipes the ring
203
- // before a human can read the caption at all. Give it the same
204
- // "pop for a few seconds" dwell as the CLI's own auto-highlight
205
- // clicks before the real action runs.
206
- await new Promise((resolve) => setTimeout(resolve, 650));
207
- // Re-stamp __wgLastNarrate right before handing off to action() - the
208
- // CLI's auto-highlight patches only treat a narrate() as "just
209
- // handled" within a short window (500ms), which the dwell above
210
- // already burned through. Without this, action()'s own click/fill
211
- // (same patched Locator prototype) would stomp this caption with its
212
- // own generic one a moment after it finally became visible.
213
- await page
214
- .evaluate(() => {
215
- globalThis.__wgLastNarrate = Date.now();
216
- })
217
- .catch(() => { });
218
- }
219
- }
220
- catch {
221
- // best-effort - the real action below still runs either way
222
- }
223
- try {
224
- return await action();
225
- }
226
- finally {
227
- if (owning) {
228
- await page
229
- .evaluate(() => {
230
- const w = globalThis;
231
- w.__wgNarrateOwnsRing = false;
232
- if (w.__wgHideRing)
233
- w.__wgHideRing();
234
- })
235
- .catch(() => { });
236
- }
237
- }
238
- }
239
- /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
240
- export const start = Symbol("waygraph.start");
241
- export const end = Symbol("waygraph.end");
242
- function findBlockIndex(middle, block) {
243
- if (typeof block === "number") {
244
- if (block < 0 || block >= middle.length) {
245
- throw new Error(`Flow: no Block at index ${block} in this flow (has ${middle.length})`);
246
- }
247
- return block;
248
- }
249
- const blockName = typeof block === "string" ? block : block.name;
250
- const index = middle.findIndex((b) => b.name === blockName);
251
- if (index === -1) {
252
- throw new Error(`Flow: no Block named "${blockName}" in this flow (has: ${middle.map((b) => b.name).join(", ")})`);
253
- }
254
- return index;
255
- }
256
- function buildFlow(middle, engineConfig) {
257
- // resetSession/title start false/unset here - defineFlow itself carries
258
- // no such options anymore. They're set by wrapping the result in
259
- // withSessionReset()/withTitle() instead (non-destructive decorators,
260
- // same pattern as withVerify), which also re-apply themselves after any
261
- // further withBlockVerify/modBlockVerify patch so the flag survives.
262
- // Layouts have to be threaded into every pairwise connect(), not just the
263
- // trailing runGraph call - see connect()'s own doc comment in types.ts:
264
- // buildFlow's reduce IS the only place a defineFlow-built Flow's
265
- // intermediate (non-final) Checkpoints are ever verified at all.
266
- const chain = middle.reduce((a, b) => connect(a, b, engineConfig.layouts));
267
- // Declared separately (not as the object-literal method below) so the
268
- // three-overload `Flow.run` signature can be checked against the actual
269
- // per-call-shape behavior, then attached with a cast - object-literal
270
- // methods can't satisfy TS's per-overload implementation check the way a
271
- // standalone function assigned to the property can.
272
- const run = async function run(contextOrMem, memOrConfig, options) {
273
- if (contextOrMem instanceof MemPage) {
274
- const mem = contextOrMem;
275
- const config = { ...engineConfig, ...memOrConfig };
276
- // Checked again inside runGraph too, but doing it here first means a
277
- // missing key fails before a browser process even launches, not just
278
- // before a page opens - the same "fail loud, cheaply" guarantee
279
- // run(context, mem) already has via an existing context.
280
- preflight(mem, chain);
281
- const browserName = config.browserName ?? "chromium";
282
- const launch = resolveLauncher(browserName, config);
283
- // If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
284
- // or Flatpak Chromium), launch that instead of Playwright's own bundled
285
- // binary - only for chromium (the env var names a Chromium build, not a
286
- // Firefox/WebKit one), and a no-op default anywhere that env var isn't set.
287
- const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
288
- const browser = await launch.launch({
289
- headless: config.headless ?? true,
290
- ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
291
- ...(executablePath ? { executablePath } : {}),
292
- });
293
- try {
294
- const context = await browser.newContext(config.recordVideo ? { recordVideo: config.recordVideo } : {});
295
- try {
296
- return await runGraph(chain, undefined, context, mem, 5000, {
297
- ...(config.layouts !== undefined ? { layouts: config.layouts } : {}),
298
- });
299
- }
300
- finally {
301
- // Playwright finalizes recordVideo on context.close(); skip when
302
- // not recording so mem-only runs with stub contexts stay unchanged.
303
- if (config.recordVideo) {
304
- await context.close();
305
- }
306
- }
307
- }
308
- finally {
309
- await browser.close();
310
- }
311
- }
312
- const context = contextOrMem;
313
- const mem = memOrConfig;
314
- // Open (or reuse) the page here, not inside runGraph, so this method -
315
- // not runGraph - decides whether to close it once the run is over; the
316
- // page reference has to survive past runGraph's own return either way.
317
- const gotOwnPage = options?.page === undefined;
318
- const page = options?.page ?? (await context.newPage());
319
- // Defaulting close-on-finish to `true` unconditionally would close a
320
- // page the *caller* opened and handed in via `options.page` - the exact
321
- // thing this option exists to let a caller keep driving. Only default to
322
- // closing when this call opened the page itself (no `options.page`
323
- // given), which is the one case that matches every `run(context, mem)`
324
- // call from before this option existed. An explicit `closeOnFinish`
325
- // always wins either way.
326
- const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
327
- const resolvedLayouts = options?.layouts ?? engineConfig.layouts;
328
- const result = await runGraph(chain, undefined, context, mem, 5000, {
329
- page,
330
- closeOnFinish: false,
331
- ...(resolvedLayouts !== undefined ? { layouts: resolvedLayouts } : {}),
332
- });
333
- if (closeOnFinish) {
334
- await page.close();
335
- }
336
- // The return *shape* only changes on an explicit `closeOnFinish: false`
337
- // (the literal the `{ result, page }` overload is typed against) - not
338
- // on the computed `closeOnFinish` default above. A caller who passed
339
- // their own `page` and left `closeOnFinish` unset already holds that
340
- // page reference; handing it back again in a different return shape
341
- // would silently break the plain-`Out` overload their call actually
342
- // matched at the type level.
343
- if (options?.closeOnFinish === false) {
344
- return { result, page };
345
- }
346
- return result;
347
- };
348
- return {
349
- resetSession: false,
350
- blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
351
- run: run,
352
- withBlockVerify(block, verify) {
353
- const index = findBlockIndex(middle, block);
354
- const patched = [...middle];
355
- patched[index] = withVerify(middle[index], verify);
356
- return buildFlow(patched, engineConfig);
357
- },
358
- modBlockVerify(block, nameOrIndex, newCheck) {
359
- const index = findBlockIndex(middle, block);
360
- const patched = [...middle];
361
- patched[index] = modVerify(middle[index], nameOrIndex, newCheck);
362
- return buildFlow(patched, engineConfig);
363
- },
364
- };
365
- }
366
- /**
367
- * Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
368
- * browser state - e.g. a login flow meant to be re-entered later in a longer
369
- * showcase. Non-destructive, same pattern as {@link withVerify}: returns a
370
- * new Flow: the original is untouched, and the flag survives any further
371
- * `withBlockVerify`/`modBlockVerify` patch applied to the result.
372
- *
373
- * By itself this does nothing - `Flow.run()` never reads `resetSession`.
374
- * It's a hint {@link chainFlow} looks for: right before this Flow's first
375
- * Block runs (skipped if it's the very first thing in the chain - nothing
376
- * to reset yet), chainFlow clears cookies and storage so this Flow starts
377
- * exactly as if a human had opened the app fresh, not wherever the
378
- * previous Flow in the chain happened to leave the page.
379
- * @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
380
- */
381
- export function withSessionReset(flow) {
382
- return {
383
- ...flow,
384
- resetSession: true,
385
- withBlockVerify: (block, verify) => withSessionReset(flow.withBlockVerify(block, verify)),
386
- modBlockVerify: (block, nameOrIndex, newCheck) => withSessionReset(flow.modBlockVerify(block, nameOrIndex, newCheck)),
387
- };
388
- }
389
- /**
390
- * Names a Flow for display - a step-through overlay/showcase (waygraph's
391
- * `chain --step`) shows this as the running banner while this Flow's own
392
- * Blocks are executing, instead of one fixed title for the whole run.
393
- * Non-destructive, same pattern as {@link withSessionReset}.
394
- * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
395
- */
396
- export function withTitle(flow, title) {
397
- return {
398
- ...flow,
399
- title,
400
- withBlockVerify: (block, verify) => withTitle(flow.withBlockVerify(block, verify), title),
401
- modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
402
- };
403
- }
404
- /**
405
- * Opts this Flow into memStub: `requires` keys the caller didn't supply get
406
- * filled from `registerMemStub`'s registry (`src/mem-stub.ts`) instead of
407
- * failing preflight, for any key that has a registered fake generator - a
408
- * required key with no registration still fails exactly as before.
409
- * Non-destructive, same pattern as {@link withTitle}. `run()` never reads
410
- * this itself - the CLI's `--data`/`--mem-stub` seeding, or a bare-library
411
- * caller's own explicit `seedMemStub(mem, flow)` call, does the filling.
412
- * @example const demoLogin = withMemStub(loginFlow);
413
- */
414
- export function withMemStub(flow) {
415
- return {
416
- ...flow,
417
- memStub: true,
418
- withBlockVerify: (block, verify) => withMemStub(flow.withBlockVerify(block, verify)),
419
- modBlockVerify: (block, nameOrIndex, newCheck) => withMemStub(flow.modBlockVerify(block, nameOrIndex, newCheck)),
420
- };
421
- }
422
- /**
423
- * Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
424
- * Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
425
- * use this when an entire episode should be fast or slow.
426
- * @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
427
- * @example withDemoPace(happyFlow, 0.5) // 2x faster than normal
428
- * @example withDemoPace(gapFlow, 2.5) // 2.5x slower
429
- * @example withDemoPace(gapFlow, 4500) // absolute ~4.5s gates/dwells
430
- */
431
- export function withDemoPace(flow, pace) {
432
- const demoPace = pace;
433
- return {
434
- ...flow,
435
- demoPace,
436
- withBlockVerify: (block, verify) => withDemoPace(flow.withBlockVerify(block, verify), demoPace),
437
- modBlockVerify: (block, nameOrIndex, newCheck) => withDemoPace(flow.modBlockVerify(block, nameOrIndex, newCheck), demoPace),
438
- };
439
- }
440
- /**
441
- * Default highlight look for a Flow episode (size / weight / optional tone).
442
- * Per-slot stub or {@link withHighlightFixtures} values win when set.
443
- * @example withHighlightStyle(withDemoPace(gapFlow, "slow"), { size: "lg", weight: "bold" })
444
- */
445
- export function withHighlightStyle(flow, style) {
446
- const highlightStyle = {
447
- ...(style.tone !== undefined ? { tone: normalizeHighlightTone(style.tone) } : {}),
448
- ...(style.size !== undefined ? { size: normalizeHighlightSize(style.size) } : {}),
449
- ...(style.weight !== undefined ? { weight: normalizeHighlightWeight(style.weight) } : {}),
450
- };
451
- return {
452
- ...flow,
453
- highlightStyle,
454
- withBlockVerify: (block, verify) => withHighlightStyle(flow.withBlockVerify(block, verify), highlightStyle),
455
- modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightStyle(flow.modBlockVerify(block, nameOrIndex, newCheck), highlightStyle),
456
- };
457
- }
458
- /**
459
- * Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
460
- * @example withBlockPace(composeBlock("gap-review", [...]), "slow")
461
- */
462
- export function withBlockPace(block, pace) {
463
- return {
464
- ...block,
465
- demoPace: pace,
466
- };
467
- }
468
- /**
469
- * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
470
- * login attempt genuinely failing, demonstrating the app's own real
471
- * behavior rather than a bug in the demo. Non-destructive, same pattern as
472
- * {@link withTitle}.
473
- *
474
- * By itself this changes nothing about how the Flow runs - it still throws
475
- * Marks a Flow whose last step is an intentional product failure (demo overlay).
476
- * Step mode shows stubOnError rings + an amber "expected outcome" panel when:
477
- * - the last block **throws**, or
478
- * - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
479
- * after submit-login branched instead of throwing).
480
- * Headless `run()` is unchanged - branching success returns the checkpoint.
481
- * @example const viewerBlockedFlow = withExpectedFailure(
482
- * engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
483
- * "locked_out_user stays on LoginPage with the error banner - working as intended.",
484
- * );
485
- */
486
- export function withExpectedFailure(flow, reason) {
487
- return {
488
- ...flow,
489
- expectedFailureReason: reason,
490
- withBlockVerify: (block, verify) => withExpectedFailure(flow.withBlockVerify(block, verify), reason),
491
- modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
492
- };
493
- }
494
- /**
495
- * Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
496
- * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter/
497
- * stubOnError slots. Non-destructive, same pattern as {@link withTitle}.
498
- * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
499
- * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
500
- */
501
- export function withHighlightFixtures(flow, fixtures) {
502
- return {
503
- ...flow,
504
- highlightFixtures: fixtures,
505
- withBlockVerify: (block, verify) => withHighlightFixtures(flow.withBlockVerify(block, verify), fixtures),
506
- modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightFixtures(flow.modBlockVerify(block, nameOrIndex, newCheck), fixtures),
507
- };
508
- }
509
- function locateInChain(flows, block) {
510
- if (typeof block === "number") {
511
- let remaining = block;
512
- for (let fi = 0; fi < flows.length; fi++) {
513
- const len = flows[fi].blocks().length;
514
- if (remaining < len)
515
- return { flowIndex: fi, localIndex: remaining };
516
- remaining -= len;
517
- }
518
- throw new Error(`chainFlow: no Block at index ${block} across ${flows.length} chained flows`);
519
- }
520
- const blockName = typeof block === "string" ? block : block.name;
521
- for (let fi = 0; fi < flows.length; fi++) {
522
- const localIndex = flows[fi].blocks().findIndex((bi) => bi.name === blockName);
523
- if (localIndex !== -1)
524
- return { flowIndex: fi, localIndex };
525
- }
526
- throw new Error(`chainFlow: no Block named "${blockName}" in any chained flow`);
527
- }
528
- async function clearSessionState(context, page) {
529
- await context.clearCookies().catch(() => { });
530
- await page
531
- .evaluate(() => {
532
- try {
533
- localStorage.clear();
534
- sessionStorage.clear();
535
- }
536
- catch {
537
- /* storage blocked (e.g. about:blank) - nothing to clear anyway */
538
- }
539
- })
540
- .catch(() => { });
541
- }
542
- /**
543
- * Runs several complete, independent Flows one after another, reusing the
544
- * same page/context/mem in order - each remains its own validated
545
- * start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
546
- * untouched), this only sequences whole Flows. Deliberately NOT the same
547
- * thing as {@link composeBlock}: composeBlock nests several Blocks into
548
- * one Block that can drop back into another `defineFlow([...])` - a
549
- * chainFlow result is a Flow, but not meant to be nested inside another
550
- * Flow's own Block list. Recursive nesting stays composeBlock's job alone;
551
- * chainFlow is a flat, one-level sequencing of complete showcases (e.g.
552
- * "ticket #123 end to end, then ticket #456 end to end").
553
- *
554
- * A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
555
- * cleared right before it runs (skipped for the very first flow in the
556
- * chain - nothing to reset yet). The combined result is whatever the LAST
557
- * flow resolved to.
558
- * @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
559
- */
560
- export function chainFlow(...flows) {
561
- if (flows.length === 0) {
562
- throw new Error("chainFlow: give at least one Flow");
563
- }
564
- const run = async function run(contextOrMem, memOrConfig, options) {
565
- if (contextOrMem instanceof MemPage) {
566
- const mem = contextOrMem;
567
- const config = memOrConfig;
568
- // One browser for the WHOLE chain - each sub-flow's own run(mem,
569
- // config) would otherwise each launch a separate browser, defeating
570
- // the point of sequencing them (a fresh browser is itself a full
571
- // session reset, silently making withSessionReset a no-op).
572
- const browserName = config?.browserName ?? "chromium";
573
- const launch = resolveLauncher(browserName, config);
574
- const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
575
- const browser = await launch.launch({
576
- headless: config?.headless ?? true,
577
- ...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
578
- ...(executablePath ? { executablePath } : {}),
579
- });
580
- try {
581
- const context = await browser.newContext(config?.recordVideo ? { recordVideo: config.recordVideo } : {});
582
- try {
583
- return await run(context, mem, { closeOnFinish: true });
584
- }
585
- finally {
586
- await context.close();
587
- }
588
- }
589
- finally {
590
- await browser.close();
591
- }
592
- }
593
- const context = contextOrMem;
594
- const mem = memOrConfig;
595
- const gotOwnPage = options?.page === undefined;
596
- const page = options?.page ?? (await context.newPage());
597
- const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
598
- let result;
599
- for (let i = 0; i < flows.length; i++) {
600
- const flow = flows[i];
601
- if (i > 0 && flow.resetSession) {
602
- await clearSessionState(context, page);
603
- }
604
- const outcome = (await flow.run(context, mem, { page, closeOnFinish: false }));
605
- result = outcome.result;
606
- }
607
- if (closeOnFinish) {
608
- await page.close();
609
- }
610
- if (options?.closeOnFinish === false) {
611
- return { result, page };
612
- }
613
- return result;
614
- };
615
- return {
616
- resetSession: false,
617
- blocks: () => flows.flatMap((flow, idx) => flow.blocks().map((bi, i) => ({
618
- ...bi,
619
- ...(i === 0 && idx > 0 && flow.resetSession ? { resetSessionBefore: true } : {}),
620
- }))),
621
- run: run,
622
- withBlockVerify(block, verify) {
623
- const { flowIndex, localIndex } = locateInChain(flows, block);
624
- const patched = [...flows];
625
- patched[flowIndex] = patched[flowIndex].withBlockVerify(localIndex, verify);
626
- return chainFlow(...patched);
627
- },
628
- modBlockVerify(block, nameOrIndex, newCheck) {
629
- const { flowIndex, localIndex } = locateInChain(flows, block);
630
- const patched = [...flows];
631
- patched[flowIndex] = patched[flowIndex].modBlockVerify(localIndex, nameOrIndex, newCheck);
632
- return chainFlow(...patched);
633
- },
634
- };
635
- }
636
- export function composeBlock(name, steps) {
637
- if (steps.length === 0) {
638
- throw new Error(`composeBlock: "${name}" needs at least one step`);
639
- }
640
- const chain = steps.reduce((a, b) => connect(a, b));
641
- return {
642
- ...chain,
643
- name,
644
- steps: () => steps.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
645
- withStepVerify(step, verify) {
646
- const index = findBlockIndex(steps, step);
647
- const patched = [...steps];
648
- patched[index] = withVerify(steps[index], verify);
649
- return composeBlock(name, patched);
650
- },
651
- modStepVerify(step, nameOrIndex, newCheck) {
652
- const index = findBlockIndex(steps, step);
653
- const patched = [...steps];
654
- patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
655
- return composeBlock(name, patched);
656
- },
657
- };
658
- }
659
- /** True when `block` came from {@link fastForwardComposeBlock}. */
660
- export function isFastForwardBlock(block) {
661
- return (block.fastForward === true &&
662
- typeof block.steps === "function");
663
- }
664
- function wrapFfError(ffName, err) {
665
- const msg = err instanceof Error ? err.message : String(err);
666
- if (msg.startsWith(`${ffName} > `)) {
667
- return err instanceof Error ? err : new Error(msg);
668
- }
669
- const wrapped = new Error(`${ffName} > ${msg}`);
670
- if (err instanceof Error && err.stack) {
671
- wrapped.stack = err.stack;
672
- }
673
- return wrapped;
674
- }
675
- export function fastForwardComposeBlock(name, steps) {
676
- if (steps.length === 0) {
677
- throw new Error(`fastForwardComposeBlock: "${name}" needs at least one step`);
678
- }
679
- const base = composeBlock(name, steps);
680
- const origAct = base.instruction.act.bind(base.instruction);
681
- const ff = {
682
- ...base,
683
- fastForward: true,
684
- instruction: {
685
- ...base.instruction,
686
- async act(page, input, mem) {
687
- try {
688
- await origAct(page, input, mem);
689
- }
690
- catch (e) {
691
- throw wrapFfError(name, e);
692
- }
693
- },
694
- },
695
- withStepVerify(step, verify) {
696
- const index = findBlockIndex(steps, step);
697
- const patched = [...steps];
698
- patched[index] = withVerify(steps[index], verify);
699
- return fastForwardComposeBlock(name, patched);
700
- },
701
- modStepVerify(step, nameOrIndex, newCheck) {
702
- const index = findBlockIndex(steps, step);
703
- const patched = [...steps];
704
- patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
705
- return fastForwardComposeBlock(name, patched);
706
- },
707
- };
708
- return ff;
709
- }
710
- /**
711
- * Holds engine-level configuration shared across every flow defined from it -
712
- * for now, just how to launch a browser when a Flow owns its own
713
- * (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
714
- * in a later change.
715
- * @example const engine = new Engine({ headless: false, slowMo: 250 });
716
- */
717
- export class Engine {
718
- config;
719
- constructor(config = {}) {
720
- this.config = config;
721
- }
722
- defineFlow(blocks) {
723
- return buildFlow(blocks.slice(1, -1), this.config);
724
- }
725
- /**
726
- * Fluent, kind-checked alternative to `defineFlow([start, ...blocks, end])` -
727
- * see {@link MapBuilder}. Real, direct request this responds to: pia/zsign's
728
- * own agents kept hand-editing/hand-composing Blocks into ad hoc shapes
729
- * ("locks" convention tried, still got broken) - `map()` forces every step
730
- * through this Engine's own `define*Block` factories (checked by the same
731
- * `__waygraphKind`/`__waygraphSalt` runtime markers `graph.ts`/`map-check.ts`
732
- * already trust), so a hand-rolled plain-object Block can never silently
733
- * pass as a real navigation/assertion/method step. `homeOrigin`, if given,
734
- * also gates `.gotoPage()`/`.gotoExternal()` against each Nav/Page Block's
735
- * own static `url` (skipped, honestly, for click-based/dynamic nav - not
736
- * statically checkable, same limitation `map-check.ts` already documents).
737
- * @example
738
- * const flow = engine.map({ homeOrigin: "https://app.example.com" })
739
- * .start()
740
- * .gotoPage(NavHomeBlock)
741
- * .assert(AssertHelloBlock)
742
- * .gotoExternal(NavMailpitBlock)
743
- * .end();
744
- */
745
- map(options) {
746
- return MapBuilder.begin(this, options?.homeOrigin);
747
- }
748
- }
749
- function mapKindOf(block) {
750
- return block?.__waygraphKind;
751
- }
752
- function mapSaltOf(block) {
753
- return block?.__waygraphSalt;
754
- }
755
- function mapNavUrlOf(block) {
756
- const url = block?.__waygraphNavUrl;
757
- return typeof url === "string" ? url : undefined;
758
- }
759
- const MAP_KIND_FACTORY_HINT = {
760
- nav: "defineNavBlock/defineMemNavBlock",
761
- page: "definePageBlock",
762
- assert: "defineAssertBlock",
763
- };
764
- const MAP_SALT_FACTORY_HINT = {
765
- method: "defineMethodBlock",
766
- effect: "defineEffectBlock",
767
- };
768
- function assertMapKind(block, allowed, method) {
769
- const kind = mapKindOf(block);
770
- if (kind !== undefined && allowed.includes(kind))
771
- return;
772
- const wanted = allowed.map((k) => MAP_KIND_FACTORY_HINT[k] ?? k).join(" or ");
773
- const found = kind ? `a Block of kind "${kind}"` : "an object with no waygraph kind marker at all";
774
- throw new Error(`Waygraph map: .${method}("${block?.name ?? "?"}") requires a Block built with ${wanted} ` +
775
- `(found ${found} - a hand-built plain object doesn't count). This check is the whole point ` +
776
- "of the map() builder: only real Blocks from waygraph's own factories can enter a chain.");
777
- }
778
- function assertMapSalt(block, allowed, method) {
779
- const salt = mapSaltOf(block);
780
- if (salt !== undefined && allowed.includes(salt))
781
- return;
782
- const wanted = allowed.map((s) => MAP_SALT_FACTORY_HINT[s] ?? s).join(" or ");
783
- const found = salt ? `a Block salted "${salt}"` : "an object with no waygraph salt marker at all";
784
- throw new Error(`Waygraph map: .${method}("${block?.name ?? "?"}") requires a Block built with ${wanted} ` +
785
- `(found ${found} - a hand-built plain object doesn't count). This check is the whole point ` +
786
- "of the map() builder: only real Blocks from waygraph's own factories can enter a chain.");
787
- }
788
- function assertMapOrigin(block, homeOrigin, expect, method) {
789
- if (!homeOrigin)
790
- return; // not configured - can't validate, honest no-op
791
- const navUrl = mapNavUrlOf(block);
792
- if (navUrl === undefined)
793
- return; // click-based/dynamic nav - not statically checkable
794
- let targetOrigin;
795
- let wantOrigin;
796
- try {
797
- targetOrigin = new URL(navUrl, homeOrigin).origin;
798
- wantOrigin = new URL(homeOrigin).origin;
799
- }
800
- catch {
801
- return; // malformed URL - not this check's job to validate that
802
- }
803
- const isExternal = targetOrigin !== wantOrigin;
804
- if (expect === "internal" && isExternal) {
805
- throw new Error(`Waygraph map: .gotoPage("${block.name}") targets ${targetOrigin}, which is NOT this map's ` +
806
- `home origin (${wantOrigin}) - use .gotoExternal() for a genuinely cross-origin destination.`);
807
- }
808
- if (expect === "external" && !isExternal) {
809
- throw new Error(`Waygraph map: .gotoExternal("${block.name}") targets ${targetOrigin}, which IS this map's ` +
810
- `home origin (${wantOrigin}) - use .gotoPage() for an internal destination.`);
811
- }
812
- }
813
- /**
814
- * Fluent builder over {@link Engine.defineFlow} - see `map()`'s own doc
815
- * comment for why this exists. Each step method is scoped to exactly the
816
- * Block kind its name promises, checked at RUNTIME against the
817
- * `__waygraphKind`/`__waygraphSalt` markers `defineNavBlock`/`definePageBlock`/
818
- * `defineAssertBlock`/`defineMethodBlock`/`defineEffectBlock` already set
819
- * (the same markers `graph.ts` and `map-check.ts` trust) - not just a type
820
- * hint, since a hand-rolled object can satisfy the TypeScript `Block<In,Out>`
821
- * shape without ever going through a real factory. Each call also
822
- * typechecks the accumulated Checkpoint chain exactly like `defineFlow`'s
823
- * own tuple overloads do (a step's `In` must equal the previous step's
824
- * `Out`) - this is what "no teleporting" means: there is no method on this
825
- * builder that can skip from one Checkpoint to an unrelated one without a
826
- * real, kind-correct Block in between.
827
- */
828
- export class MapBuilder {
829
- engine;
830
- steps;
831
- homeOrigin;
832
- ff;
833
- constructor(engine, steps, homeOrigin, ff = null) {
834
- this.engine = engine;
835
- this.steps = steps;
836
- this.homeOrigin = homeOrigin;
837
- this.ff = ff;
838
- }
839
- /** @internal - use `engine.map()` or the standalone `map()` export. */
840
- static begin(engine, homeOrigin) {
841
- return new MapBuilder(engine, [], homeOrigin, null);
842
- }
843
- /** Readable chain-opener - mirrors `defineFlow`'s leading `start` sentinel. Returns `this` unchanged; entirely optional. */
844
- start() {
845
- return this;
846
- }
847
- /**
848
- * Open a fast-forward window - steps until {@link ffEnd} collapse into one
849
- * opaque `fastForwardComposeBlock` (blitz theater). Kind/salt checks still
850
- * run on each inner step as it is added.
851
- */
852
- ffStart(name) {
853
- if (this.ff) {
854
- throw new Error(`Waygraph map: .ffStart() while already inside "${this.ff.name}" - call .ffEnd() first`);
855
- }
856
- const ffName = typeof name === "string" && name.trim()
857
- ? name.trim()
858
- : `ff-${this.steps.length + 1}`;
859
- return new MapBuilder(this.engine, this.steps, this.homeOrigin, {
860
- name: ffName,
861
- buffer: [],
862
- });
863
- }
864
- /**
865
- * Close the current fast-forward window and append one FFCompose step.
866
- * Throws if empty or if {@link ffStart} was never opened.
867
- */
868
- ffEnd() {
869
- if (!this.ff) {
870
- throw new Error("Waygraph map: .ffEnd() with no open .ffStart()");
871
- }
872
- if (this.ff.buffer.length === 0) {
873
- throw new Error(`Waygraph map: .ffEnd() for "${this.ff.name}" has no steps - add .gotoPage/.method/… inside the window`);
874
- }
875
- const composed = fastForwardComposeBlock(this.ff.name,
876
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
877
- this.ff.buffer);
878
- return new MapBuilder(this.engine, [...this.steps, composed], this.homeOrigin, null);
879
- }
880
- appendStep(block) {
881
- if (this.ff) {
882
- return new MapBuilder(this.engine, this.steps, this.homeOrigin, {
883
- name: this.ff.name,
884
- buffer: [...this.ff.buffer, block],
885
- });
886
- }
887
- return new MapBuilder(this.engine, [...this.steps, block], this.homeOrigin, null);
888
- }
889
- /**
890
- * Appends an internal navigation/page-arrival step - requires a Block from
891
- * `defineNavBlock`/`defineMemNavBlock`/`definePageBlock`. Throws if given
892
- * anything else, including a `homeOrigin`-mismatched static `url`.
893
- * Compile-time: {@link NavBlock} or {@link PageBlock} only (not Assert/Method).
894
- */
895
- gotoPage(block) {
896
- assertMapKind(block, ["nav", "page"], "gotoPage");
897
- assertMapOrigin(block, this.homeOrigin, "internal", "gotoPage");
898
- return this.appendStep(block);
899
- }
900
- /**
901
- * Appends a genuinely cross-origin navigation step (e.g. `(external)/`
902
- * Blocks in the Waygraph Map convention: mailpit, maildrop.cc) - same
903
- * kind requirement as {@link gotoPage}, plus the inverse `homeOrigin` check.
904
- * Compile-time: {@link NavBlock} or {@link PageBlock} only.
905
- */
906
- gotoExternal(block) {
907
- assertMapKind(block, ["nav", "page"], "gotoExternal");
908
- assertMapOrigin(block, this.homeOrigin, "external", "gotoExternal");
909
- return this.appendStep(block);
910
- }
911
- /**
912
- * Appends a self-loop verification step - requires a Block from
913
- * `defineAssertBlock`. Compile-time: {@link AssertBlock} only (not `.method()`).
914
- */
915
- assert(block) {
916
- assertMapKind(block, ["assert"], "assert");
917
- return this.appendStep(block);
918
- }
919
- /**
920
- * Appends a non-navigating page action (submit, upload, add/remove an
921
- * instance) - requires a Block from `defineMethodBlock`/`defineActionBlock`/
922
- * `defineEffectBlock`/`defineMemEffectBlock`.
923
- * Compile-time: {@link MethodBlock} or {@link EffectBlock} only - Assert
924
- * Blocks must use `.assert()`. Runtime also rejects `__waygraphKind =
925
- * "assert"` (those still carry method salt from the factory internals).
926
- */
927
- method(block) {
928
- if (mapKindOf(block) === "assert") {
929
- throw new Error(`Waygraph map: .method("${block.name}") got a defineAssertBlock - use .assert() for ` +
930
- "self-loop verify steps (Assert Blocks share method salt internally but are not methods).");
931
- }
932
- assertMapSalt(block, ["method", "effect"], "method");
933
- return this.appendStep(block);
934
- }
935
- /**
936
- * Finalizes this chain into a real, runnable {@link Flow} - same object
937
- * `defineFlow` returns, so `withBlockVerify`/`modBlockVerify` and every
938
- * `withTitle`/`withHighlightFixtures`/etc. decorator still apply exactly
939
- * as they do today; this builder only changes how the chain is assembled,
940
- * never what it produces. Throws if no step was ever added - an empty map
941
- * isn't a flow.
942
- */
943
- end() {
944
- if (this.ff) {
945
- throw new Error(`Waygraph map: .end() while .ffStart("${this.ff.name}") is still open - call .ffEnd() first`);
946
- }
947
- if (this.steps.length === 0) {
948
- throw new Error("Waygraph map: .end() called with zero steps - add at least one .gotoPage()/.gotoExternal()/" +
949
- ".assert()/.method() before .end()");
950
- }
951
- const chain = [start, ...this.steps, end];
952
- return this.engine.defineFlow(chain);
953
- }
954
- }
955
- /**
956
- * Standalone convenience for `new Engine(config).map(options)` - use this
957
- * when a call site doesn't otherwise need its own `Engine` instance (no
958
- * shared `headless`/`slowMo`/`layouts` across several flows).
959
- * @example const flow = map({ homeOrigin: "https://app.example.com" }).gotoPage(NavHomeBlock).end();
960
- */
961
- export function map(options) {
962
- return new Engine(options).map(options);
963
- }
964
- /**
965
- * Attaches routing to `block` without touching its act/observe/resolve/verify -
966
- * `routes` must cover every tag of `block`'s output (a missing route is a compile
967
- * error), and the returned Block runs exactly as `block` always did, just followed
968
- * by whichever next Block the resolved tag routes to. `null` for a tag means that
969
- * tag is terminal - the run stops there, same as a Block with no routing at all.
970
- * A route pointing back at `block` itself is a self-loop; see `runGraph`'s
971
- * `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
972
- * a route can lead anywhere, including back through itself, so there is no fixed
973
- * Out type to express; state the expected outcomes to `runGraph` via `terminals`
974
- * instead.
975
- */
976
- export function branch(block, routes) {
977
- const base = stripMethods(block);
978
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
979
- let routed;
980
- const routesAsData = {};
981
- for (const [tag, target] of Object.entries(routes)) {
982
- routesAsData[tag] = target === null ? null : target === block ? base.name : target.name;
983
- }
984
- routed = defineBlock({
985
- ...base,
986
- next: (checkpoint) => {
987
- const target = routes[checkpoint.__state];
988
- if (target === undefined || target === null)
989
- return undefined;
990
- // A route naming `block` itself (the only self-reference in scope when
991
- // `routes` was written) means "loop back with routing intact" - substitute
992
- // the routed Block, not the original with no `next` of its own.
993
- return target === block ? routed : target;
994
- },
995
- routes: routesAsData,
996
- });
997
- return routed;
998
- }
999
- /**
1000
- * Strips `withVerify`/`modVerify`/`modVerifyAll` off a Block, keeping everything
1001
- * else - `name`, `instruction`, `next`, `requires`. Every function below that
1002
- * returns a new Block calls this on its input first, then rebuilds through
1003
- * `defineBlock`. Without it, a Block's attached methods close over the shape it
1004
- * had *when they were attached* - call `.withVerify()` on a Block that
1005
- * `branch()` added routing to afterward, and without this step the result would
1006
- * silently drop that routing, since the stale closure never knew about it.
1007
- */
1008
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1009
- function stripMethods(block) {
1010
- return {
1011
- name: block.name,
1012
- instruction: block.instruction,
1013
- ...(block.description ? { description: block.description } : {}),
1014
- ...(block.next ? { next: block.next } : {}),
1015
- ...(block.requires ? { requires: block.requires } : {}),
1016
- ...(block.routes ? { routes: block.routes } : {}),
1017
- ...(block.instanceOptions ? { instanceOptions: block.instanceOptions } : {}),
1018
- };
1019
- }
1020
- /** Copy non-enumerable waygraph markers so decorate/withVerify keep map() kind checks. */
1021
- function copyWaygraphRuntime(from, to) {
1022
- for (const key of [
1023
- "__waygraphKind",
1024
- "__waygraphSalt",
1025
- "__waygraphNavUrl",
1026
- "__waygraphNavClick",
1027
- ]) {
1028
- const desc = Object.getOwnPropertyDescriptor(from, key);
1029
- if (!desc)
1030
- continue;
1031
- // Re-define as configurable so later decorate chains can copy again
1032
- // (factory markers are non-configurable on the original).
1033
- Object.defineProperty(to, key, {
1034
- value: desc.value,
1035
- enumerable: false,
1036
- writable: false,
1037
- configurable: true,
1038
- });
1039
- }
1040
- const ff = from.fastForward;
1041
- if (ff === true) {
1042
- to.fastForward = true;
1043
- }
1044
- const pace = from.demoPace;
1045
- if (pace !== undefined) {
1046
- to.demoPace = pace;
1047
- }
1048
- }
1049
- /**
1050
- * Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
1051
- * methods - `LoginBlock.withVerify([...])`, discoverable by typing
1052
- * `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
1053
- * if you already know it exists. A raw object literal is still a perfectly
1054
- * valid Block (every test fixture in this codebase is one) - it just won't have
1055
- * these attached. The methods are thin: they delegate to the identically-named
1056
- * free functions, which remain the one place this logic actually lives.
1057
- */
1058
- export function defineBlock(base) {
1059
- const plain = {
1060
- name: base.name,
1061
- instruction: base.instruction,
1062
- ...(base.description ? { description: base.description } : {}),
1063
- ...(base.next ? { next: base.next } : {}),
1064
- ...(base.requires ? { requires: base.requires } : {}),
1065
- ...(base.routes ? { routes: base.routes } : {}),
1066
- ...(base.instanceOptions ? { instanceOptions: base.instanceOptions } : {}),
1067
- };
1068
- // Methods must close over `defined` (this object), not `plain` - factories
1069
- // attach __waygraphKind/__waygraphSalt on the returned object after
1070
- // defineBlock returns; decorate/withVerify need those markers.
1071
- const defined = {
1072
- ...plain,
1073
- withVerify: (verify) => withVerify(defined, verify),
1074
- modVerify: (nameOrIndex, newCheck) => modVerify(defined, nameOrIndex, newCheck),
1075
- modVerifyAll: (patches) => modVerifyAll(defined, patches),
1076
- stubBefore: (stub) => withStubBefore(defined, stub),
1077
- stubAfter: (stub) => withStubAfter(defined, stub),
1078
- stubOnError: (stub) => withStubOnError(defined, stub),
1079
- };
1080
- return defined;
1081
- }
1082
- /**
1083
- * Builds a {@link NavBlock}. The generated `act()` is always exactly
1084
- * `page.goto(url)` or `page.locator(click).click()` - never anything else,
1085
- * never author-supplied. Callers get a real `Page` here internally (the one
1086
- * legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
1087
- * narrowing is what discourages navigation everywhere else, and never
1088
- * applies to this generated function since no author ever writes it by
1089
- * hand.
1090
- * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
1091
- * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
1092
- * @example defineNavClickBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
1093
- * For click-nav in app code prefer {@link defineNavClickBlock} (url stays here).
1094
- */
1095
- export function defineNavBlock(options) {
1096
- const built = defineBlock({
1097
- name: options.name,
1098
- ...(options.description ? { description: options.description } : {}),
1099
- ...(options.requires ? { requires: options.requires } : {}),
1100
- ...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
1101
- instruction: {
1102
- async act(page, _input, mem) {
1103
- if (options.url !== undefined) {
1104
- const url = typeof options.url === "function" ? options.url(mem) : options.url;
1105
- await page.goto(url);
1106
- }
1107
- else {
1108
- // Always Locator.click() so step-mode's demo cursor/pulse patch
1109
- // (instrumentInteractionHighlighting) sees NavBlock click-nav -
1110
- // never page.click() / goto teleport.
1111
- const selector = typeof options.click === "function" ? options.click(mem) : options.click;
1112
- await page.locator(selector).click();
1113
- }
1114
- },
1115
- resolve: () => checkpoint(options.checkpoint),
1116
- ...(options.verify ? { verify: options.verify } : {}),
1117
- ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
1118
- ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
1119
- ...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
1120
- ...(options.highlights ? { highlights: options.highlights } : {}),
1121
- },
1122
- });
1123
- // Non-enumerable so it never shows up in Object.keys/JSON/autocomplete -
1124
- // purely a runtime marker `waygraph check` (or anything else walking a
1125
- // project's Blocks) can test for to tell a NavBlock apart from a regular
1126
- // one, since the TypeScript type alone (NavBlock = DefinedBlock) doesn't
1127
- // survive to runtime.
1128
- Object.defineProperty(built, "__waygraphKind", {
1129
- value: "nav",
1130
- enumerable: false,
1131
- configurable: false,
1132
- });
1133
- // Demo/step tooling: remember the click selector (or factory) so
1134
- // overlays can label "nav click" without re-parsing source.
1135
- if (options.click !== undefined) {
1136
- Object.defineProperty(built, "__waygraphNavClick", {
1137
- value: options.click,
1138
- enumerable: false,
1139
- configurable: false,
1140
- });
1141
- }
1142
- // Same idea, for the url case - only when it's a plain string (a
1143
- // function URL is mem-dependent/dynamic, can't be compared statically
1144
- // without running it). Real, direct request this responds to: an agent
1145
- // driving Blind Pilot had no structured way to ask "which of the links
1146
- // on THIS page are already covered by a NavBlock" without re-parsing
1147
- // every nav.block.ts's own source - this is what `AutoSession` now reads
1148
- // to warn about real, unmapped nav links found live on the page.
1149
- if (typeof options.url === "string") {
1150
- Object.defineProperty(built, "__waygraphNavUrl", {
1151
- value: options.url,
1152
- enumerable: false,
1153
- configurable: false,
1154
- });
1155
- }
1156
- return built;
1157
- }
1158
- /** @see {@link NavClickBlockOptions} */
1159
- export function defineNavClickBlock(options) {
1160
- return defineNavBlock({
1161
- name: options.name,
1162
- checkpoint: options.checkpoint,
1163
- click: options.click,
1164
- ...(options.description ? { description: options.description } : {}),
1165
- ...(options.requires ? { requires: options.requires } : {}),
1166
- ...(options.verify ? { verify: options.verify } : {}),
1167
- ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
1168
- ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
1169
- ...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
1170
- ...(options.highlights ? { highlights: options.highlights } : {}),
1171
- ...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
1172
- });
1173
- }
1174
- export function defineMethodBlock(base) {
1175
- const built = defineBlock(base);
1176
- Object.defineProperty(built, "__waygraphSalt", {
1177
- value: "method",
1178
- enumerable: false,
1179
- configurable: false,
1180
- });
1181
- return built;
1182
- }
1183
- /** @deprecated Prefer {@link defineMethodBlock} - identical helper (0.7 name). */
1184
- export const defineActionBlock = defineMethodBlock;
1185
- /**
1186
- * Sugar for a self-loop-only "pure assertion" Block: no hand-written
1187
- * `act`/`resolve`, just a name, a `checkpoint` tag, and a `verify` array.
1188
- * `resolve` is generated from the one `checkpoint` field given, closing the
1189
- * copy/rename footgun where a hand-restated checkpoint string can silently
1190
- * drift wrong across a copy/pasted file. Mirrors `definePageBlock`'s own
1191
- * construction pattern (flat options in, `defineBlock` built internally).
1192
- * See openspec/changes/waygraph-agent-skill-hardening/spec.md.
1193
- * @example
1194
- * defineAssertBlock({
1195
- * name: "assert-raw-materials-table",
1196
- * checkpoint: "RawMaterials",
1197
- * waitForHeading: "Raw Materials & Lead Times",
1198
- * verify: [Trait.visible(RawMaterialsSel.reorderBadge)],
1199
- * });
1200
- */
1201
- export function defineAssertBlock(options) {
1202
- const built = defineMethodBlock({
1203
- name: options.name,
1204
- ...(options.description ? { description: options.description } : {}),
1205
- ...(options.requires ? { requires: options.requires } : {}),
1206
- instruction: {
1207
- async act(page) {
1208
- if (options.waitForHeading !== undefined) {
1209
- await page.getByRole("heading", { name: options.waitForHeading }).waitFor();
1210
- }
1211
- },
1212
- resolve: () => checkpoint(options.checkpoint),
1213
- verify: options.verify,
1214
- ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
1215
- ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
1216
- ...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
1217
- ...(options.slides ? { slides: options.slides } : {}),
1218
- },
1219
- });
1220
- // Real bug this closes: `waygraph graph`'s static discovery only knows
1221
- // how to read a Block's Checkpoint tags from an EXPLICIT `<In, Out>`
1222
- // generic written in the source text (or, for Nav/Page Blocks, by calling
1223
- // their own input-independent `resolve()` directly). `defineAssertBlock`'s
1224
- // whole point is skipping that generic - called exactly as documented
1225
- // (`defineAssertBlock({ name, checkpoint, verify })`, no generics), it was
1226
- // silently invisible to both `waygraph graph` and the live Pilot menu
1227
- // ("2 Block(s) skipped (Out not resolvable)", confirmed live against a
1228
- // real veciro-waygraph session). `resolve()` here is ALSO input-
1229
- // independent (always `checkpoint(options.checkpoint)`), so the same
1230
- // resolve()-calling path Nav/Page Blocks already use is reused via this
1231
- // marker instead of requiring authors to write generics just to be found.
1232
- Object.defineProperty(built, "__waygraphKind", {
1233
- value: "assert",
1234
- enumerable: false,
1235
- configurable: false,
1236
- });
1237
- // Brand for map().assert() - distinct from MethodBlock so .method(assert)
1238
- // is a compile error even though runtime still carries method salt.
1239
- return built;
1240
- }
1241
- function resolvePageMethods(methods) {
1242
- if (!methods)
1243
- return [];
1244
- const entries = Array.isArray(methods) ? methods : Object.values(methods);
1245
- return entries.map((entry) => (typeof entry === "function" ? entry() : entry));
1246
- }
1247
- /** @see {@link PageBlockOptions} */
1248
- export function definePageBlock(options) {
1249
- if (options.url !== undefined && options.click !== undefined) {
1250
- throw new Error(`definePageBlock("${options.name}"): pass url XOR click (or neither for arrival-only hub)`);
1251
- }
1252
- const methodList = resolvePageMethods(options.methods);
1253
- const built = defineBlock({
1254
- name: options.name,
1255
- ...(options.description ? { description: options.description } : {}),
1256
- ...(options.requires ? { requires: options.requires } : {}),
1257
- ...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
1258
- instruction: {
1259
- async act(page, _input, mem) {
1260
- if (options.url !== undefined) {
1261
- const url = typeof options.url === "function" ? options.url(mem) : options.url;
1262
- await page.goto(url);
1263
- }
1264
- else if (options.click !== undefined) {
1265
- const selector = typeof options.click === "function" ? options.click(mem) : options.click;
1266
- await page.locator(selector).click();
1267
- }
1268
- // else: arrival-only hub — previous Block already landed here
1269
- },
1270
- resolve: () => checkpoint(options.checkpoint),
1271
- ...(options.verify ? { verify: options.verify } : {}),
1272
- ...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
1273
- ...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
1274
- ...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
1275
- ...(options.highlights ? { highlights: options.highlights } : {}),
1276
- },
1277
- });
1278
- Object.defineProperty(built, "__waygraphKind", {
1279
- value: "page",
1280
- enumerable: false,
1281
- configurable: false,
1282
- });
1283
- Object.defineProperty(built, "__waygraphSalt", {
1284
- value: "page",
1285
- enumerable: false,
1286
- configurable: false,
1287
- });
1288
- Object.defineProperty(built, "methods", {
1289
- value: Object.freeze([...methodList]),
1290
- enumerable: true,
1291
- configurable: false,
1292
- writable: false,
1293
- });
1294
- Object.defineProperty(built, "__waygraphMethods", {
1295
- value: methodList.map((m) => m.name),
1296
- enumerable: false,
1297
- configurable: false,
1298
- });
1299
- // Same marker `defineNavBlock` sets, same reason - a page hub with a
1300
- // `url` (deep-link) is exactly as "known, coverable" as a plain
1301
- // `defineNavBlock`, and in practice most real projects' own nav Blocks
1302
- // ARE page hubs (methods hanging off them), not bare NavBlocks - the real
1303
- // saucedemo fixture this whole package tests against uses definePageBlock
1304
- // for every one of its own nav-* Blocks. Missing this here meant the
1305
- // marker existed but was never actually set on any real project's Blocks.
1306
- if (typeof options.url === "string") {
1307
- Object.defineProperty(built, "__waygraphNavUrl", {
1308
- value: options.url,
1309
- enumerable: false,
1310
- configurable: false,
1311
- });
1312
- }
1313
- if (options.url !== undefined || options.click !== undefined) {
1314
- Object.defineProperty(built, "__waygraphPageDeepLink", {
1315
- value: true,
1316
- enumerable: false,
1317
- configurable: false,
1318
- });
1319
- }
1320
- if (options.click !== undefined) {
1321
- Object.defineProperty(built, "__waygraphNavClick", {
1322
- value: options.click,
1323
- enumerable: false,
1324
- configurable: false,
1325
- });
1326
- }
1327
- return built;
1328
- }
1329
- /**
1330
- * {@link defineBlock} constrained for effect-style instance menus: `requires` +
1331
- * `instanceOptions` are mandatory. Prefer this when the Block adds / removes /
1332
- * toggles a live instance rather than navigating. Runtime result is an ordinary
1333
- * Block - the helper is TypeScript salt only.
1334
- */
1335
- export function defineEffectBlock(base) {
1336
- const built = defineBlock(base);
1337
- Object.defineProperty(built, "__waygraphMemKind", {
1338
- value: "effect",
1339
- enumerable: false,
1340
- configurable: false,
1341
- });
1342
- Object.defineProperty(built, "__waygraphSalt", {
1343
- value: "effect",
1344
- enumerable: false,
1345
- configurable: false,
1346
- });
1347
- return built;
1348
- }
1349
- /** @deprecated Prefer {@link defineEffectBlock} - identical helper. */
1350
- export const defineMemEffectBlock = defineEffectBlock;
1351
- /**
1352
- * {@link defineNavBlock} constrained to mem-picked navigation: requires +
1353
- * instanceOptions mandatory, and `click`/`url` should depend on that mem
1354
- * (typically `click: (mem) => …`). Prefer this when the Block opens one of
1355
- * several live rows/items rather than a fixed nav target. Still a Block.
1356
- */
1357
- export function defineMemNavBlock(options) {
1358
- const built = defineNavBlock(options);
1359
- Object.defineProperty(built, "__waygraphMemKind", {
1360
- value: "nav",
1361
- enumerable: false,
1362
- configurable: false,
1363
- });
1364
- return built;
1365
- }
1366
- /**
1367
- * "Where am I" - reverse-matches a live `page` against every NavBlock /
1368
- * PageBlock in `library`, in order, returning the first one whose own `verify`
1369
- * Traits all pass (its `checkpoint`), or `null` if none match. Deliberately
1370
- * Nav/Page hubs only: each has exactly one fixed `checkpoint` and a `verify`
1371
- * list whose whole job is already "confirm arrival here," so it's a reliable
1372
- * fingerprint - a regular `defineBlock` can branch to different `Out` tags
1373
- * depending on runtime evidence, so its `verify` alone doesn't identify a
1374
- * single state the way a hub's does.
1375
- *
1376
- * The primitive `waygraph auto`'s discovered graph is *for* - the graph says
1377
- * what pages exist and how to reach them; `locate()` is how an autonomous
1378
- * run figures out which one it's actually looking at right now, without a
1379
- * human telling it.
1380
- *
1381
- * More than one NavBlock matching is a real possibility (two Checkpoints
1382
- * whose `verify` Traits are both satisfied by the same page) - `locate()`
1383
- * returns the first match rather than treating that as an error; resolving
1384
- * genuine ambiguity is a follow-up, not solved here.
1385
- * @example const here = await locate(page, [NavLoginBlock, NavDashboardBlock]);
1386
- */
1387
- export async function locate(page, library, options) {
1388
- const mem = new MemPage();
1389
- const timeoutMs = options?.timeoutMs;
1390
- if (timeoutMs !== undefined) {
1391
- page.setDefaultTimeout(timeoutMs);
1392
- }
1393
- try {
1394
- for (const block of library) {
1395
- try {
1396
- // NavBlock's own resolve() ignores whatever it's called with - always
1397
- // exactly `checkpoint(options.checkpoint)` - so this is safe to call
1398
- // before verify, unlike a regular Block where resolve depends on
1399
- // observed evidence.
1400
- const resolved = await block.instruction.resolve(undefined);
1401
- const verify = block.instruction.verify;
1402
- const traits = typeof verify === "function" ? verify(resolved) : (verify ?? []);
1403
- let allPass = true;
1404
- for (const trait of traits) {
1405
- // eslint-disable-next-line no-await-in-loop
1406
- if (!(await trait.check(page, mem))) {
1407
- allPass = false;
1408
- break;
1409
- }
1410
- }
1411
- if (allPass)
1412
- return resolved.__state;
1413
- }
1414
- catch {
1415
- // This NavBlock's own verify threw (e.g. a waitFor timing out) -
1416
- // treat that the same as "didn't match," try the next candidate.
1417
- }
1418
- }
1419
- return null;
1420
- }
1421
- finally {
1422
- if (timeoutMs !== undefined) {
1423
- page.setDefaultTimeout(30_000);
1424
- }
1425
- }
1426
- }
1427
- /**
1428
- * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
1429
- * same routing - but with `verify` replaced. For giving one flow stricter (or
1430
- * looser, or just different) confirmation on a Block it didn't author, without
1431
- * that Block needing to have been written as a parameterized factory in
1432
- * anticipation. Same move as `branch()`: decorate an existing Block, don't ask
1433
- * every Block to pre-build its own override mechanism.
1434
- *
1435
- * Wrap the one Block you want to change, in place, wherever it already sits in
1436
- * a `defineFlow([...])` array - every other Block stays untouched:
1437
- * `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
1438
- * There is no separate positional array to keep in sync and no `null`
1439
- * placeholder for "skip this one" - `defineFlow` has no per-slot verify
1440
- * argument at all, only whatever `verify` each Block's own `instruction` (or
1441
- * a `withVerify(...)` wrapping it) carries with it.
1442
- */
1443
- export function withVerify(block, verify) {
1444
- const base = stripMethods(block);
1445
- const next = defineBlock({ ...base, instruction: { ...base.instruction, verify } });
1446
- copyWaygraphRuntime(block, next);
1447
- return next;
1448
- }
1449
- function isHighlightStubPhase(v) {
1450
- return !!v && typeof v === "object" && !Array.isArray(v);
1451
- }
1452
- /** Run a prior stub (fn or slot map) into an open ctx so decorate can chain. */
1453
- async function applyStubIntoCtx(raw, ctx) {
1454
- if (raw === undefined)
1455
- return;
1456
- if (typeof raw === "function") {
1457
- const ret = await raw(ctx);
1458
- if (isHighlightStubPhase(ret)) {
1459
- for (const [id, stub] of Object.entries(ret)) {
1460
- if (stub)
1461
- ctx.ring(id, stub);
1462
- }
1463
- }
1464
- return;
1465
- }
1466
- if (isHighlightStubPhase(raw)) {
1467
- for (const [id, stub] of Object.entries(raw)) {
1468
- if (stub)
1469
- ctx.ring(id, stub);
1470
- }
1471
- }
1472
- }
1473
- function withStubPhase(block, phase, stub) {
1474
- const base = stripMethods(block);
1475
- const instr = base.instruction;
1476
- const prev = instr[phase];
1477
- const chained = async (ctx) => {
1478
- await applyStubIntoCtx(prev, ctx);
1479
- await applyStubIntoCtx(stub, ctx);
1480
- };
1481
- const next = defineBlock({
1482
- ...base,
1483
- instruction: { ...base.instruction, [phase]: chained },
1484
- });
1485
- copyWaygraphRuntime(block, next);
1486
- return next;
1487
- }
1488
- /**
1489
- * Attach / chain a stubBefore lifecycle on a Block. Prefer
1490
- * `(ctx) => { ctx.ring(...); const x = ctx.mem?.get(Key); }`.
1491
- * @example FillUsernameBlock.stubBefore((ctx) => { ctx.ring("username", { ... }) })
1492
- */
1493
- export function withStubBefore(block, stub) {
1494
- return withStubPhase(block, "stubBefore", stub);
1495
- }
1496
- /** @see {@link withStubBefore} */
1497
- export function withStubAfter(block, stub) {
1498
- return withStubPhase(block, "stubAfter", stub);
1499
- }
1500
- /** @see {@link withStubBefore} */
1501
- export function withStubOnError(block, stub) {
1502
- return withStubPhase(block, "stubOnError", stub);
1503
- }
1504
- /**
1505
- * Replaces one Trait inside `block`'s verify list, leaving every other check
1506
- * untouched - the v1/v2/legacy-variant case, where one check needs a different
1507
- * selector or URL pattern but the rest of the Block is identical. Addressed by
1508
- * the Trait's own `name` (recommended - inserting or reordering other checks
1509
- * can't silently retarget it) or, if you genuinely know the position and don't
1510
- * need that safety, a numeric index. Either way a bad address fails loud
1511
- * immediately instead of quietly doing nothing. `newCheck` can be a bare
1512
- * `check` function (keeps the existing name) or a full `Trait` (to rename too).
1513
- * Only works on a flat `verify` array - a function-form `verify` (from
1514
- * `verify: (out) => Trait[]`) has no fixed list to address into, so this
1515
- * throws rather than guess; use `withVerify` to replace it wholesale instead.
1516
- */
1517
- export function modVerify(block, nameOrIndex, newCheck) {
1518
- const verify = block.instruction.verify;
1519
- if (typeof verify === "function") {
1520
- throw new Error(`modVerify: "${block.name}" has a function-form verify, which has no fixed list to address into - use withVerify to replace it wholesale instead`);
1521
- }
1522
- const traits = verify ?? [];
1523
- const index = typeof nameOrIndex === "number" ? nameOrIndex : traits.findIndex((t) => t.name === nameOrIndex);
1524
- const existing = traits[index];
1525
- if (!existing) {
1526
- throw new Error(typeof nameOrIndex === "number"
1527
- ? `modVerify: "${block.name}" has no verify trait at index ${nameOrIndex} (has ${traits.length})`
1528
- : `modVerify: "${block.name}" has no verify trait named "${nameOrIndex}" (has: ${traits.map((t) => t.name).join(", ") || "none"})`);
1529
- }
1530
- const replacement = typeof newCheck === "function"
1531
- ? { name: typeof nameOrIndex === "string" ? nameOrIndex : existing.name, check: newCheck }
1532
- : newCheck;
1533
- const updated = [...traits];
1534
- updated[index] = replacement;
1535
- return withVerify(block, updated);
1536
- }
1537
- /**
1538
- * `modVerify`, but for several named checks in one call - a v2 variant that
1539
- * differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
1540
- * modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
1541
- * Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
1542
- * fine here - unlike `MemPage`, there's no identity-collision reason to need
1543
- * `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
1544
- * same fail-loud-on-unknown-name behavior holds for every entry.
1545
- */
1546
- export function modVerifyAll(block, patches) {
1547
- const seed = defineBlock(stripMethods(block));
1548
- copyWaygraphRuntime(block, seed);
1549
- return Object.entries(patches).reduce((current, [name, newCheck]) => modVerify(current, name, newCheck), seed);
1550
- }
1
+ // Barrel: the engine lives in src/engine/ (see src/ARCHITECTURE.md). Kept so existing imports of "./engine.js" don't change.
2
+ export * from "./engine/config.js";
3
+ export * from "./engine/run-graph.js";
4
+ export * from "./engine/flow.js";
5
+ export * from "./engine/core.js";
6
+ export * from "./engine/compose.js";
7
+ export * from "./engine/engine-class.js";
8
+ export * from "./engine/blocks/nav.js";
9
+ export * from "./engine/blocks/method.js";
10
+ export * from "./engine/blocks/page.js";
11
+ export * from "./engine/blocks/effect.js";
12
+ export * from "./engine/locate.js";