waygraph 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,9 +101,13 @@ so the caller can keep driving it - hands a run tab back instead of losing it. S
101
101
  "Recipes" for capture-on-popup, which stays a documented pattern until a second real use
102
102
  case promotes it to an engine API.
103
103
 
104
+ ```bash
105
+ npx create-waygraph my-project && cd my-project && npm install && npm test
106
+ ```
107
+
104
108
  [`create-waygraph`](https://github.com/deviate-dv8/create-waygraph) scaffolds a new
105
109
  project (package.json, tsconfig, playwright.config, one working example flow) in one
106
- command - built and proven end-to-end, not yet published to npm.
110
+ command, published on npm - the above runs a real green test with no manual edits.
107
111
 
108
112
  Deliberately **not yet implemented** (tracked on the project board):
109
113
  - Interstitials/Watchers - background overlay handling (cookie banners, popups).
package/dist/cli.js CHANGED
@@ -251,6 +251,31 @@ async function findBlock(projectDir, ref) {
251
251
  throw new Error("waygraph chain: no Block named \\"" + ref + "\\" found under " + projectDir);
252
252
  }
253
253
 
254
+ /**
255
+ * Finds an already-defined Flow by its export name (e.g. loginFlow) among
256
+ * the project's *.flow.ts files - "I already have this wired up, just point
257
+ * the stepper at it, no chain spec to hand-write." Flows have no runtime
258
+ * .name of their own (unlike Blocks), so this only matches by export
259
+ * identifier. Returns null (not a throw) when nothing matches - the caller
260
+ * falls back to ordinary block-chain spec parsing.
261
+ */
262
+ async function findFlow(projectDir, flowName) {
263
+ const files = walkDir(projectDir, /\\.flow\\.ts$/);
264
+ for (const file of files) {
265
+ let mod;
266
+ try {
267
+ mod = await import("file://" + file);
268
+ } catch {
269
+ continue;
270
+ }
271
+ const candidate = mod[flowName];
272
+ if (candidate && typeof candidate.run === "function") {
273
+ return candidate;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
254
279
  function parseChainSpec(spec) {
255
280
  return spec
256
281
  .split(/\\bthen\\b/)
@@ -309,43 +334,475 @@ function seedMemForBlock(mem, resolved, json) {
309
334
  }
310
335
  }
311
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // WAYGRAPH_STEP=1 - human-verification overlay: one Block at a time, a
339
+ // browser-injected panel showing/editing that Block's MemKeys, a "Run this
340
+ // step" gate, then a highlight ring over whatever its own verify Traits just
341
+ // confirmed, then a "Next" gate before moving on. Ring CSS borrowed from
342
+ // help-center-clip-engine's video-pipeline overlay (same purple ring +
343
+ // label-under-box language, minus everything camera/narration-specific).
344
+ // Purely CLI-level orchestration - no engine changes, no change to the
345
+ // non-STEP path above.
346
+ const RING_CSS =
347
+ "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
348
+ "border:2.5px solid #7C3AED;border-radius:10px;" +
349
+ "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
350
+ "#wg-ring::after{content:attr(data-label);position:absolute;left:0;top:calc(100% + 8px);" +
351
+ "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
352
+ "font:600 12px/1.2 system-ui,sans-serif;}" +
353
+ "#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
354
+ "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
355
+ "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
356
+ "padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
357
+ "opacity:0;transition:opacity .25s ease;}" +
358
+ "#wg-panel.wg-in{opacity:1;}" +
359
+ "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
360
+ "#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
361
+ "letter-spacing:.02em;text-transform:uppercase;}" +
362
+ "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
363
+ "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
364
+ "#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
365
+ "#wg-modules .wg-mod{padding:3px 9px;border-radius:6px;font:600 11px system-ui,sans-serif;}" +
366
+ "#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
367
+ "#wg-modules .wg-mod-current{background:#7C3AED;color:#fff;}" +
368
+ "#wg-modules .wg-mod-upcoming{background:transparent;color:#5a4a80;border:1px solid #3a2a60;}" +
369
+ "#wg-panel .wg-key{margin:8px 0;}" +
370
+ "#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
371
+ "#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
372
+ "border:1px solid #4b2a80;border-radius:8px;padding:6px 8px;font:12px/1.3 monospace;resize:vertical;}" +
373
+ "#wg-panel button{margin-top:10px;background:#7C3AED;color:#fff;border:none;border-radius:8px;" +
374
+ "padding:8px 16px;font:600 13px system-ui,sans-serif;cursor:pointer;}" +
375
+ "#wg-panel button:hover{background:#6b2fd6;}" +
376
+ "#wg-panel .wg-result{font:12px/1.4 monospace;background:#0f0620;border-radius:8px;padding:8px;" +
377
+ "margin:8px 0;white-space:pre-wrap;}" +
378
+ "#wg-panel .wg-result-pretty{font:600 14px/1.4 system-ui,sans-serif;}" +
379
+ "#wg-panel .wg-toggle{display:flex;gap:4px;margin:0 0 4px;}" +
380
+ "#wg-panel .wg-toggle button{margin:0;padding:3px 10px;font:600 11px system-ui,sans-serif;" +
381
+ "background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
382
+ "#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}";
383
+
384
+ async function installOverlay(page) {
385
+ await page.addStyleTag({ content: RING_CSS }).catch(() => {});
386
+ await page
387
+ .evaluate(() => {
388
+ if (!document.getElementById("wg-ring")) {
389
+ const ring = document.createElement("div");
390
+ ring.id = "wg-ring";
391
+ document.documentElement.appendChild(ring);
392
+ }
393
+ })
394
+ .catch(() => {});
395
+ }
396
+
397
+ async function renderBeforeStep(page, info) {
398
+ await installOverlay(page);
399
+ // A ring left highlighting the PREVIOUS step's element (and its live
400
+ // resize/scroll tracker) shouldn't linger once a new step's own panel is
401
+ // up - only relevant when a Block's act() doesn't navigate away, since a
402
+ // real navigation already wipes document.documentElement's children.
403
+ await page
404
+ .evaluate(() => {
405
+ if (window.__wgRingTrack) {
406
+ window.removeEventListener("resize", window.__wgRingTrack);
407
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
408
+ window.__wgRingTrack = null;
409
+ }
410
+ const ring = document.getElementById("wg-ring");
411
+ if (ring) ring.style.opacity = "0";
412
+ })
413
+ .catch(() => {});
414
+ await page
415
+ .evaluate((info) => {
416
+ const old = document.getElementById("wg-panel");
417
+ if (old) old.remove();
418
+ const panel = document.createElement("div");
419
+ panel.id = "wg-panel";
420
+ const pct = Math.round((info.index / info.total) * 100);
421
+ const modulesHtml = info.allNames
422
+ .map((name, idx) => {
423
+ const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
424
+ return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
425
+ })
426
+ .join("");
427
+ let html =
428
+ "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
429
+ "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
430
+ "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>";
431
+ if (info.keys.length === 0) {
432
+ html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
433
+ }
434
+ for (const k of info.keys) {
435
+ html +=
436
+ "<div class=\\"wg-key\\"><label>" + k.name + "</label>" +
437
+ "<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
438
+ k.value.replace(/</g, "&lt;") + "</textarea></div>";
439
+ }
440
+ html += info.autoplay
441
+ ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
442
+ : "<button id=\\"wg-run\\">Run this step \\u25B6</button>";
443
+ panel.innerHTML = html;
444
+ document.documentElement.appendChild(panel);
445
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
446
+ const runBtn = document.getElementById("wg-run");
447
+ if (runBtn) {
448
+ runBtn.addEventListener("click", () => {
449
+ const edits = {};
450
+ panel.querySelectorAll("textarea[data-key]").forEach((el) => {
451
+ edits[el.getAttribute("data-key")] = el.value;
452
+ });
453
+ window.__wgNext(edits);
454
+ });
455
+ }
456
+ }, info)
457
+ .catch(() => {});
458
+ }
459
+
460
+ async function renderAfterStep(page, info) {
461
+ await installOverlay(page);
462
+ // Tracks the highlighted element live (recomputes on resize/scroll)
463
+ // instead of a one-time Node-side boundingBox() snapshot - a resized
464
+ // window used to leave the ring frozen at its stale old position.
465
+ // Only the first recovered selector is drawn (one ring); best-effort - a
466
+ // selector that never resolves just leaves the ring hidden.
467
+ await page
468
+ .evaluate((highlights) => {
469
+ if (window.__wgRingTrack) {
470
+ window.removeEventListener("resize", window.__wgRingTrack);
471
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
472
+ window.__wgRingTrack = null;
473
+ }
474
+ const ring = document.getElementById("wg-ring");
475
+ if (!ring) return;
476
+ const h = highlights[0];
477
+ if (!h) {
478
+ ring.style.opacity = "0";
479
+ return;
480
+ }
481
+ const reposition = () => {
482
+ const el = document.querySelector(h.selector);
483
+ if (!el) {
484
+ ring.style.opacity = "0";
485
+ return;
486
+ }
487
+ const box = el.getBoundingClientRect();
488
+ ring.style.left = box.x - 6 + "px";
489
+ ring.style.top = box.y - 6 + "px";
490
+ ring.style.width = box.width + 12 + "px";
491
+ ring.style.height = box.height + 12 + "px";
492
+ ring.setAttribute("data-label", h.label);
493
+ ring.style.opacity = "1";
494
+ };
495
+ reposition();
496
+ window.__wgRingTrack = reposition;
497
+ window.addEventListener("resize", reposition);
498
+ window.addEventListener("scroll", reposition, true);
499
+ }, info.highlights)
500
+ .catch(() => {});
501
+ await page
502
+ .evaluate((info) => {
503
+ const old = document.getElementById("wg-panel");
504
+ if (old) old.remove();
505
+ const panel = document.createElement("div");
506
+ panel.id = "wg-panel";
507
+ const pct = Math.round(((info.index + 1) / info.total) * 100);
508
+ const heading = info.isLast
509
+ ? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
510
+ : "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " done";
511
+ const buttonLabel = info.isLast ? "Finish" : "Next \\u25B6";
512
+ const modulesHtml = info.allNames
513
+ .map((name, idx) => {
514
+ const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
515
+ return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
516
+ })
517
+ .join("");
518
+ // QA-friendly by default ("LoginSuccess" -> "Login Success") - raw
519
+ // JSON is one click away for whoever actually wants __state.
520
+ const stateTag = info.result && info.result.__state ? info.result.__state : "";
521
+ const prettyText = stateTag
522
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
523
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") || info.resultTag;
524
+ const pretty = window.__wgPretty !== false;
525
+ const resultHtml =
526
+ "<div class=\\"wg-toggle\\">" +
527
+ "<button class=\\"wg-toggle-btn" + (pretty ? " wg-active" : "") + "\\" data-mode=\\"pretty\\">Pretty</button>" +
528
+ "<button class=\\"wg-toggle-btn" + (pretty ? "" : " wg-active") + "\\" data-mode=\\"json\\">JSON</button>" +
529
+ "</div>" +
530
+ "<div class=\\"wg-result wg-result-pretty\\" style=\\"display:" + (pretty ? "block" : "none") + "\\">" +
531
+ prettyText + "</div>" +
532
+ "<div class=\\"wg-result wg-result-json\\" style=\\"display:" + (pretty ? "none" : "block") + "\\">" +
533
+ info.resultTag + "</div>";
534
+ const gateHtml = info.autoplay
535
+ ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
536
+ : "<button id=\\"wg-run\\">" + buttonLabel + "</button>";
537
+ panel.innerHTML =
538
+ "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
539
+ "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
540
+ "<h3>" + heading + "</h3>" +
541
+ resultHtml +
542
+ gateHtml;
543
+ document.documentElement.appendChild(panel);
544
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
545
+ panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
546
+ btn.addEventListener("click", () => {
547
+ const wantPretty = btn.getAttribute("data-mode") === "pretty";
548
+ window.__wgPretty = wantPretty;
549
+ panel.querySelectorAll(".wg-toggle-btn").forEach((b) => b.classList.remove("wg-active"));
550
+ btn.classList.add("wg-active");
551
+ panel.querySelector(".wg-result-pretty").style.display = wantPretty ? "block" : "none";
552
+ panel.querySelector(".wg-result-json").style.display = wantPretty ? "none" : "block";
553
+ });
554
+ });
555
+ const runBtn = document.getElementById("wg-run");
556
+ if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
557
+ }, info)
558
+ .catch(() => {});
559
+ }
560
+
561
+ /**
562
+ * Recovers a DOM selector from a built-in Trait's own .name string -
563
+ * visible(sel) / text-equals(sel, "...") - since Trait.check itself is
564
+ * an opaque closure with no selector field of its own. Best-effort only: a
565
+ * hand-written bespoke Trait, or url-matches(...) (no DOM target), yields
566
+ * nothing to highlight, which is fine - the panel still shows the result.
567
+ */
568
+ function extractVerifyHighlights(block, resultTag) {
569
+ let verify = block.instruction && block.instruction.verify;
570
+ if (typeof verify === "function") {
571
+ try {
572
+ verify = verify({ __state: resultTag });
573
+ } catch {
574
+ verify = [];
575
+ }
576
+ }
577
+ if (!Array.isArray(verify)) return [];
578
+ const highlights = [];
579
+ for (const t of verify) {
580
+ const name = t && t.name;
581
+ if (typeof name !== "string") continue;
582
+ let m = /^visible\\((.+)\\)$/.exec(name);
583
+ if (m) {
584
+ highlights.push({ selector: m[1], label: name });
585
+ continue;
586
+ }
587
+ m = /^text-equals\\((.+?),\\s*"/.exec(name);
588
+ if (m) {
589
+ highlights.push({ selector: m[1], label: name });
590
+ continue;
591
+ }
592
+ }
593
+ return highlights;
594
+ }
595
+
596
+ /**
597
+ * Patches Locator.prototype.fill (via any real locator's own prototype
598
+ * chain - Playwright doesn't export the class directly) so every fill(),
599
+ * regardless of how the Block built that locator (page.locator, chained
600
+ * .getByLabel off a scoped form locator, etc.), highlights the real target
601
+ * first, then types it out character by character instead of snapping the
602
+ * whole value in - "when something is written from mem, it should
603
+ * highlight then slowly input." One-time patch (idempotent - guarded so a
604
+ * multi-step chain doesn't re-wrap an already-wrapped fill).
605
+ */
606
+ function instrumentFillHighlighting(page) {
607
+ const proto = Object.getPrototypeOf(page.locator("html"));
608
+ if (proto.__wgFillPatched) return;
609
+ proto.__wgFillPatched = true;
610
+ const originalFill = proto.fill;
611
+ proto.fill = async function (value, options) {
612
+ try {
613
+ await installOverlay(page);
614
+ const box = await this.boundingBox();
615
+ if (box) {
616
+ await page.evaluate(
617
+ (box) => {
618
+ const ring = document.getElementById("wg-ring");
619
+ if (!ring) return;
620
+ ring.style.left = box.x - 6 + "px";
621
+ ring.style.top = box.y - 6 + "px";
622
+ ring.style.width = box.width + 12 + "px";
623
+ ring.style.height = box.height + 12 + "px";
624
+ ring.setAttribute("data-label", "writing from mem");
625
+ ring.style.opacity = "1";
626
+ },
627
+ box,
628
+ );
629
+ await new Promise((res) => setTimeout(res, 200));
630
+ }
631
+ } catch {
632
+ // best-effort - element not visible/attached yet is not this
633
+ // instrumentation's problem, the real fill below still runs
634
+ }
635
+ try {
636
+ // NOT this.clear() - Locator.clear() is itself implemented as
637
+ // fill(""), and since fill is patched on the shared prototype, that
638
+ // call would resolve back to THIS same patched function and recurse
639
+ // forever. Call the real original fill directly to clear instead.
640
+ await originalFill.call(this, "", { timeout: options && options.timeout });
641
+ return await this.pressSequentially(String(value), { delay: 45, timeout: options && options.timeout });
642
+ } catch {
643
+ // pressSequentially unsupported on this element (e.g. a
644
+ // contenteditable div, or a locator .fill() genuinely needs to
645
+ // handle specially) - fall back to the real, unpatched fill.
646
+ return await originalFill.call(this, value, options);
647
+ }
648
+ };
649
+ }
650
+
651
+ async function runStepMode(engine, start, end, context, page, mem, resolved) {
652
+ instrumentFillHighlighting(page);
653
+ let resolveNext = null;
654
+ await page.exposeFunction("__wgNext", (edits) => {
655
+ if (resolveNext) {
656
+ const r = resolveNext;
657
+ resolveNext = null;
658
+ r(edits);
659
+ }
660
+ });
661
+ const waitForNext = () => new Promise((res) => (resolveNext = res));
662
+ // WAYGRAPH_AUTOPLAY=1 - hands-off, matching the existing
663
+ // scripts/waygraph-demo.mjs pattern (plain slowMo, no clicks, just watch)
664
+ // but keeping this overlay's progress bar/module breadcrumb/highlight.
665
+ const autoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
666
+ const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
667
+ const gate = () => (autoplay ? new Promise((res) => setTimeout(() => res({}), autoplayMs)) : waitForNext());
668
+ const allNames = resolved.map((r) => r.block.name);
669
+
670
+ let result;
671
+ for (let i = 0; i < resolved.length; i++) {
672
+ const r = resolved[i];
673
+ const requires = r.block.requires ?? [];
674
+ const keys = requires.map((k) => {
675
+ let value = "<not yet set>";
676
+ try {
677
+ value = JSON.stringify(mem.get(k));
678
+ } catch {
679
+ // not written yet - shown as a placeholder, not a crash
680
+ }
681
+ return { name: k.name, value };
682
+ });
683
+ await renderBeforeStep(page, { index: i, total: resolved.length, blockName: r.block.name, keys, allNames, autoplay });
684
+ const edits = await gate();
685
+ for (const k of requires) {
686
+ if (edits[k.name] !== undefined) {
687
+ try {
688
+ mem.set(k, JSON.parse(edits[k.name]));
689
+ } catch {
690
+ // left as-authored if the human's edit isn't valid JSON
691
+ }
692
+ }
693
+ }
694
+ const stepFlow = engine.defineFlow([start, r.block, end]);
695
+ // { closeOnFinish: false } makes Flow.run return { result, page }, not
696
+ // the plain checkpoint - destructure it, don't treat the wrapper as the
697
+ // checkpoint itself (caught via the standalone verify script: this used
698
+ // to serialize the whole { result, page } object into the panel/log).
699
+ const stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
700
+ result = stepOutcome.result;
701
+ const highlights = extractVerifyHighlights(r.block, result.__state);
702
+ await renderAfterStep(page, {
703
+ index: i,
704
+ total: resolved.length,
705
+ blockName: r.block.name,
706
+ result,
707
+ resultTag: JSON.stringify(result),
708
+ highlights,
709
+ isLast: i === resolved.length - 1,
710
+ allNames,
711
+ autoplay,
712
+ });
713
+ await gate();
714
+ }
715
+ return result;
716
+ }
717
+
312
718
  async function main() {
313
719
  const projectDir = process.argv[2];
314
720
  const spec = process.argv[3];
315
- const segments = parseChainSpec(spec);
316
- if (segments.length === 0) {
317
- throw new Error("waygraph chain: empty spec - give at least one block name");
318
- }
319
721
  const { connect, MemPage, Engine, start, end } = await import("waygraph");
320
722
  const mem = new MemPage();
321
- const resolved = [];
322
- for (const seg of segments) {
323
- const r = await findBlock(projectDir, seg.ref);
324
- seedMemForBlock(mem, r, seg.json);
325
- resolved.push(r);
723
+ let resolved = [];
724
+ // A bare identifier (no "(", no "then") might name an existing Flow
725
+ // that's already wired up (e.g. loginFlow) - try that FIRST so pointing
726
+ // at real, already-built flows needs no chain-spec typing at all. Falls
727
+ // through to ordinary block-chain parsing if nothing matches.
728
+ const bareRef = /^[A-Za-z_$][\\w]*$/.test(spec.trim()) ? spec.trim() : null;
729
+ if (bareRef) {
730
+ const flow = await findFlow(projectDir, bareRef);
731
+ if (flow && typeof flow.blocks === "function") {
732
+ resolved = flow.blocks().map((bi) => ({ block: bi.block, exportName: bi.name }));
733
+ console.log(
734
+ "waygraph: running existing flow \\"" + bareRef + "\\" - " +
735
+ resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
736
+ (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
737
+ );
738
+ }
739
+ }
740
+ if (resolved.length === 0) {
741
+ const segments = parseChainSpec(spec);
742
+ if (segments.length === 0) {
743
+ throw new Error("waygraph chain: empty spec - give at least one block name");
744
+ }
745
+ for (const seg of segments) {
746
+ const r = await findBlock(projectDir, seg.ref);
747
+ seedMemForBlock(mem, r, seg.json);
748
+ resolved.push(r);
749
+ }
750
+ console.log(
751
+ "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
752
+ " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
753
+ );
326
754
  }
327
- console.log(
328
- "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
329
- " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
330
- );
331
- const blocks = resolved.map((r) => r.block);
332
- const chained = blocks.reduce((a, b) => connect(a, b));
333
- const headed = process.env.WAYGRAPH_HEADED === "1";
334
- const slowMo = process.env.WAYGRAPH_SLOWMO ? Number(process.env.WAYGRAPH_SLOWMO) : undefined;
755
+ const step = process.env.WAYGRAPH_STEP === "1";
756
+ // Stepping through headless defeats the point - a human can't watch it.
757
+ const headed = process.env.WAYGRAPH_HEADED === "1" || step;
758
+ // Step mode defaults to a visible pace (each fill/click actually shows on
759
+ // screen instead of snapping in) unless the human overrides it - an
760
+ // explicit WAYGRAPH_SLOWMO=0 still means "off".
761
+ const slowMo = process.env.WAYGRAPH_SLOWMO !== undefined
762
+ ? Number(process.env.WAYGRAPH_SLOWMO)
763
+ : step
764
+ ? 350
765
+ : undefined;
335
766
  const baseURL = process.env.WAYGRAPH_BASE_URL;
336
767
  const engine = new Engine({ headless: !headed, slowMo });
337
- const flow = engine.defineFlow([start, chained, end]);
338
768
  let result;
339
- if (baseURL) {
769
+ if (step || baseURL) {
340
770
  const { chromium } = await import("playwright");
341
- const browser = await chromium.launch({ headless: !headed, slowMo });
342
- const context = await browser.newContext({ baseURL, viewport: { width: 1280, height: 720 } });
771
+ // --start-maximized (step mode only): viewport: null alone only made
772
+ // the PAGE content track the window - the actual browser WINDOW still
773
+ // launched at Chromium's own default size/position, which stayed put
774
+ // where it was for a previous, larger monitor and looked chopped off on
775
+ // a smaller one. Maximizing fills whatever screen it's actually on.
776
+ const browser = await chromium.launch({
777
+ headless: !headed,
778
+ slowMo,
779
+ args: step ? ["--start-maximized"] : [],
780
+ });
781
+ const context = await browser.newContext(
782
+ step ? { baseURL, viewport: null } : { baseURL, viewport: { width: 1280, height: 720 } },
783
+ );
343
784
  try {
344
- result = await flow.run(context, mem);
785
+ if (step) {
786
+ const page = await context.newPage();
787
+ // Step 1's "before" panel used to sit over a blank about:blank page
788
+ // until the human clicked Run - show the real destination first.
789
+ if (baseURL) {
790
+ await page.goto(baseURL).catch(() => {});
791
+ }
792
+ result = await runStepMode(engine, start, end, context, page, mem, resolved);
793
+ } else {
794
+ const blocks = resolved.map((r) => r.block);
795
+ const chained = blocks.reduce((a, b) => connect(a, b));
796
+ const flow = engine.defineFlow([start, chained, end]);
797
+ result = await flow.run(context, mem);
798
+ }
345
799
  } finally {
346
800
  await browser.close();
347
801
  }
348
802
  } else {
803
+ const blocks = resolved.map((r) => r.block);
804
+ const chained = blocks.reduce((a, b) => connect(a, b));
805
+ const flow = engine.defineFlow([start, chained, end]);
349
806
  result = await flow.run(mem);
350
807
  }
351
808
  console.log("waygraph: chain finished -- " + JSON.stringify(result));
@@ -460,6 +917,13 @@ Usage:
460
917
  WAYGRAPH_BASE_URL base URL for Blocks using relative page.goto()
461
918
  WAYGRAPH_HEADED=1 show the browser instead of headless
462
919
  WAYGRAPH_SLOWMO=ms slow down each Playwright action, for watching a run
920
+ WAYGRAPH_STEP=1 human-verification mode - one Block at a time, a
921
+ browser overlay shows/lets you edit that Block's
922
+ MemKeys, a "Run this step" button gates it, then a
923
+ highlight ring shows whatever its verify Traits
924
+ just confirmed, then "Next" before moving on.
925
+ Implies headed - stepping through headless defeats
926
+ the point.
463
927
 
464
928
  "project" defaults to the current directory.
465
929
  `);
package/dist/engine.d.ts CHANGED
@@ -105,7 +105,34 @@ type EndMarker = typeof end;
105
105
  * each call, so it's still correct after a patch too. Returns a new Flow -
106
106
  * the original is untouched, same as every other decorator in this module.
107
107
  */
108
+ /**
109
+ * One Block's identity as exposed by graph introspection - {@link Flow.blocks},
110
+ * {@link ComposedBlock.steps} - without needing to execute anything. Does not
111
+ * include the Block's literal Checkpoint `In`/`Out` tags: those are TypeScript
112
+ * literal types, erased at runtime, and can only be recovered by a build-time
113
+ * static-analysis tool reading the source's own type annotations - see the
114
+ * `graph-visualization` openspec change for that piece.
115
+ */
116
+ export interface BlockInfo {
117
+ name: string;
118
+ /** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
119
+ routes?: Readonly<Record<string, string | null>>;
120
+ /**
121
+ * The actual Block, re-runnable on its own via
122
+ * `engine.defineFlow([start, info.block, end])` - e.g. step-through
123
+ * tooling that runs a Flow's Blocks one at a time. Introspection that only
124
+ * wants to read the shape (a visualizer) can ignore this and use `name`/
125
+ * `routes` alone.
126
+ */
127
+ block: Block<any, any>;
128
+ }
108
129
  export interface Flow<Out extends Checkpoint<string>> {
130
+ /**
131
+ * This Flow's constituent Blocks, in order between `start`/`end`, as plain
132
+ * data - for introspection/visualization tools, without running anything.
133
+ * @example loginFlow.blocks() // [{ name: "login" }, { name: "add-to-cart", routes: {...} }]
134
+ */
135
+ blocks(): readonly BlockInfo[];
109
136
  run(context: BrowserContext, mem: MemPage): Promise<Out>;
110
137
  /**
111
138
  * `options.closeOnFinish: false` hands the driven page back instead of
@@ -150,6 +177,12 @@ export interface Flow<Out extends Checkpoint<string>> {
150
177
  * casing needed anywhere that only expects a `Block`.
151
178
  */
152
179
  export interface ComposedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> extends Block<In, Out> {
180
+ /**
181
+ * This composed Block's constituent steps, in order, as plain data - same
182
+ * shape and purpose as {@link Flow.blocks}.
183
+ * @example GovFormBlock.steps() // [{ name: "step-1-name" }, { name: "step-2-address" }, ...]
184
+ */
185
+ steps(): readonly BlockInfo[];
153
186
  /**
154
187
  * Replaces one step's whole verify list, wherever it sits in this composed
155
188
  * Block - same rules as {@link withVerify}. Throws if no step matches.
@@ -269,6 +302,7 @@ export declare function defineBlock<In extends Checkpoint<string>, Out extends C
269
302
  instruction: Instruction<In, Out, any>;
270
303
  next?: Block<In, Out>["next"];
271
304
  requires?: Block<In, Out>["requires"];
305
+ routes?: Block<In, Out>["routes"];
272
306
  }): DefinedBlock<In, Out>;
273
307
  /**
274
308
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
package/dist/engine.js CHANGED
@@ -180,6 +180,7 @@ function buildFlow(middle, engineConfig) {
180
180
  return result;
181
181
  };
182
182
  return {
183
+ blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
183
184
  run: run,
184
185
  withBlockVerify(block, verify) {
185
186
  const index = findBlockIndex(middle, block);
@@ -203,6 +204,7 @@ export function composeBlock(name, steps) {
203
204
  return {
204
205
  ...chain,
205
206
  name,
207
+ steps: () => steps.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
206
208
  withStepVerify(step, verify) {
207
209
  const index = findBlockIndex(steps, step);
208
210
  const patched = [...steps];
@@ -249,6 +251,10 @@ export function branch(block, routes) {
249
251
  const base = stripMethods(block);
250
252
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
251
253
  let routed;
254
+ const routesAsData = {};
255
+ for (const [tag, target] of Object.entries(routes)) {
256
+ routesAsData[tag] = target === null ? null : target === block ? base.name : target.name;
257
+ }
252
258
  routed = defineBlock({
253
259
  ...base,
254
260
  next: (checkpoint) => {
@@ -260,6 +266,7 @@ export function branch(block, routes) {
260
266
  // the routed Block, not the original with no `next` of its own.
261
267
  return target === block ? routed : target;
262
268
  },
269
+ routes: routesAsData,
263
270
  });
264
271
  return routed;
265
272
  }
@@ -279,6 +286,7 @@ function stripMethods(block) {
279
286
  instruction: block.instruction,
280
287
  ...(block.next ? { next: block.next } : {}),
281
288
  ...(block.requires ? { requires: block.requires } : {}),
289
+ ...(block.routes ? { routes: block.routes } : {}),
282
290
  };
283
291
  }
284
292
  /**
@@ -296,6 +304,7 @@ export function defineBlock(base) {
296
304
  instruction: base.instruction,
297
305
  ...(base.next ? { next: base.next } : {}),
298
306
  ...(base.requires ? { requires: base.requires } : {}),
307
+ ...(base.routes ? { routes: base.routes } : {}),
299
308
  };
300
309
  return {
301
310
  ...plain,
package/dist/index.d.ts CHANGED
@@ -3,4 +3,4 @@ export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
5
  export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
6
- export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } from "./engine.js";
package/dist/types.d.ts CHANGED
@@ -79,6 +79,18 @@ export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<str
79
79
  * self-loop - see `runGraph`'s `maxSteps` for the safety net that needs.
80
80
  */
81
81
  next?(checkpoint: Out): Block<any, any> | null | undefined;
82
+ /**
83
+ * `branch()`'s routing table as plain, readable data - Checkpoint tag ->
84
+ * target Block's `.name` (`null` for a terminal tag) - alongside {@link next}
85
+ * itself, which stays the actual runtime dispatch. `next` is an opaque
86
+ * closure a graph-introspection tool can't read; this is `next`'s own
87
+ * routing decisions exposed as data instead, purely additive and never
88
+ * consulted by `runGraph`/`connect` - it exists only for tools like
89
+ * `Flow`'s inspection method (and, eventually, `toMermaid`) to walk without
90
+ * executing anything. A route back to the Block itself (a self-loop) maps
91
+ * to that same Block's own name, not `null`.
92
+ */
93
+ routes?: Readonly<Record<string, string | null>>;
82
94
  /**
83
95
  * MemKeys this Block's `act` reads from `mem` and needs already set.
84
96
  * Declaring it lets {@link preflight} (built into `runGraph`) catch a missing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",