waygraph 0.15.21 → 0.15.23

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.
@@ -0,0 +1,776 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { instrumentInteractionHighlighting } from "./interaction-patch.js";
4
+ import { applyDevicePhase, applyHighlightStyleDefaults, applyTodoPhase, demoPaceGateMs, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, formatHighlightCaption, hasAuthoredStubAfter, resolveSlides, resolveStepDemoPace, resolveTodoDockUi, runStubPhase } from "../highlights.js";
5
+ import { markStepRunning, resetPageState, teardownOverlay } from "./teardown.js";
6
+ import { demoLog, logStubPhaseFixtures, logTodoDockFull, paceSpeakFields, summarizeDevice } from "./demo-log.js";
7
+ import { applyDeviceToPage } from "./device-stage.js";
8
+ import { extractVerifyHighlights, presentFailPanel, renderAfterStep, renderBeforeStep } from "./step-panels.js";
9
+ import { probeTodoDocksOnPage } from "./todo-dock.js";
10
+ import { join } from "node:path";
11
+ import { presentSlides } from "./slides.js";
12
+
13
+ export async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
14
+ // A shared, mutable pacing knob the interaction patches read live (per
15
+ // call, not once at setup) - flipped per-block below so one block (e.g.
16
+ // WAYGRAPH_FAST_BLOCKS=login or an FFCompose unit) can run through with
17
+ // none of the overlay's own added dwell while the rest of the chain keeps
18
+ // the full theatrical pace.
19
+ // --fast / WAYGRAPH_DEMO_FAST: shorter auto-next / Next gates ONLY -
20
+ // keeps smooth cursor travel (not akin to waygraph run).
21
+ // FFCompose + WAYGRAPH_FAST_BLOCKS: skipTheater (blitz overlay delays).
22
+ // When the flow has any opaque FF unit and WAYGRAPH_SLOWMO is unset,
23
+ // launch slowMo is 0 so Playwright itself does not re-tax every click
24
+ // inside the FF (that was the "30s login / cursor gone but still slow" bug).
25
+ const demoFast = process.env.WAYGRAPH_DEMO_FAST === "1";
26
+ const stepperMode = process.env.WAYGRAPH_STEPPER === "full" ? "full" : "carousel";
27
+ const pacing = { gatesFast: demoFast, gatesSlow: false, skipTheater: false, demoPace: "normal" };
28
+ const stubBeforeRef = { current: [] };
29
+ /** Live todo dock for Method fill/click advance (same carry as lastTodoDock). */
30
+ const todoDockRef = { current: undefined };
31
+ /** Live device fixture for touch theater (same carry as lastDevice). */
32
+ const deviceRef = { current: undefined };
33
+ instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef, deviceRef);
34
+ // Force the panel checkbox from this process's flags/env at run start.
35
+ // installOverlay only seeds localStorage when the key is null (so mid-run
36
+ // checkbox clicks survive navigations). Without this, a prior --autoplay
37
+ // session sticks forever and agents cannot flip back with --no-autoplay.
38
+ // Must run on the *demo origin* (after goto), not about:blank - localStorage
39
+ // is origin-scoped.
40
+ const syncAutoplayFromEnv = async () => {
41
+ if (process.env.WAYGRAPH_AUTOPLAY === undefined) return;
42
+ const on = process.env.WAYGRAPH_AUTOPLAY === "1";
43
+ await page
44
+ .evaluate((want) => {
45
+ try {
46
+ localStorage.setItem("wg-autoplay", want ? "1" : "0");
47
+ } catch {
48
+ /* private mode / blocked storage */
49
+ }
50
+ }, on)
51
+ .catch(() => {});
52
+ };
53
+ await syncAutoplayFromEnv();
54
+ // Some real Blocks (e.g. zsign-all's login.block.ts) call
55
+ // page.setViewportSize({ width: 1280, height: 720 }) inside their own
56
+ // act() - a hardcoded override for THEIR OWN testing consistency, with no
57
+ // idea an interactive human session is watching. Restoring the size
58
+ // AFTER each step (the first attempt at this) was still just repairing
59
+ // damage after the fact, on a delay, per-step - not the real fix. The
60
+ // real fix is to stop the override from ever landing at all: no-op
61
+ // setViewportSize entirely for the duration of step mode. Blocks that
62
+ // call it don't need it to actually do anything here - their own
63
+ // selectors/layouts still work fine at whatever size the real window
64
+ // already is. Authored ctx.device() still uses __wgRealSetViewportSize.
65
+ page.__wgRealSetViewportSize = page.setViewportSize.bind(page);
66
+ page.setViewportSize = async () => {};
67
+ let resolveNext = null;
68
+ // Render-to-gate race: a slide/step panel's Next button is wired to
69
+ // window.__wgNext BEFORE gate() calls waitForNext() (there is at least
70
+ // one more await - the autoplay-sync evaluate - in between on the
71
+ // presentSlides path). A click landing in that small window used to be
72
+ // dropped silently (resolveNext was still null) - the panel then looked
73
+ // "stuck" forever since gate() started listening AFTER the click already
74
+ // fired and no second click ever came. Queue it instead: the very next
75
+ // waitForNext() call picks up a pending click immediately.
76
+ let pendingNext = null;
77
+ await page.exposeFunction("__wgNext", (edits) => {
78
+ if (resolveNext) {
79
+ const r = resolveNext;
80
+ resolveNext = null;
81
+ r(edits);
82
+ } else {
83
+ pendingNext = edits;
84
+ }
85
+ });
86
+ const waitForNext = () =>
87
+ new Promise((res) => {
88
+ if (pendingNext !== null) {
89
+ const edits = pendingNext;
90
+ pendingNext = null;
91
+ res(edits);
92
+ return;
93
+ }
94
+ resolveNext = res;
95
+ });
96
+ // WAYGRAPH_AUTOPLAY=1 only sets the STARTING checkbox state now - the
97
+ // panel's own "Auto-advance" checkbox can flip it live, mid-run, and a
98
+ // manual click always wins over an in-flight autoplay wait regardless of
99
+ // which way the checkbox is set. That's the hybrid Dan asked for: some
100
+ // steps auto-advance, some get a manual click, toggled as the demo goes,
101
+ // not fixed for the whole run from a single env var.
102
+ const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS
103
+ ? Number(process.env.WAYGRAPH_AUTOPLAY_MS)
104
+ : demoFast
105
+ ? 450
106
+ : 1800;
107
+ const currentAutoplay = () =>
108
+ page
109
+ .evaluate(() => {
110
+ try {
111
+ return localStorage.getItem("wg-autoplay") === "1";
112
+ } catch {
113
+ return false;
114
+ }
115
+ })
116
+ .catch(() => false);
117
+ const gate = async () => {
118
+ const next = waitForNext();
119
+ // WAYGRAPH_FAST_BLOCKS names a block that should blow past its own
120
+ // gates too, not just skip its interaction dwell - otherwise autoplay
121
+ // still stalls the full autoplayMs admiring a step that intentionally
122
+ // ran too fast to watch.
123
+ const ms = demoPaceGateMs(
124
+ pacing.skipTheater ? "blitz" : pacing.demoPace,
125
+ autoplayMs,
126
+ );
127
+ let elapsed = 0;
128
+ for (;;) {
129
+ // Re-read the checkbox EVERY loop tick, not once up front - a human
130
+ // starting a step in manual mode and then checking "Auto-advance"
131
+ // mid-wait must actually start counting down from that moment, not
132
+ // get stuck on whatever mode was live when gate() was first called.
133
+ const auto = await currentAutoplay();
134
+ const slice = auto
135
+ ? Math.min(250, Math.max(50, ms - elapsed))
136
+ : 250; // manual: just a cheap poll tick, waiting on either a click or the box getting checked
137
+ const winner = await Promise.race([
138
+ next.then((edits) => ({ clicked: true, edits })),
139
+ new Promise((res) => setTimeout(() => res({ clicked: false }), slice)),
140
+ ]);
141
+ if (winner.clicked) return winner.edits;
142
+ if (!auto) continue;
143
+ elapsed += slice;
144
+ if (elapsed >= ms) return {};
145
+ }
146
+ };
147
+ const allNames = resolved.map((r) => r.block.name);
148
+ const allDescriptions = resolved.map((r) => r.block.description || "");
149
+ // Deduped, in-order list of every real episode this chain touches - for
150
+ // the episode tab bar. Ad hoc block segments (no named Flow) carry no
151
+ // episodeNumber and never appear here, same gate the "Episode N:"
152
+ // heading already used.
153
+ const allEpisodes = [];
154
+ for (const r of resolved) {
155
+ if (r.episodeNumber && !allEpisodes.some((e) => e.episodeNumber === r.episodeNumber)) {
156
+ allEpisodes.push({ episodeNumber: r.episodeNumber, episodeTitle: r.episodeTitle });
157
+ }
158
+ }
159
+
160
+ let result;
161
+ /** Carry floating todo dock across steps (empty stubBefore must not wipe). */
162
+ let lastTodoDock = undefined;
163
+ /** Author todoDockUi patches (merged); resolved against env each step. */
164
+ let lastTodoDockUiPatch = undefined;
165
+ /** Multi-todo: every dock keyed by todoId (or "_default"), survives navigation. */
166
+ const todoDockById = new Map();
167
+ const pushTodoDockUi = async (patch) => {
168
+ if (patch && typeof patch === "object") {
169
+ lastTodoDockUiPatch = { ...(lastTodoDockUiPatch || {}), ...patch };
170
+ }
171
+ const ui = resolveTodoDockUi(lastTodoDockUiPatch);
172
+ await page
173
+ .evaluate((u) => {
174
+ window.__wgTodoDockUi = u;
175
+ }, ui)
176
+ .catch(() => {});
177
+ };
178
+ const dockRegistryKey = (dock) =>
179
+ dock && dock.id && String(dock.id).trim() ? String(dock.id).trim() : "_default";
180
+ const rememberTodoDock = (dock, sync, parallel) => {
181
+ if (sync === "clear") {
182
+ todoDockById.clear();
183
+ return;
184
+ }
185
+ if (sync === "set" && dock) {
186
+ if (!parallel) todoDockById.clear();
187
+ todoDockById.set(dockRegistryKey(dock), dock);
188
+ return;
189
+ }
190
+ // keep: refresh / re-seed so syncAllTodoDocks never sees an empty map
191
+ // while lastTodoDock still carries (nav wipe + mid-act advance).
192
+ if (sync === "keep" && dock) {
193
+ todoDockById.set(dockRegistryKey(dock), dock);
194
+ }
195
+ };
196
+ const syncAllTodoDocks = async (opts) => {
197
+ const docks = [...todoDockById.values()];
198
+ const parallel = !!(opts && opts.parallel);
199
+ if (!docks.length) {
200
+ // Empty registry: do NOT clear the DOM. renderBefore/After may have
201
+ // just painted from lastTodoDock (keep). Clearing here made todos
202
+ // vanish until the next ctx.todos() set (PIA #15).
203
+ return;
204
+ }
205
+ await page
206
+ .evaluate(
207
+ ({ list, parallel }) => {
208
+ if (!window.__wgSyncTodos) return;
209
+ for (let i = 0; i < list.length; i++) {
210
+ const d = list[i];
211
+ window.__wgSyncTodos({
212
+ sync: "set",
213
+ dock: d,
214
+ todoId: (d && d.id) || null,
215
+ pos: (d && d.pos) || null,
216
+ // First dock replaces; later ones keep siblings when parallel.
217
+ replace: !(parallel && i > 0),
218
+ parallel: parallel && i > 0,
219
+ });
220
+ }
221
+ },
222
+ { list: docks, parallel },
223
+ )
224
+ .catch(() => {});
225
+ };
226
+ /** Carry device / touch fixture across steps (omit = keep, like todos). */
227
+ let lastDevice = undefined;
228
+ for (let i = 0; i < resolved.length; i++) {
229
+ const r = resolved[i];
230
+ // The block breadcrumb is scoped to THIS step's own episode, not the
231
+ // whole chain - within episode 2, step 1 should read as "1st of 2,"
232
+ // not "6th of 7." Falls back to the whole chain when this block has no
233
+ // episode (an ad hoc chain with no named Flows), unchanged from before.
234
+ const episodeBlockIndices = r.episodeNumber
235
+ ? resolved.reduce((acc, x, idx) => {
236
+ if (x.episodeNumber === r.episodeNumber) acc.push(idx);
237
+ return acc;
238
+ }, [])
239
+ : resolved.map((_x, idx) => idx);
240
+ const moduleNames = episodeBlockIndices.map((idx) => allNames[idx]);
241
+ const moduleDescriptions = episodeBlockIndices.map((idx) => allDescriptions[idx]);
242
+ const moduleIndex = episodeBlockIndices.indexOf(i);
243
+ // Where a "Retry Episode" click (see the catch block below) rewinds to -
244
+ // the current episode's own first block, or the whole chain's first
245
+ // block when there's no real episode (episodeBlockIndices degrades to
246
+ // the full resolved array in that case, so this is just 0).
247
+ const episodeStartIndex = episodeBlockIndices[0];
248
+ // True only on an episode's own first block - the one moment worth a
249
+ // gentle "you're here now" signal, not every step inside it.
250
+ const justEnteredEpisode = moduleIndex === 0 && r.episodeNumber !== undefined;
251
+ // Seed THIS segment's own json payload now, not earlier - see the long
252
+ // comment where seedMem closures are built, in main()'s resolution
253
+ // loop. Must run before "keys" below reads mem.get() for the panel
254
+ // display, and before this segment's own first block executes.
255
+ if (r.seedMem) r.seedMem();
256
+ // Reset session state before this block if EITHER: the blanket
257
+ // WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
258
+ // the-first-block stopgap for ad hoc block chains with no real Flow
259
+ // object), OR this specific block is a Flow's own declared entry point
260
+ // (defineFlow(...) wrapped in withSessionReset) reached via a
261
+ // multi-flow chain spec - the proper, opt-in-per-Flow mechanism. Never
262
+ // resets before the very first block - there's no prior state yet.
263
+ //
264
+ // Done HERE, before this step's own "before" panel is even shown - not
265
+ // after the human clicks "Run this step" - so the panel for the new
266
+ // episode appears over an already-fresh page, not the PREVIOUS
267
+ // episode's stale final page (e.g. still showing /dashboard) with the
268
+ // new episode's panel merely floating on top of it ("overlapping",
269
+ // Dan's own word for this). The explicit re-navigate (not just
270
+ // clearing cookies/storage) is what actually makes the page itself
271
+ // look fresh again - clearing storage alone doesn't change what's
272
+ // still rendered on screen.
273
+ if ((clearSession || r.resetSession) && i > 0) {
274
+ await resetPageState(context, page, baseURL);
275
+ // clear() wiped origin storage - re-apply CLI autoplay default.
276
+ await syncAutoplayFromEnv();
277
+ }
278
+ // Episode pace: block > flow > CLI --fast (only when no authored pace).
279
+ // Authoring wins: withDemoPace("slow"|2|4500) keeps slow dwell even under --fast.
280
+ const stepPace = resolveStepDemoPace({
281
+ blockPace: r.block.demoPace,
282
+ flowPace: r.demoPace,
283
+ fastForward: !!r.block.fastForward,
284
+ wasFastForward: !!r.wasFastForward,
285
+ });
286
+ const authoredPace =
287
+ (r.block.demoPace != null && r.block.demoPace !== "") ||
288
+ (r.demoPace != null && r.demoPace !== "");
289
+ const cliFast = demoFast || fastBlockNames.has(r.block.name);
290
+ pacing.demoPace =
291
+ stepPace === "normal" && cliFast && !authoredPace
292
+ ? fastBlockNames.has(r.block.name)
293
+ ? "blitz"
294
+ : "fast"
295
+ : stepPace;
296
+ pacing.gatesFast =
297
+ demoPaceIsFast(pacing.demoPace) ||
298
+ !!r.block.fastForward ||
299
+ !!r.wasFastForward ||
300
+ (!authoredPace && demoFast);
301
+ pacing.gatesSlow = demoPaceIsSlow(pacing.demoPace);
302
+ pacing.skipTheater =
303
+ demoPaceIsBlitz(pacing.demoPace) ||
304
+ fastBlockNames.has(r.block.name) ||
305
+ !!r.block.fastForward ||
306
+ !!r.wasFastForward;
307
+ const fixtures = r.highlightFixtures;
308
+ const flowStyle = r.highlightStyle;
309
+ const stubBeforePhase = await runStubPhase(r.block, "stubBefore", { fixtures, mem });
310
+ if (stubBeforePhase.todoDockUi) {
311
+ await pushTodoDockUi(stubBeforePhase.todoDockUi);
312
+ } else {
313
+ await pushTodoDockUi(undefined);
314
+ }
315
+ stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
316
+ applyHighlightStyleDefaults(h, flowStyle),
317
+ );
318
+ logStubPhaseFixtures("before", {
319
+ ...stubBeforePhase,
320
+ highlights: stubBeforeRef.current.map((h) => ({
321
+ ...h,
322
+ label: formatHighlightCaption(h),
323
+ })),
324
+ });
325
+ const appliedBefore = applyTodoPhase(lastTodoDock, {
326
+ todoSync: stubBeforePhase.todoSync,
327
+ todoDock: stubBeforePhase.todoDock,
328
+ todos: stubBeforePhase.todos,
329
+ todoPos: stubBeforePhase.todoPos,
330
+ });
331
+ lastTodoDock = appliedBefore.dock;
332
+ todoDockRef.current = lastTodoDock;
333
+ rememberTodoDock(appliedBefore.dock, appliedBefore.sync, !!stubBeforePhase.todoParallel);
334
+ const appliedDeviceBefore = applyDevicePhase(lastDevice, {
335
+ deviceSync: stubBeforePhase.deviceSync,
336
+ device: stubBeforePhase.device,
337
+ });
338
+ lastDevice = appliedDeviceBefore.device;
339
+ deviceRef.current = lastDevice;
340
+ if (appliedDeviceBefore.sync !== "keep" || lastDevice) {
341
+ await applyDeviceToPage(page, lastDevice, appliedDeviceBefore.sync);
342
+ }
343
+ const stubBeforeTodos =
344
+ (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
345
+ stubBeforePhase.todos ||
346
+ [];
347
+ const overlayTitle =
348
+ (stubBeforePhase.title && String(stubBeforePhase.title).trim()) ||
349
+ (r.episodeTitle && String(r.episodeTitle).trim()) ||
350
+ title ||
351
+ "waygraph demo";
352
+ const overlayTodoPos =
353
+ (lastTodoDock && lastTodoDock.pos) || stubBeforePhase.todoPos || undefined;
354
+ const isNavBlock = r.block.__waygraphKind === "nav";
355
+ const autoNow = await currentAutoplay();
356
+ // Video / --mini: compact pill. Never pass false - Hide/localStorage wins.
357
+ const forceCollapsed =
358
+ !!process.env.WAYGRAPH_VIDEO ||
359
+ process.env.WAYGRAPH_MINI === "1" ||
360
+ process.env.WAYGRAPH_STEPPER_MINI === "1"
361
+ ? true
362
+ : undefined;
363
+ const requires = r.block.requires ?? [];
364
+ const keys = requires.map((k) => {
365
+ let value = "<not yet set>";
366
+ try {
367
+ value = JSON.stringify(mem.get(k));
368
+ } catch {
369
+ // not written yet - shown as a placeholder, not a crash
370
+ }
371
+ return { name: k.name, value };
372
+ });
373
+ const paceSpeak = paceSpeakFields(pacing.demoPace, autoplayMs);
374
+ if (process.env.WAYGRAPH_JSON !== "1") {
375
+ demoLog(
376
+ "step " +
377
+ (i + 1) +
378
+ "/" +
379
+ resolved.length +
380
+ " " +
381
+ r.block.name +
382
+ " · " +
383
+ paceSpeak.paceLabel +
384
+ (r.episodeNumber ? " · episode " + r.episodeNumber : ""),
385
+ );
386
+ demoLog(
387
+ " gate~" +
388
+ autoplayMs +
389
+ "ms skipTheater=" +
390
+ !!pacing.skipTheater +
391
+ " gatesFast=" +
392
+ !!pacing.gatesFast,
393
+ );
394
+ demoLog(" " + summarizeDevice(lastDevice));
395
+ const phaseZoom =
396
+ stubBeforePhase.zoom != null && Number(stubBeforePhase.zoom) > 0
397
+ ? Number(stubBeforePhase.zoom)
398
+ : null;
399
+ const phaseZoomOut =
400
+ stubBeforePhase.zoomOut !== undefined ? !!stubBeforePhase.zoomOut : null;
401
+ demoLog(
402
+ " zoom=" +
403
+ (phaseZoom != null ? phaseZoom + "x" : "default") +
404
+ (phaseZoomOut != null ? " zoomOut=" + phaseZoomOut : "") +
405
+ " (StubCtx / API; live chip top-left when ring zoom >1)",
406
+ );
407
+ logTodoDockFull(lastTodoDock, " before");
408
+ demoLog(" todoSync=" + appliedBefore.sync);
409
+ }
410
+ await renderBeforeStep(page, {
411
+ index: i,
412
+ total: resolved.length,
413
+ blockName: r.block.name,
414
+ description: r.block.description || "",
415
+ keys,
416
+ allNames: moduleNames,
417
+ allDescriptions: moduleDescriptions,
418
+ moduleIndex,
419
+ allEpisodes,
420
+ justEnteredEpisode,
421
+ title: overlayTitle,
422
+ todoPos: overlayTodoPos,
423
+ episodeNumber: r.episodeNumber,
424
+ episodeTitle: r.episodeTitle,
425
+ stepperMode,
426
+ forceCollapsed,
427
+ todos: stubBeforeTodos,
428
+ todoDock: lastTodoDock,
429
+ todoSync: appliedBefore.sync,
430
+ todoId: lastTodoDock && lastTodoDock.id,
431
+ ...paceSpeak,
432
+ });
433
+ // Re-paint dock(s) - renderBeforeStep syncs the active one; this enforces replace.
434
+ await syncAllTodoDocks({ parallel: !!stubBeforePhase.todoParallel });
435
+ if (process.env.WAYGRAPH_JSON !== "1") {
436
+ const docks = await probeTodoDocksOnPage(page);
437
+ if (!docks.length && lastTodoDock) {
438
+ demoLog(" WARN todos authored but dock-dom empty (not painted?)");
439
+ } else if (docks.length) {
440
+ demoLog(
441
+ " dock-dom " +
442
+ docks
443
+ .map(
444
+ (d) =>
445
+ d.key +
446
+ "@" +
447
+ d.pos +
448
+ " " +
449
+ d.left +
450
+ "," +
451
+ d.top +
452
+ " " +
453
+ d.w +
454
+ "x" +
455
+ d.h +
456
+ (d.onScreen ? "" : " OFFSCREEN") +
457
+ " n=" +
458
+ d.n +
459
+ (d.current ? ' cur="' + d.current + '"' : ""),
460
+ )
461
+ .join(" | "),
462
+ );
463
+ }
464
+ }
465
+ const edits = await gate();
466
+ for (const k of requires) {
467
+ if (edits[k.name] !== undefined) {
468
+ try {
469
+ mem.set(k, JSON.parse(edits[k.name]));
470
+ } catch {
471
+ // left as-authored if the human's edit isn't valid JSON
472
+ }
473
+ }
474
+ }
475
+ const recordingVideo = !!process.env.WAYGRAPH_VIDEO;
476
+ await markStepRunning(page, {});
477
+ // NavBlock click-nav: label the upcoming Locator.click demo cursor as
478
+ // "nav: <block>" so pia/demo watchers see cursor+pulse on click nav
479
+ // (not only on regular Block clicks).
480
+ const navClick = r.block.__waygraphNavClick;
481
+ if (r.block.__waygraphKind === "nav" && navClick !== undefined) {
482
+ await page
483
+ .evaluate((label) => {
484
+ window.__wgPendingNavClickLabel = label;
485
+ }, "nav: " + (r.block.name || "click"))
486
+ .catch(() => {});
487
+ } else {
488
+ await page
489
+ .evaluate(() => {
490
+ delete window.__wgPendingNavClickLabel;
491
+ })
492
+ .catch(() => {});
493
+ }
494
+ const stepFlow = engine.defineFlow([start, r.block, end]);
495
+ // { closeOnFinish: false } makes Flow.run return { result, page }, not
496
+ // the plain checkpoint - destructure it, don't treat the wrapper as the
497
+ // checkpoint itself (caught via the standalone verify script: this used
498
+ // to serialize the whole { result, page } object into the panel/log).
499
+ let stepOutcome;
500
+ try {
501
+ stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
502
+ } catch (err) {
503
+ await page
504
+ .evaluate(() => {
505
+ delete window.__wgPendingNavClickLabel;
506
+ })
507
+ .catch(() => {});
508
+ // stubOnError + error/expected panel BEFORE acknowledging (PIA RFC).
509
+ const errEdits = await presentFailPanel(page, {
510
+ block: r.block,
511
+ fixtures: r.highlightFixtures,
512
+ highlightStyle: r.highlightStyle,
513
+ error: err,
514
+ index: i,
515
+ total: resolved.length,
516
+ blockName: r.block.name,
517
+ message: err && err.message ? err.message : String(err),
518
+ allNames: moduleNames,
519
+ allDescriptions: moduleDescriptions,
520
+ moduleIndex,
521
+ allEpisodes,
522
+ title,
523
+ episodeNumber: r.episodeNumber,
524
+ episodeTitle: r.episodeTitle,
525
+ expectedFailureReason: r.expectedFailureReason,
526
+ stepperMode,
527
+ gatesFast: pacing.gatesFast,
528
+ demoPace: pacing.demoPace,
529
+ gate,
530
+ mem,
531
+ });
532
+ if (errEdits && errEdits.__wgRetry) {
533
+ await resetPageState(context, page, baseURL);
534
+ await syncAutoplayFromEnv();
535
+ i = episodeStartIndex - 1;
536
+ continue;
537
+ }
538
+ // withExpectedFailure episode (e.g. locked_out_user) - throwing here is
539
+ // the demo working, not a broken run.
540
+ if (r.expectedFailureReason) {
541
+ if (process.env.WAYGRAPH_JSON !== "1") {
542
+ console.log(
543
+ "waygraph: expected failure on " + r.block.name + " - " + r.expectedFailureReason,
544
+ );
545
+ }
546
+ break;
547
+ }
548
+ throw err;
549
+ }
550
+ await page
551
+ .evaluate(() => {
552
+ delete window.__wgPendingNavClickLabel;
553
+ })
554
+ .catch(() => {});
555
+ result = stepOutcome.result;
556
+ // Mid-act Method fill/click advances live on todoDockRef - fold that
557
+ // back into lastTodoDock before stubAfter / next-block keep, or the
558
+ // next step reverts to the pre-act (blank / index-0) checklist.
559
+ if (todoDockRef.current) lastTodoDock = todoDockRef.current;
560
+ // withExpectedFailure last block that SUCCEEDS on the intentional fail
561
+ // branch (e.g. submit-login -> LoginPage + error banner). Still show
562
+ // stubOnError rings + amber "expected outcome" panel - branching no
563
+ // longer throws, but the demo must not skip the educational overlay.
564
+ if (r.expectedFailureReason) {
565
+ const errEdits = await presentFailPanel(page, {
566
+ block: r.block,
567
+ fixtures: r.highlightFixtures,
568
+ highlightStyle: r.highlightStyle,
569
+ error: undefined,
570
+ out: result,
571
+ index: i,
572
+ total: resolved.length,
573
+ blockName: r.block.name,
574
+ message:
575
+ "Resolved " +
576
+ (result && result.__state ? result.__state : JSON.stringify(result)) +
577
+ " (expected failure path)",
578
+ allNames: moduleNames,
579
+ allDescriptions: moduleDescriptions,
580
+ moduleIndex,
581
+ allEpisodes,
582
+ title,
583
+ episodeNumber: r.episodeNumber,
584
+ episodeTitle: r.episodeTitle,
585
+ expectedFailureReason: r.expectedFailureReason,
586
+ stepperMode,
587
+ gatesFast: pacing.gatesFast,
588
+ demoPace: pacing.demoPace,
589
+ gate,
590
+ mem,
591
+ });
592
+ if (errEdits && errEdits.__wgRetry) {
593
+ await resetPageState(context, page, baseURL);
594
+ await syncAutoplayFromEnv();
595
+ i = episodeStartIndex - 1;
596
+ continue;
597
+ }
598
+ if (process.env.WAYGRAPH_JSON !== "1") {
599
+ console.log(
600
+ "waygraph: expected failure on " + r.block.name + " - " + r.expectedFailureReason,
601
+ );
602
+ }
603
+ break;
604
+ }
605
+ const fixturesAfter = r.highlightFixtures;
606
+ const slides = resolveSlides(r.block, { out: result, fixtures: fixturesAfter }).map((s) =>
607
+ applyHighlightStyleDefaults(s, r.highlightStyle),
608
+ );
609
+ if (slides.length > 0) {
610
+ await presentSlides(page, slides, gate, {
611
+ title,
612
+ blockName: r.block.name,
613
+ fast: pacing.gatesFast,
614
+ pace: pacing.demoPace,
615
+ episodeNumber: r.episodeNumber,
616
+ episodeTitle: r.episodeTitle,
617
+ autoplayMs,
618
+ });
619
+ }
620
+ let highlights;
621
+ let stubAfterTodos = [];
622
+ let afterOverlayTitle = overlayTitle;
623
+ let afterTodoPos = overlayTodoPos;
624
+ let appliedAfter = { dock: lastTodoDock, sync: "keep" };
625
+ let appliedDeviceAfter = { device: lastDevice, sync: "keep" };
626
+ let afterTodoParallel = false;
627
+ if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
628
+ const afterPhase = await runStubPhase(r.block, "stubAfter", {
629
+ out: result,
630
+ fixtures: fixturesAfter,
631
+ mem,
632
+ });
633
+ if (afterPhase.todoDockUi) {
634
+ await pushTodoDockUi(afterPhase.todoDockUi);
635
+ }
636
+ logStubPhaseFixtures("after", {
637
+ ...afterPhase,
638
+ highlights: (afterPhase.highlights || []).map((h) => {
639
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
640
+ return { ...styled, label: formatHighlightCaption(styled) };
641
+ }),
642
+ });
643
+ afterTodoParallel = !!afterPhase.todoParallel;
644
+ appliedAfter = applyTodoPhase(lastTodoDock, {
645
+ todoSync: afterPhase.todoSync,
646
+ todoDock: afterPhase.todoDock,
647
+ todos: afterPhase.todos,
648
+ todoPos: afterPhase.todoPos,
649
+ });
650
+ lastTodoDock = appliedAfter.dock;
651
+ todoDockRef.current = lastTodoDock;
652
+ rememberTodoDock(appliedAfter.dock, appliedAfter.sync, afterTodoParallel);
653
+ appliedDeviceAfter = applyDevicePhase(lastDevice, {
654
+ deviceSync: afterPhase.deviceSync,
655
+ device: afterPhase.device,
656
+ });
657
+ lastDevice = appliedDeviceAfter.device;
658
+ deviceRef.current = lastDevice;
659
+ if (appliedDeviceAfter.sync !== "keep" || (appliedDeviceAfter.sync === "set" && lastDevice)) {
660
+ await applyDeviceToPage(page, lastDevice, appliedDeviceAfter.sync);
661
+ }
662
+ stubAfterTodos =
663
+ (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
664
+ afterPhase.todos ||
665
+ [];
666
+ if (afterPhase.title && String(afterPhase.title).trim()) {
667
+ afterOverlayTitle = String(afterPhase.title).trim();
668
+ }
669
+ if (afterPhase.todoPos) afterTodoPos = afterPhase.todoPos;
670
+ if (lastTodoDock && lastTodoDock.pos) afterTodoPos = lastTodoDock.pos;
671
+ highlights = afterPhase.highlights.map((h) => {
672
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
673
+ return {
674
+ selector: styled.selector,
675
+ label: formatHighlightCaption(styled),
676
+ duration: styled.duration,
677
+ fastMode: styled.fastMode,
678
+ tone: styled.tone,
679
+ size: styled.size,
680
+ weight: styled.weight,
681
+ zoom: styled.zoom,
682
+ zoomOut: styled.zoomOut,
683
+ focus: !!styled.focus,
684
+ tone: styled.tone,
685
+ color: styled.color,
686
+ detail: styled.detail,
687
+ };
688
+ });
689
+ } else {
690
+ highlights = extractVerifyHighlights(r.block, result.__state).map((h) =>
691
+ applyHighlightStyleDefaults(h, r.highlightStyle),
692
+ );
693
+ }
694
+ await renderAfterStep(page, {
695
+ index: i,
696
+ total: resolved.length,
697
+ blockName: r.block.name,
698
+ result,
699
+ resultTag: JSON.stringify(result),
700
+ highlights,
701
+ gatesFast: pacing.gatesFast,
702
+ pace: pacing.demoPace,
703
+ ...paceSpeakFields(pacing.demoPace, autoplayMs),
704
+ isLast: i === resolved.length - 1,
705
+ allNames: moduleNames,
706
+ allDescriptions: moduleDescriptions,
707
+ moduleIndex,
708
+ allEpisodes,
709
+ title: afterOverlayTitle,
710
+ todoPos: afterTodoPos,
711
+ episodeNumber: r.episodeNumber,
712
+ episodeTitle: r.episodeTitle,
713
+ stepperMode,
714
+ todos: stubAfterTodos,
715
+ todoDock: lastTodoDock,
716
+ todoSync: appliedAfter.sync,
717
+ todoId: lastTodoDock && lastTodoDock.id,
718
+ todoDockRef,
719
+ forceCollapsed:
720
+ !!process.env.WAYGRAPH_VIDEO ||
721
+ process.env.WAYGRAPH_MINI === "1" ||
722
+ process.env.WAYGRAPH_STEPPER_MINI === "1"
723
+ ? true
724
+ : undefined,
725
+ });
726
+ await syncAllTodoDocks({ parallel: afterTodoParallel });
727
+ // Ring cycle / stubAfter may have advanced the dock - persist for next block.
728
+ if (todoDockRef.current) {
729
+ lastTodoDock = todoDockRef.current;
730
+ rememberTodoDock(todoDockRef.current, "set", afterTodoParallel);
731
+ }
732
+ if (process.env.WAYGRAPH_JSON !== "1") {
733
+ demoLog(
734
+ " after " +
735
+ summarizeDevice(lastDevice) +
736
+ " deviceSync=" +
737
+ appliedDeviceAfter.sync,
738
+ );
739
+ logTodoDockFull(lastTodoDock, " after");
740
+ demoLog(" after todoSync=" + appliedAfter.sync);
741
+ const docksAfter = await probeTodoDocksOnPage(page);
742
+ if (!docksAfter.length && lastTodoDock && appliedAfter.sync !== "clear") {
743
+ demoLog(" WARN after: todos authored but dock-dom empty");
744
+ } else if (docksAfter.length) {
745
+ demoLog(
746
+ " after dock-dom " +
747
+ docksAfter
748
+ .map(
749
+ (d) =>
750
+ d.key +
751
+ "@" +
752
+ d.pos +
753
+ " " +
754
+ d.left +
755
+ "," +
756
+ d.top +
757
+ " " +
758
+ d.w +
759
+ "x" +
760
+ d.h +
761
+ (d.onScreen ? "" : " OFFSCREEN") +
762
+ " n=" +
763
+ d.n +
764
+ (d.current ? ' cur="' + d.current + '"' : ""),
765
+ )
766
+ .join(" | "),
767
+ );
768
+ } else if (appliedAfter.sync === "clear") {
769
+ demoLog(" after dock-dom cleared (expected)");
770
+ }
771
+ }
772
+ await gate();
773
+ }
774
+ await teardownOverlay(page);
775
+ return result;
776
+ }