waygraph 0.5.0 → 0.7.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
@@ -103,9 +103,16 @@ a fresh tab (the run won't close a page you handed it unless you say so), and
103
103
  so the caller can keep driving it - hands a run tab back instead of losing it. See
104
104
  "Recipes" for capture-on-popup, which stays a documented pattern until a second real use
105
105
  case promotes it to an engine API.
106
- `defineNavBlock({ name, checkpoint, url })` builds a Block whose only possible action is
107
- navigating - `url` accepts a plain string or `(mem) => string` for parameterized routes,
108
- and the generated `act()` is always exactly `page.goto(url)`, never author-supplied. A
106
+ `defineNavBlock({ name, checkpoint, url })` or `defineNavBlock({ name, checkpoint, click })`
107
+ builds a Block whose only possible action is navigating - exactly one of `url`/`click` is
108
+ required, enforced at compile time. Both accept a plain string or `(mem) => string`/
109
+ `(mem) => selector` for parameterized routes; the generated `act()` is always exactly
110
+ `page.goto(url)` or `page.locator(click).click()`, never author-supplied. `click` is the
111
+ opinionated default - navigating by clicking a real element already on the page (a nav
112
+ link, a sidebar item) instead of teleporting straight to a URL, so a Block actually proves
113
+ the app's own navigation path works instead of skipping past it. `url` still exists for the
114
+ real cases with no click path to get there (a deep link from an email, a public share
115
+ link). A
109
116
  regular `defineBlock`'s `act()` receives its `page` typed as `ActionPage` instead of raw
110
117
  `Page` - structurally identical, every method still fully present and callable, except
111
118
  `goto`/`reload`/`goBack`/`goForward` are re-declared `@deprecated`, so a TypeScript-aware
package/dist/cli.js CHANGED
@@ -12,8 +12,9 @@
12
12
  * "project" defaults to the current directory.
13
13
  * A project is any directory containing *.flow.ts files (typically under src/flows/).
14
14
  */
15
- import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
- import { resolve, relative, join, basename } from "node:path";
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "node:fs";
16
+ import { resolve, relative, join, basename, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
17
18
  import { spawn } from "node:child_process";
18
19
  import { MemPage } from "./mem-page.js";
19
20
  // ---------------------------------------------------------------------------
@@ -401,7 +402,15 @@ const RING_CSS =
401
402
  "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
402
403
  "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
403
404
  "padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
404
- "opacity:0;transition:opacity .06s ease;}" +
405
+ // .06s was tuned back when the panel was removed and recreated on
406
+ // EVERY step - fast was the only way to avoid feeling laggy. Now that
407
+ // routine per-step updates reuse the same element (no fade at all - see
408
+ // the panel-reuse fix), this only ever fires for a genuinely fresh DOM
409
+ // (first load, or a real navigation - which is exactly what an episode
410
+ // boundary is). Slower and smoother reads as a real, comfortable
411
+ // transition instead of an instant pop - "eyes friendly," not laggy,
412
+ // since it no longer costs anything on the common case.
413
+ "opacity:0;transition:opacity .35s ease;}" +
405
414
  "#wg-panel.wg-in{opacity:1;}" +
406
415
  "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
407
416
  "#wg-panel .wg-autoplay-row{margin-top:8px;}" +
@@ -420,6 +429,32 @@ const RING_CSS =
420
429
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
421
430
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
422
431
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
432
+ // A real tab bar for episodes - the currently-active episode reads as
433
+ // active (filled underline, full-brightness text), every other episode
434
+ // reads as inactive (dimmed, no underline) - Dan's own ask: "if episode
435
+ // 1 is active, the episode 2 tab is inactive." Only rendered when the
436
+ // chain spec actually named real Flows (chainFlow tags each via
437
+ // BlockInfo - same gate the "Episode N:" heading already uses); an
438
+ // ad hoc block chain with no episodes shows no tab bar at all.
439
+ "#wg-episodes{display:flex;gap:4px;margin:0 0 10px;border-bottom:1px solid #3a2a60;}" +
440
+ "#wg-episodes .wg-ep-tab{padding:6px 14px 8px;font:600 12px system-ui,sans-serif;" +
441
+ "border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;}" +
442
+ "#wg-episodes .wg-ep-current{color:#fff;border-bottom-color:#7C3AED;}" +
443
+ "#wg-episodes .wg-ep-done{color:#9a7ad1;}" +
444
+ "#wg-episodes .wg-ep-upcoming{color:#5a4a80;}" +
445
+ // A one-time, gentle signal for "you just entered this episode" - Dan:
446
+ // "i want a clear ui eyes friendly to remind me that i am on next
447
+ // episode." A slow outward glow, not an opacity blink/strobe (that's
448
+ // exactly the kind of flashing that already caused the original
449
+ // complaint) - plays once (no loop), 1.1s, only on the FIRST block of a
450
+ // newly-entered episode, not on every step within it.
451
+ "@keyframes wg-ep-enter{0%{box-shadow:0 0 0 0 rgba(124,58,237,.55);}" +
452
+ "100%{box-shadow:0 0 0 10px rgba(124,58,237,0);}}" +
453
+ "#wg-episodes .wg-ep-entered{border-radius:6px;animation:wg-ep-enter 1.1s ease-out;}" +
454
+ // The block breadcrumb below it is scoped to the CURRENT episode's own
455
+ // blocks only, not the whole chain - "in episode 2 it shows 2nd to the
456
+ // last... it should start from the first block" (Dan). An ad hoc block
457
+ // chain with no episodes still shows the whole chain here, unchanged.
423
458
  "#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
424
459
  "#wg-modules .wg-mod{padding:3px 9px;border-radius:6px;font:600 11px system-ui,sans-serif;}" +
425
460
  "#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
@@ -435,12 +470,23 @@ const RING_CSS =
435
470
  "#wg-banner .wg-banner-tag{display:block;font-size:10px;font-weight:700;color:#c9a6ff;" +
436
471
  "letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;}" +
437
472
  "#wg-panel .wg-key{margin:8px 0;}" +
473
+ // A live, human-readable preview of what's about to be written for this
474
+ // MemKey - Dan: "i want pretty to exist in memkeys too... it shows what
475
+ // input its gonig to be written." Same global Pretty/JSON preference the
476
+ // result display already has (window.__wgPretty) - hidden entirely in
477
+ // JSON mode, since the raw textarea already speaks for itself there.
478
+ "#wg-panel .wg-key-pretty{margin-top:4px;font:12px/1.5 system-ui,sans-serif;" +
479
+ "background:#0f0620;border-radius:6px;padding:6px 8px;}" +
480
+ "#wg-panel .wg-key-pretty-row{color:#f0e8ff;}" +
481
+ "#wg-panel .wg-key-pretty-label{color:#9a7ad1;font-weight:600;}" +
438
482
  "#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
439
483
  "#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
440
484
  "border:1px solid #4b2a80;border-radius:8px;padding:6px 8px;font:12px/1.3 monospace;resize:vertical;}" +
441
485
  "#wg-panel button{margin-top:10px;background:#7C3AED;color:#fff;border:none;border-radius:8px;" +
442
486
  "padding:8px 16px;font:600 13px system-ui,sans-serif;cursor:pointer;}" +
443
487
  "#wg-panel button:hover{background:#6b2fd6;}" +
488
+ "#wg-panel button:disabled{background:#4b2a80;cursor:default;opacity:.7;}" +
489
+ "#wg-panel textarea:disabled{opacity:.6;}" +
444
490
  "#wg-panel .wg-result{font:12px/1.4 monospace;background:#0f0620;border-radius:8px;padding:8px;" +
445
491
  "margin:8px 0;white-space:pre-wrap;}" +
446
492
  "#wg-panel .wg-result-pretty{font:600 14px/1.4 system-ui,sans-serif;}" +
@@ -452,8 +498,15 @@ const RING_CSS =
452
498
  "#wg-panel .wg-error-heading{color:#ff8fa0;}" +
453
499
  "#wg-panel .wg-error-msg{font:12px/1.5 monospace;background:#2a0f16;color:#ffc7cf;" +
454
500
  "border-radius:8px;padding:10px;margin:0 0 12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;}" +
455
- "#wg-panel .wg-error-stop{background:#e0475c;}" +
456
- "#wg-panel .wg-error-stop:hover{background:#c33a4c;}";
501
+ "#wg-panel .wg-error-actions{display:flex;gap:8px;}" +
502
+ "#wg-panel .wg-error-stop{background:#e0475c;margin-top:0;}" +
503
+ "#wg-panel .wg-error-stop:hover{background:#c33a4c;}" +
504
+ "#wg-panel .wg-error-retry{background:#2a9d6f;margin-top:0;}" +
505
+ "#wg-panel .wg-error-retry:hover{background:#22855e;}" +
506
+ "#wg-panel.wg-expected{border:1.5px solid #e0a53e;}" +
507
+ "#wg-panel .wg-expected-heading{color:#ffcf7a;}" +
508
+ "#wg-panel .wg-expected-reason{font:600 12.5px/1.5 system-ui,sans-serif;background:#2a2410;" +
509
+ "color:#ffe6ae;border-radius:8px;padding:10px;margin:0 0 8px;}";
457
510
 
458
511
  // Purple dot favicon (matches the overlay's own theme color) - the tab-bar
459
512
  // signal that "this Chromium window is a waygraph run," even at a glance
@@ -668,15 +721,51 @@ async function renderBeforeStep(page, info) {
668
721
  .catch(() => {});
669
722
  await page
670
723
  .evaluate((info) => {
671
- const old = document.getElementById("wg-panel");
672
- if (old) old.remove();
673
- const panel = document.createElement("div");
674
- panel.id = "wg-panel";
724
+ // Reused in place, not removed + recreated, every step - the old
725
+ // remove()-then-fade-back-in cycle was a real flash on every single
726
+ // transition (Dan: "the appear and disappear... hurts eyes and
727
+ // dizzy"). Updating one persistent element's content has nothing to
728
+ // flash - it only ever fades in ONCE, the first time this page
729
+ // genuinely has no panel yet (a real navigation wiped the whole
730
+ // document, or this is the very first step).
731
+ let panel = document.getElementById("wg-panel");
732
+ const isNewPanel = !panel;
733
+ if (!panel) {
734
+ panel = document.createElement("div");
735
+ panel.id = "wg-panel";
736
+ }
675
737
  const pct = Math.round((info.index / info.total) * 100);
676
738
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
739
+ // A real tab bar, not a single text line - every episode this chain
740
+ // touches, the current one reading as active (Dan: "if episode 1 is
741
+ // active, the episode 2 tab is inactive"). Absent entirely for an ad
742
+ // hoc block chain with no named Flows.
743
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
744
+ ? "<div id=\\"wg-episodes\\">" +
745
+ info.allEpisodes
746
+ .map((e) => {
747
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
748
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
749
+ : "wg-ep-upcoming";
750
+ // The gentle one-time glow only plays on the tab actually
751
+ // being entered right now, not on every render of it.
752
+ const enteredCls = info.justEnteredEpisode && e.episodeNumber === info.episodeNumber
753
+ ? " wg-ep-entered"
754
+ : "";
755
+ return "<span class=\\"wg-ep-tab " + cls + enteredCls + "\\">Episode " + e.episodeNumber + ": " +
756
+ esc(e.episodeTitle || "") + "</span>";
757
+ })
758
+ .join("") +
759
+ "</div>"
760
+ : "";
761
+ // Scoped to THIS episode's own blocks (info.allNames is already the
762
+ // episode-local subset the caller computed) - moduleIndex is this
763
+ // step's position WITHIN that subset, not the whole chain's index -
764
+ // "in episode 2 it shows 2nd to the last... it should start from the
765
+ // first block" (Dan).
677
766
  const modulesHtml = info.allNames
678
767
  .map((name, idx) => {
679
- const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
768
+ const cls = idx < info.moduleIndex ? "wg-mod-done" : idx === info.moduleIndex ? "wg-mod-current" : "wg-mod-upcoming";
680
769
  const desc = info.allDescriptions && info.allDescriptions[idx];
681
770
  const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
682
771
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
@@ -685,13 +774,10 @@ async function renderBeforeStep(page, info) {
685
774
  const narrationHtml = info.description
686
775
  ? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
687
776
  : "";
688
- const episodeHtml = info.episodeNumber
689
- ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + esc(info.episodeTitle || "") + "</div>"
690
- : "";
691
777
  let html =
692
778
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
779
+ episodesHtml +
693
780
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
694
- episodeHtml +
695
781
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
696
782
  narrationHtml;
697
783
  if (info.keys.length === 0) {
@@ -701,7 +787,9 @@ async function renderBeforeStep(page, info) {
701
787
  html +=
702
788
  "<div class=\\"wg-key\\"><label>" + k.name + "</label>" +
703
789
  "<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
704
- k.value.replace(/</g, "&lt;") + "</textarea></div>";
790
+ k.value.replace(/</g, "&lt;") + "</textarea>" +
791
+ "<div class=\\"wg-key-pretty\\" data-pretty-for=\\"" + esc(k.name) + "\\"></div>" +
792
+ "</div>";
705
793
  }
706
794
  let autoNow = false;
707
795
  try {
@@ -718,8 +806,49 @@ async function renderBeforeStep(page, info) {
718
806
  "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
719
807
  ">Auto-advancing...</div>";
720
808
  panel.innerHTML = html;
721
- document.documentElement.appendChild(panel);
722
- requestAnimationFrame(() => panel.classList.add("wg-in"));
809
+ if (isNewPanel) {
810
+ document.documentElement.appendChild(panel);
811
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
812
+ }
813
+ // Live, human-readable preview of what a MemKey's raw JSON will
814
+ // actually write - object fields become "Field: value" lines
815
+ // (camelCase split the same way state tags already are); a
816
+ // non-object payload (a plain string/number/array) is shown as-is.
817
+ // Hidden entirely in JSON mode - shares the SAME global toggle the
818
+ // result display's own Pretty/JSON buttons already set, not a
819
+ // second, separate preference.
820
+ const prettyMemValue = (raw) => {
821
+ let parsed;
822
+ try {
823
+ parsed = JSON.parse(raw);
824
+ } catch {
825
+ return "<em>(invalid JSON)</em>";
826
+ }
827
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
828
+ return esc(JSON.stringify(parsed));
829
+ }
830
+ return Object.entries(parsed)
831
+ .map(([field, value]) => {
832
+ const label = field.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
833
+ return "<div class=\\"wg-key-pretty-row\\"><span class=\\"wg-key-pretty-label\\">" +
834
+ esc(label) + ":</span> " + esc(String(value)) + "</div>";
835
+ })
836
+ .join("");
837
+ };
838
+ const applyKeyPretty = () => {
839
+ const prettyOn = window.__wgPretty !== false;
840
+ panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
841
+ const key = ta.getAttribute("data-key");
842
+ const previewEl = panel.querySelector(".wg-key-pretty[data-pretty-for=\\"" + key + "\\"]");
843
+ if (!previewEl) return;
844
+ previewEl.style.display = prettyOn ? "" : "none";
845
+ if (prettyOn) previewEl.innerHTML = prettyMemValue(ta.value);
846
+ });
847
+ };
848
+ applyKeyPretty();
849
+ panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
850
+ ta.addEventListener("input", applyKeyPretty);
851
+ });
723
852
  const runBtn = document.getElementById("wg-run");
724
853
  if (runBtn) {
725
854
  runBtn.addEventListener("click", () => {
@@ -810,10 +939,13 @@ async function renderAfterStep(page, info) {
810
939
  }
811
940
  await page
812
941
  .evaluate((info) => {
813
- const old = document.getElementById("wg-panel");
814
- if (old) old.remove();
815
- const panel = document.createElement("div");
816
- panel.id = "wg-panel";
942
+ // Reused in place - see renderBeforeStep's own comment on this.
943
+ let panel = document.getElementById("wg-panel");
944
+ const isNewPanel = !panel;
945
+ if (!panel) {
946
+ panel = document.createElement("div");
947
+ panel.id = "wg-panel";
948
+ }
817
949
  const pct = Math.round(((info.index + 1) / info.total) * 100);
818
950
  const heading = info.isLast
819
951
  ? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
@@ -822,7 +954,7 @@ async function renderAfterStep(page, info) {
822
954
  const escA = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
823
955
  const modulesHtml = info.allNames
824
956
  .map((name, idx) => {
825
- const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
957
+ const cls = idx <= info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
826
958
  const desc = info.allDescriptions && info.allDescriptions[idx];
827
959
  const titleAttr = desc ? " title=\\"" + escA(desc) + "\\"" : "";
828
960
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
@@ -858,18 +990,30 @@ async function renderAfterStep(page, info) {
858
990
  "><button id=\\"wg-run\\">" + buttonLabel + "</button></div>" +
859
991
  "<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
860
992
  ">Auto-advancing...</div>";
861
- const episodeHtml = info.episodeNumber
862
- ? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + escA(info.episodeTitle || "") + "</div>"
993
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
994
+ ? "<div id=\\"wg-episodes\\">" +
995
+ info.allEpisodes
996
+ .map((e) => {
997
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
998
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
999
+ : "wg-ep-upcoming";
1000
+ return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
1001
+ escA(e.episodeTitle || "") + "</span>";
1002
+ })
1003
+ .join("") +
1004
+ "</div>"
863
1005
  : "";
864
1006
  panel.innerHTML =
865
1007
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1008
+ episodesHtml +
866
1009
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
867
- episodeHtml +
868
1010
  "<h3>" + heading + "</h3>" +
869
1011
  resultHtml +
870
1012
  gateHtml;
871
- document.documentElement.appendChild(panel);
872
- requestAnimationFrame(() => panel.classList.add("wg-in"));
1013
+ if (isNewPanel) {
1014
+ document.documentElement.appendChild(panel);
1015
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
1016
+ }
873
1017
  panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
874
1018
  btn.addEventListener("click", () => {
875
1019
  const wantPretty = btn.getAttribute("data-mode") === "pretty";
@@ -910,30 +1054,63 @@ async function renderStepError(page, info) {
910
1054
  await installOverlay(page, info.title);
911
1055
  await page
912
1056
  .evaluate((info) => {
913
- const old = document.getElementById("wg-panel");
914
- if (old) old.remove();
915
- const panel = document.createElement("div");
916
- panel.id = "wg-panel";
917
- panel.className = "wg-error";
1057
+ // Reused in place - see renderBeforeStep's own comment on this.
1058
+ let panel = document.getElementById("wg-panel");
1059
+ const isNewPanel = !panel;
1060
+ if (!panel) {
1061
+ panel = document.createElement("div");
1062
+ panel.id = "wg-panel";
1063
+ }
1064
+ const isExpected = !!info.expectedFailureReason;
1065
+ const errClass = isExpected ? "wg-expected" : "wg-error";
1066
+ panel.className = isNewPanel ? errClass : errClass + " wg-in";
918
1067
  const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1068
+ const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
1069
+ ? "<div id=\\"wg-episodes\\">" +
1070
+ info.allEpisodes
1071
+ .map((e) => {
1072
+ const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
1073
+ : e.episodeNumber === info.episodeNumber ? "wg-ep-current"
1074
+ : "wg-ep-upcoming";
1075
+ return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
1076
+ esc(e.episodeTitle || "") + "</span>";
1077
+ })
1078
+ .join("") +
1079
+ "</div>"
1080
+ : "";
919
1081
  const modulesHtml = info.allNames
920
1082
  .map((name, idx) => {
921
- const cls = idx === info.index ? "wg-mod-current" : idx < info.index ? "wg-mod-done" : "wg-mod-upcoming";
1083
+ const cls = idx === info.moduleIndex ? "wg-mod-current" : idx < info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
922
1084
  const desc = info.allDescriptions && info.allDescriptions[idx];
923
1085
  const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
924
1086
  return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
925
1087
  })
926
1088
  .join("");
1089
+ const retryLabel = info.episodeNumber !== undefined ? "Retry Episode " + info.episodeNumber : "Retry";
1090
+ const headingText = isExpected
1091
+ ? "Expected outcome - Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " failed as intended"
1092
+ : "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " threw";
1093
+ const reasonHtml = isExpected
1094
+ ? "<div class=\\"wg-expected-reason\\">" + esc(info.expectedFailureReason) + "</div>"
1095
+ : "";
927
1096
  panel.innerHTML =
1097
+ episodesHtml +
928
1098
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
929
- "<h3 class=\\"wg-error-heading\\">Step " + (info.index + 1) + " / " + info.total + " - " +
930
- info.blockName + " threw</h3>" +
1099
+ "<h3 class=\\"" + (isExpected ? "wg-expected-heading" : "wg-error-heading") + "\\">" + headingText + "</h3>" +
1100
+ reasonHtml +
931
1101
  "<div class=\\"wg-error-msg\\">" + esc(info.message) + "</div>" +
932
- "<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>";
933
- document.documentElement.appendChild(panel);
934
- requestAnimationFrame(() => panel.classList.add("wg-in"));
1102
+ "<div class=\\"wg-error-actions\\">" +
1103
+ "<button id=\\"wg-error-retry\\" class=\\"wg-error-retry\\">" + esc(retryLabel) + "</button>" +
1104
+ "<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>" +
1105
+ "</div>";
1106
+ if (isNewPanel) {
1107
+ document.documentElement.appendChild(panel);
1108
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
1109
+ }
935
1110
  const runBtn = document.getElementById("wg-run");
936
1111
  if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
1112
+ const retryBtn = document.getElementById("wg-error-retry");
1113
+ if (retryBtn) retryBtn.addEventListener("click", () => window.__wgNext({ __wgRetry: "1" }));
937
1114
  }, info)
938
1115
  .catch(() => {});
939
1116
  }
@@ -1006,6 +1183,35 @@ function extractVerifyHighlights(block, resultTag) {
1006
1183
  * highlight then slowly input." One-time patch (idempotent - guarded so a
1007
1184
  * multi-step chain doesn't re-wrap an already-wrapped fill).
1008
1185
  */
1186
+ /**
1187
+ * Called once the gate resolves - manual click OR autoplay timeout, both
1188
+ * covered from here rather than duplicating this in the in-page click
1189
+ * handler - right before the Block's own act() actually starts. Dan: "i
1190
+ * want the button, step is running thing. so i wont be able to interrupt
1191
+ * the playwright automation" - the "Run this step" button used to sit
1192
+ * there still looking clickable for the entire multi-second duration a
1193
+ * real act()/observe() takes, inviting a confusing extra click (harmless -
1194
+ * __wgNext no-ops once already consumed - but looked live when it wasn't).
1195
+ * Disables the button and mem-key textareas, and swaps whichever gate
1196
+ * text was showing (manual button or "Auto-advancing...") to "Running...".
1197
+ */
1198
+ async function markStepRunning(page) {
1199
+ await page
1200
+ .evaluate(() => {
1201
+ const runBtn = document.getElementById("wg-run");
1202
+ if (runBtn) {
1203
+ runBtn.disabled = true;
1204
+ runBtn.textContent = "Running \\u25B6";
1205
+ }
1206
+ const autoEl = document.getElementById("wg-gate-auto");
1207
+ if (autoEl) autoEl.textContent = "Running...";
1208
+ document.querySelectorAll("#wg-panel textarea[data-key]").forEach((ta) => {
1209
+ ta.disabled = true;
1210
+ });
1211
+ })
1212
+ .catch(() => {});
1213
+ }
1214
+
1009
1215
  async function showRing(page, box, label) {
1010
1216
  await page
1011
1217
  .evaluate(
@@ -1204,7 +1410,16 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1204
1410
  } catch {
1205
1411
  // best-effort - the real click below still runs either way
1206
1412
  }
1207
- if (clickPoint) await clickPulseAt(page, clickPoint.x, clickPoint.y);
1413
+ if (clickPoint) {
1414
+ await clickPulseAt(page, clickPoint.x, clickPoint.y);
1415
+ // clickPulseAt only triggers the CSS animation class - it doesn't
1416
+ // wait for it. Without a pause here, the real click (and any
1417
+ // resulting navigation/DOM change) fires while the ripple is still
1418
+ // mid-animation, sometimes cutting it off before it's even visible.
1419
+ // Wait out the same .5s the "@keyframes wg-pulse" rule uses, so the
1420
+ // mock click visually completes before the real one fires.
1421
+ await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 500));
1422
+ }
1208
1423
  const result = await originalClick.call(this, options);
1209
1424
  await new Promise((res) => setTimeout(res, clickPostPop()));
1210
1425
  await hideRing(page);
@@ -1275,6 +1490,28 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1275
1490
  }
1276
1491
  }
1277
1492
 
1493
+ /**
1494
+ * Clears cookies + storage and re-navigates to a known-fresh page. Shared by
1495
+ * the per-block session-reset check below and by the error panel's own
1496
+ * "Retry Episode" button - a retry needs the exact same clean slate a normal
1497
+ * episode boundary gets, not just re-running the failed block against
1498
+ * whatever broken/half-navigated state it left the page in.
1499
+ */
1500
+ async function resetPageState(context, page, baseURL) {
1501
+ await context.clearCookies().catch(() => {});
1502
+ await page
1503
+ .evaluate(() => {
1504
+ try {
1505
+ localStorage.clear();
1506
+ sessionStorage.clear();
1507
+ } catch {
1508
+ /* storage blocked (e.g. about:blank) - nothing to clear anyway */
1509
+ }
1510
+ })
1511
+ .catch(() => {});
1512
+ await page.goto(baseURL || "about:blank").catch(() => {});
1513
+ }
1514
+
1278
1515
  async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
1279
1516
  // A shared, mutable pacing knob the interaction patches read live (per
1280
1517
  // call, not once at setup) - flipped per-block below so one block (e.g.
@@ -1351,10 +1588,46 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1351
1588
  };
1352
1589
  const allNames = resolved.map((r) => r.block.name);
1353
1590
  const allDescriptions = resolved.map((r) => r.block.description || "");
1591
+ // Deduped, in-order list of every real episode this chain touches - for
1592
+ // the episode tab bar. Ad hoc block segments (no named Flow) carry no
1593
+ // episodeNumber and never appear here, same gate the "Episode N:"
1594
+ // heading already used.
1595
+ const allEpisodes = [];
1596
+ for (const r of resolved) {
1597
+ if (r.episodeNumber && !allEpisodes.some((e) => e.episodeNumber === r.episodeNumber)) {
1598
+ allEpisodes.push({ episodeNumber: r.episodeNumber, episodeTitle: r.episodeTitle });
1599
+ }
1600
+ }
1354
1601
 
1355
1602
  let result;
1356
1603
  for (let i = 0; i < resolved.length; i++) {
1357
1604
  const r = resolved[i];
1605
+ // The block breadcrumb is scoped to THIS step's own episode, not the
1606
+ // whole chain - within episode 2, step 1 should read as "1st of 2,"
1607
+ // not "6th of 7." Falls back to the whole chain when this block has no
1608
+ // episode (an ad hoc chain with no named Flows), unchanged from before.
1609
+ const episodeBlockIndices = r.episodeNumber
1610
+ ? resolved.reduce((acc, x, idx) => {
1611
+ if (x.episodeNumber === r.episodeNumber) acc.push(idx);
1612
+ return acc;
1613
+ }, [])
1614
+ : resolved.map((_x, idx) => idx);
1615
+ const moduleNames = episodeBlockIndices.map((idx) => allNames[idx]);
1616
+ const moduleDescriptions = episodeBlockIndices.map((idx) => allDescriptions[idx]);
1617
+ const moduleIndex = episodeBlockIndices.indexOf(i);
1618
+ // Where a "Retry Episode" click (see the catch block below) rewinds to -
1619
+ // the current episode's own first block, or the whole chain's first
1620
+ // block when there's no real episode (episodeBlockIndices degrades to
1621
+ // the full resolved array in that case, so this is just 0).
1622
+ const episodeStartIndex = episodeBlockIndices[0];
1623
+ // True only on an episode's own first block - the one moment worth a
1624
+ // gentle "you're here now" signal, not every step inside it.
1625
+ const justEnteredEpisode = moduleIndex === 0 && r.episodeNumber !== undefined;
1626
+ // Seed THIS segment's own json payload now, not earlier - see the long
1627
+ // comment where seedMem closures are built, in main()'s resolution
1628
+ // loop. Must run before "keys" below reads mem.get() for the panel
1629
+ // display, and before this segment's own first block executes.
1630
+ if (r.seedMem) r.seedMem();
1358
1631
  // Reset session state before this block if EITHER: the blanket
1359
1632
  // WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
1360
1633
  // the-first-block stopgap for ad hoc block chains with no real Flow
@@ -1373,22 +1646,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1373
1646
  // look fresh again - clearing storage alone doesn't change what's
1374
1647
  // still rendered on screen.
1375
1648
  if ((clearSession || r.resetSession) && i > 0) {
1376
- await context.clearCookies().catch(() => {});
1377
- await page
1378
- .evaluate(() => {
1379
- try {
1380
- localStorage.clear();
1381
- sessionStorage.clear();
1382
- } catch {
1383
- /* storage blocked (e.g. about:blank) - nothing to clear anyway */
1384
- }
1385
- })
1386
- .catch(() => {});
1387
- if (baseURL) {
1388
- await page.goto(baseURL).catch(() => {});
1389
- } else {
1390
- await page.goto("about:blank").catch(() => {});
1391
- }
1649
+ await resetPageState(context, page, baseURL);
1392
1650
  }
1393
1651
  pacing.fast = fastBlockNames.has(r.block.name);
1394
1652
  const requires = r.block.requires ?? [];
@@ -1407,8 +1665,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1407
1665
  blockName: r.block.name,
1408
1666
  description: r.block.description || "",
1409
1667
  keys,
1410
- allNames,
1411
- allDescriptions,
1668
+ allNames: moduleNames,
1669
+ allDescriptions: moduleDescriptions,
1670
+ moduleIndex,
1671
+ allEpisodes,
1672
+ justEnteredEpisode,
1412
1673
  title,
1413
1674
  episodeNumber: r.episodeNumber,
1414
1675
  episodeTitle: r.episodeTitle,
@@ -1423,6 +1684,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1423
1684
  }
1424
1685
  }
1425
1686
  }
1687
+ await markStepRunning(page);
1426
1688
  const stepFlow = engine.defineFlow([start, r.block, end]);
1427
1689
  // { closeOnFinish: false } makes Flow.run return { result, page }, not
1428
1690
  // the plain checkpoint - destructure it, don't treat the wrapper as the
@@ -1441,11 +1703,24 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1441
1703
  total: resolved.length,
1442
1704
  blockName: r.block.name,
1443
1705
  message: err && err.message ? err.message : String(err),
1444
- allNames,
1445
- allDescriptions,
1706
+ allNames: moduleNames,
1707
+ allDescriptions: moduleDescriptions,
1708
+ moduleIndex,
1709
+ allEpisodes,
1446
1710
  title,
1711
+ episodeNumber: r.episodeNumber,
1712
+ episodeTitle: r.episodeTitle,
1713
+ expectedFailureReason: r.expectedFailureReason,
1447
1714
  });
1448
- await gate();
1715
+ const errEdits = await gate();
1716
+ if (errEdits && errEdits.__wgRetry) {
1717
+ // Same clean slate a normal episode boundary gets - not just
1718
+ // re-running the failed block against whatever broken/half-
1719
+ // navigated state it left the page in.
1720
+ await resetPageState(context, page, baseURL);
1721
+ i = episodeStartIndex - 1;
1722
+ continue;
1723
+ }
1449
1724
  throw err;
1450
1725
  }
1451
1726
  result = stepOutcome.result;
@@ -1458,8 +1733,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1458
1733
  resultTag: JSON.stringify(result),
1459
1734
  highlights,
1460
1735
  isLast: i === resolved.length - 1,
1461
- allNames,
1462
- allDescriptions,
1736
+ allNames: moduleNames,
1737
+ allDescriptions: moduleDescriptions,
1738
+ moduleIndex,
1739
+ allEpisodes,
1463
1740
  title,
1464
1741
  episodeNumber: r.episodeNumber,
1465
1742
  episodeTitle: r.episodeTitle,
@@ -1484,7 +1761,11 @@ async function main() {
1484
1761
  if (bareRef) {
1485
1762
  const flow = await findFlow(projectDir, bareRef);
1486
1763
  if (flow && typeof flow.blocks === "function") {
1487
- resolved = flow.blocks().map((bi) => ({ block: bi.block, exportName: bi.name }));
1764
+ resolved = flow.blocks().map((bi) => ({
1765
+ block: bi.block,
1766
+ exportName: bi.name,
1767
+ expectedFailureReason: flow.expectedFailureReason,
1768
+ }));
1488
1769
  console.log(
1489
1770
  "waygraph: running existing flow \\"" + bareRef + "\\" - " +
1490
1771
  resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
@@ -1517,37 +1798,62 @@ async function main() {
1517
1798
  // "Episode 2" labels on things that were never episodes to begin with.
1518
1799
  const flowMeta = [];
1519
1800
  let episodeCounter = 0;
1801
+ // seedMem is a closure, NOT called here - seeding every segment's own
1802
+ // json payload eagerly, all upfront during resolution, was a real bug:
1803
+ // mem is one shared object, so when two segments require the SAME key
1804
+ // (e.g. two segments both requiring LoginCreds, each with a DIFFERENT
1805
+ // payload - "login as standard_user then login as locked_out_user"),
1806
+ // the LAST segment resolved silently overwrote the first's value
1807
+ // before either segment had even started running - so the FIRST
1808
+ // segment's own blocks would run against the SECOND segment's
1809
+ // credentials. Deferred to execution time instead (runStepMode calls
1810
+ // this exactly once, right as each segment's first block is reached -
1811
+ // see resolved[i].seedMem below), same moment session-reset already
1812
+ // happens for the same reason.
1520
1813
  for (const seg of segments) {
1521
1814
  const flow = await findFlow(projectDir, seg.ref);
1522
1815
  if (flow && typeof flow.blocks === "function") {
1523
- seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref);
1524
1816
  flows.push(flow);
1525
1817
  episodeCounter += 1;
1526
- flowMeta.push({ episodeNumber: episodeCounter, episodeTitle: flow.title || seg.ref });
1818
+ flowMeta.push({
1819
+ episodeNumber: episodeCounter,
1820
+ episodeTitle: flow.title || seg.ref,
1821
+ expectedFailureReason: flow.expectedFailureReason,
1822
+ seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref),
1823
+ });
1527
1824
  } else {
1528
1825
  const r = await findBlock(projectDir, seg.ref);
1529
- seedMemForBlock(mem, r, seg.json);
1530
1826
  flows.push(wrapEngine.defineFlow([start, r.block, end]));
1531
- flowMeta.push(null);
1827
+ flowMeta.push({
1828
+ episodeNumber: undefined,
1829
+ episodeTitle: undefined,
1830
+ seedMem: () => seedMemForBlock(mem, r, seg.json),
1831
+ });
1532
1832
  }
1533
1833
  }
1534
1834
  const combinedBlocks = chainFlow(...flows).blocks();
1535
1835
  let fi = 0;
1536
1836
  let remainingInFlow = flows[0].blocks().length;
1837
+ let isFirstOfSegment = true;
1537
1838
  resolved = combinedBlocks.map((bi) => {
1538
1839
  while (remainingInFlow === 0) {
1539
1840
  fi += 1;
1540
1841
  remainingInFlow = flows[fi].blocks().length;
1842
+ isFirstOfSegment = true;
1541
1843
  }
1542
- remainingInFlow -= 1;
1543
1844
  const meta = flowMeta[fi];
1544
- return {
1845
+ const entry = {
1545
1846
  block: bi.block,
1546
1847
  exportName: bi.name,
1547
1848
  resetSession: bi.resetSessionBefore === true,
1548
- episodeNumber: meta ? meta.episodeNumber : undefined,
1549
- episodeTitle: meta ? meta.episodeTitle : undefined,
1849
+ episodeNumber: meta.episodeNumber,
1850
+ episodeTitle: meta.episodeTitle,
1851
+ expectedFailureReason: meta.expectedFailureReason,
1852
+ seedMem: isFirstOfSegment ? meta.seedMem : undefined,
1550
1853
  };
1854
+ remainingInFlow -= 1;
1855
+ isFirstOfSegment = false;
1856
+ return entry;
1551
1857
  });
1552
1858
  console.log(
1553
1859
  "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
@@ -1586,6 +1892,19 @@ async function main() {
1586
1892
  const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
1587
1893
  const engine = new Engine({ headless: !headed, slowMo });
1588
1894
  let result;
1895
+ // Non-interactive execution (no --step) runs the whole chain as one
1896
+ // composed flow with no per-block loop of its own to defer seeding
1897
+ // into - it can't support two segments needing DIFFERENT values for the
1898
+ // SAME mem key (e.g. two segments both requiring LoginCreds, each with
1899
+ // its own payload). Fall back to seeding every segment eagerly, upfront -
1900
+ // the same limitation "chain" always had; only --step's own runStepMode
1901
+ // loop actually needs (and correctly supports, via resolved[i].seedMem)
1902
+ // per-segment differentiated values.
1903
+ if (!step) {
1904
+ for (const r of resolved) {
1905
+ if (r.seedMem) r.seedMem();
1906
+ }
1907
+ }
1589
1908
  if (step || baseURL) {
1590
1909
  const { chromium } = await import("playwright");
1591
1910
  // --start-maximized (step mode only): viewport: null alone only made
@@ -1667,6 +1986,162 @@ async function runChain(projectDir, spec) {
1667
1986
  rmSync(scriptPath, { force: true });
1668
1987
  }
1669
1988
  }
1989
+ // ---------------------------------------------------------------------------
1990
+ // `try` - a bundled, self-contained showcase. No .flow.ts to write, no
1991
+ // scenario to design - copies a small real Flow (nav/action Blocks, Trait
1992
+ // verify, narrate()) into the target project and runs it in step mode
1993
+ // against a public demo site, so a curious dev sees the real overlay/panel
1994
+ // experience in under a minute, then gets pointed at the plain waygraph
1995
+ // source that produced it.
1996
+ // ---------------------------------------------------------------------------
1997
+ /** This package's own installed root - dist/cli.js -> .. */
1998
+ function packageRoot() {
1999
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
2000
+ }
2001
+ /**
2002
+ * Runs a throwaway probe script rooted at `projectDir` - the exact same
2003
+ * directory the real demo files get copied into, so it resolves "waygraph"
2004
+ * and "@playwright/test" exactly as they will - and actually launches (then
2005
+ * immediately closes) chromium, a real check rather than a guess at whether
2006
+ * the browser's installed. Never leaves the probe script behind.
2007
+ */
2008
+ async function probeTryPrereqs(projectDir) {
2009
+ const tsxEsm = import.meta.resolve("tsx/esm");
2010
+ const probeScript = [
2011
+ "try {",
2012
+ ' await import("waygraph");',
2013
+ "} catch {",
2014
+ ' console.error("WAYGRAPH_TRY_MISSING_WAYGRAPH");',
2015
+ " process.exit(1);",
2016
+ "}",
2017
+ "let mod;",
2018
+ "try {",
2019
+ ' mod = await import("@playwright/test");',
2020
+ "} catch {",
2021
+ ' console.error("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST");',
2022
+ " process.exit(1);",
2023
+ "}",
2024
+ "try {",
2025
+ " const browser = await mod.chromium.launch({ headless: true });",
2026
+ " await browser.close();",
2027
+ "} catch (err) {",
2028
+ " if (String(err && err.message).includes(\"Executable doesn't exist\")) {",
2029
+ ' console.error("WAYGRAPH_TRY_MISSING_BROWSER");',
2030
+ " process.exit(1);",
2031
+ " }",
2032
+ " console.error(String((err && err.stack) || err));",
2033
+ " process.exit(1);",
2034
+ "}",
2035
+ ].join("\n");
2036
+ const probePath = join(projectDir, `.waygraph-try-probe-${process.pid}.mjs`);
2037
+ writeFileSync(probePath, probeScript);
2038
+ try {
2039
+ const result = await new Promise((res, rej) => {
2040
+ const child = spawn(process.execPath, ["--import", tsxEsm, probePath], {
2041
+ cwd: projectDir,
2042
+ stdio: ["ignore", "ignore", "pipe"],
2043
+ env: process.env,
2044
+ });
2045
+ let stderrBuf = "";
2046
+ child.stderr?.on("data", (d) => { stderrBuf += d.toString(); });
2047
+ child.on("error", rej);
2048
+ child.on("exit", (code) => res({ code: code ?? 1, stderr: stderrBuf }));
2049
+ });
2050
+ if (result.code === 0)
2051
+ return { ok: true };
2052
+ const stderr = result.stderr;
2053
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_WAYGRAPH"))
2054
+ return { ok: false, missing: "waygraph" };
2055
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST"))
2056
+ return { ok: false, missing: "playwright-test" };
2057
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_BROWSER"))
2058
+ return { ok: false, missing: "browser" };
2059
+ return { ok: false, missing: "unknown", detail: stderr.trim() || `probe exited with code ${result.code}` };
2060
+ }
2061
+ finally {
2062
+ rmSync(probePath, { force: true });
2063
+ }
2064
+ }
2065
+ /** Spawns with inherited stdio (the real npm/playwright output, visible) and resolves to its exit code. */
2066
+ function runInherited(cmd, args, cwd) {
2067
+ return new Promise((res, rej) => {
2068
+ const child = spawn(cmd, args, { cwd, stdio: "inherit", env: process.env });
2069
+ child.on("error", rej);
2070
+ child.on("exit", (code) => res(code ?? 1));
2071
+ });
2072
+ }
2073
+ /**
2074
+ * `waygraph try` is meant to be a genuine one-shot `npx waygraph try` -
2075
+ * no separate init/install step the human has to remember, the same shape
2076
+ * as `npm create vite@latest`. Probes first; anything missing gets
2077
+ * installed right here (a minimal package.json if none exists, then
2078
+ * `npm install waygraph @playwright/test`, then Playwright's own chromium
2079
+ * download), with the real install output visible the whole time - not
2080
+ * silent, just not a separate step you have to run yourself first. Returns
2081
+ * null once everything's actually ready, or a message naming whatever
2082
+ * still isn't after really trying.
2083
+ */
2084
+ async function ensureTryPrereqs(projectDir) {
2085
+ let status = await probeTryPrereqs(projectDir);
2086
+ if (status.ok)
2087
+ return null;
2088
+ if (status.missing === "unknown") {
2089
+ return `couldn't verify prerequisites:\n${status.detail}`;
2090
+ }
2091
+ if (status.missing === "waygraph" || status.missing === "playwright-test") {
2092
+ if (!existsSync(join(projectDir, "package.json"))) {
2093
+ writeFileSync(join(projectDir, "package.json"), JSON.stringify({ name: basename(projectDir) || "waygraph-quickstart", private: true, type: "module" }, null, 2) + "\n");
2094
+ console.log(`waygraph try: no package.json in ${projectDir} - created a minimal one.`);
2095
+ }
2096
+ console.log(`waygraph try: installing waygraph + @playwright/test in ${projectDir} ...`);
2097
+ const installCode = await runInherited("npm", ["install", "waygraph", "@playwright/test"], projectDir);
2098
+ if (installCode !== 0) {
2099
+ return `npm install exited with code ${installCode} - see the output above.`;
2100
+ }
2101
+ status = await probeTryPrereqs(projectDir);
2102
+ if (status.ok)
2103
+ return null;
2104
+ if (status.missing === "unknown")
2105
+ return `couldn't verify prerequisites:\n${status.detail}`;
2106
+ }
2107
+ // Only the browser can still be missing at this point.
2108
+ console.log("waygraph try: downloading Playwright's chromium browser ...");
2109
+ const localPlaywrightBin = join(projectDir, "node_modules", ".bin", "playwright");
2110
+ const browserCode = existsSync(localPlaywrightBin)
2111
+ ? await runInherited(localPlaywrightBin, ["install", "chromium"], projectDir)
2112
+ : await runInherited("npx", ["--yes", "playwright", "install", "chromium"], projectDir);
2113
+ if (browserCode !== 0) {
2114
+ return `"playwright install chromium" exited with code ${browserCode} - see the output above.`;
2115
+ }
2116
+ status = await probeTryPrereqs(projectDir);
2117
+ if (status.ok)
2118
+ return null;
2119
+ return status.detail ?? "prerequisites still aren't ready after attempting to install them.";
2120
+ }
2121
+ async function runTry(projectDir) {
2122
+ const problem = await ensureTryPrereqs(projectDir);
2123
+ if (problem) {
2124
+ console.error(`waygraph try: couldn't get ready.\n${problem}`);
2125
+ process.exitCode = 1;
2126
+ return;
2127
+ }
2128
+ const destDir = join(projectDir, ".waygraph-quickstart");
2129
+ // Regenerated fresh every run (like chain's own .waygraph-chain-<pid>.mjs)
2130
+ // so an updated waygraph version's demo never goes stale - not meant to be
2131
+ // hand-edited and kept, just a real, readable copy to point people at.
2132
+ cpSync(join(packageRoot(), "templates", "quickstart"), destDir, { recursive: true });
2133
+ process.env.WAYGRAPH_HEADED ??= "1";
2134
+ process.env.WAYGRAPH_STEP ??= "1";
2135
+ process.env.WAYGRAPH_AUTOPLAY ??= "1";
2136
+ process.env.WAYGRAPH_BASE_URL ??= "https://www.saucedemo.com";
2137
+ await runChain(destDir, "quickstartFlow");
2138
+ if (!process.exitCode) {
2139
+ console.log("\nwaygraph try: that's plain waygraph - defineNavBlock, defineBlock, Trait, connect() - nothing hidden.\n" +
2140
+ "See the code that just ran:\n" +
2141
+ " " + join(destDir, "src", "flows", "quickstart.flow.ts") + "\n" +
2142
+ " " + join(destDir, "src", "blocks"));
2143
+ }
2144
+ }
1670
2145
  async function validateFlows(projectDir) {
1671
2146
  const files = discoverFlows(projectDir);
1672
2147
  const results = [];
@@ -1784,6 +2259,25 @@ Usage:
1784
2259
  waygraph run <flow> [project] Run a named flow
1785
2260
  waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
1786
2261
  waygraph check [project] Warn about navigation outside a NavBlock
2262
+ waygraph try [project] One-shot quickstart showcase - genuinely
2263
+ "npx waygraph try", no separate init step.
2264
+ Checks (a real launch+close, not a guess)
2265
+ whether "waygraph" + "@playwright/test"
2266
+ resolve from [project] (default: cwd) and
2267
+ its chromium browser is downloaded; if
2268
+ anything's missing, installs it right
2269
+ there (writes a minimal package.json if
2270
+ [project] doesn't have one yet, then
2271
+ npm install, then the browser download),
2272
+ real output visible the whole time. Once
2273
+ ready, copies a small real Flow
2274
+ (nav/action Blocks, Trait verify,
2275
+ narrate()) into
2276
+ [project]/.waygraph-quickstart/ and runs
2277
+ it in step mode against a public demo
2278
+ site - then prints the absolute path to
2279
+ that plain waygraph source, so whoever's
2280
+ watching can go read exactly how it works.
1787
2281
 
1788
2282
  "check" is a secondary sweep, complementary to the always-on TypeScript
1789
2283
  warning every regular Block's act() already gets (a struck-through
@@ -1909,6 +2403,15 @@ async function main() {
1909
2403
  await runChain(proj, spec);
1910
2404
  break;
1911
2405
  }
2406
+ case "try": {
2407
+ const proj = resolve(args[1] ?? process.cwd());
2408
+ if (!existsSync(proj)) {
2409
+ console.error(`waygraph: no such directory: ${proj}`);
2410
+ process.exit(1);
2411
+ }
2412
+ await runTry(proj);
2413
+ break;
2414
+ }
1912
2415
  case "check": {
1913
2416
  const proj = resolve(args[1] ?? process.cwd());
1914
2417
  const warnings = await checkCommand(proj);
package/dist/engine.d.ts CHANGED
@@ -170,6 +170,16 @@ export interface Flow<Out extends Checkpoint<string>> {
170
170
  * something `run()` itself uses.
171
171
  */
172
172
  readonly title?: string;
173
+ /**
174
+ * Set once this Flow has been wrapped in {@link withExpectedFailure} - a
175
+ * display hint for a step-through overlay: this Flow is DESIGNED to throw
176
+ * (e.g. a blocked user's login attempt genuinely failing), not a bug in
177
+ * the demo itself. `run()` never reads this - the Flow still throws
178
+ * exactly as it would otherwise; only the overlay's error panel reads it,
179
+ * to show an informational "this is the point" panel instead of an
180
+ * alarming one indistinguishable from a real break.
181
+ */
182
+ readonly expectedFailureReason?: string;
173
183
  /**
174
184
  * This Flow's constituent Blocks, in order between `start`/`end`, as plain
175
185
  * data - for introspection/visualization tools, without running anything.
@@ -237,6 +247,25 @@ export declare function withSessionReset<Out extends Checkpoint<string>>(flow: F
237
247
  * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
238
248
  */
239
249
  export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
250
+ /**
251
+ * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
252
+ * login attempt genuinely failing, demonstrating the app's own real
253
+ * behavior rather than a bug in the demo. Non-destructive, same pattern as
254
+ * {@link withTitle}.
255
+ *
256
+ * By itself this changes nothing about how the Flow runs - it still throws
257
+ * exactly as it would otherwise. It's a hint a step-through overlay
258
+ * (waygraph's `chain --step`) reads when that throw happens, to show an
259
+ * informational "this is the point" panel instead of one indistinguishable
260
+ * from a genuine break - the distinction a demo author needs so a viewer
261
+ * (or the author, mid-build) can tell "the app just did what it's supposed
262
+ * to" apart from "something in this demo is actually broken."
263
+ * @example const viewerBlockedFlow = withExpectedFailure(
264
+ * engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
265
+ * "locked_out_user can never reach /inventory.html - this is the block working as intended.",
266
+ * );
267
+ */
268
+ export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
240
269
  /**
241
270
  * Runs several complete, independent Flows one after another, reusing the
242
271
  * same page/context/mem in order - each remains its own validated
@@ -415,31 +444,59 @@ export declare function defineBlock<In extends Checkpoint<string>, Out extends C
415
444
  */
416
445
  export type NavBlock<Out extends Checkpoint<string>> = DefinedBlock<Checkpoint<string>, Out>;
417
446
  /**
418
- * Builds a {@link NavBlock}. `url` accepts a plain string or a function
419
- * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
420
- * with a real id read from mem). `checkpoint` is the tag this NavBlock
447
+ * `defineNavBlock`'s own options - exactly one of `url`/`click` required,
448
+ * enforced at compile time (giving both, or neither, is a type error - see
449
+ * `typecheck/nav-block.types.ts`). `checkpoint` is the tag this NavBlock
421
450
  * resolves to once navigation completes - a NavBlock never branches on
422
451
  * observed evidence the way a regular Block can, so there's no `resolve`
423
452
  * logic to author; it always just declares "arrived."
424
- *
425
- * The generated `act()` is always exactly `page.goto(url)` - never anything
426
- * else, never author-supplied. Callers get a real `Page` here internally
427
- * (the one legitimate place navigation belongs); `defineBlock`'s own
428
- * `ActionPage` narrowing is what discourages navigation everywhere else,
429
- * and never applies to this generated function since no author ever writes
430
- * it by hand.
431
- * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
432
- * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
433
453
  */
434
- export declare function defineNavBlock<Out extends Checkpoint<string>>(options: {
454
+ export type NavBlockOptions<Out extends Checkpoint<string>> = {
435
455
  name: string;
436
456
  description?: string;
437
457
  checkpoint: Out["__state"];
438
- url: string | ((mem: MemPage) => string);
439
458
  requires?: readonly MemKey<any>[];
440
459
  verify?: Trait[] | ((out: Out) => Trait[]);
441
460
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
442
- }): NavBlock<Out>;
461
+ } & ({
462
+ /**
463
+ * Direct URL navigation. Accepts a plain string or a function
464
+ * `(mem) => string` for parameterized routes (e.g.
465
+ * `/dashboard/requests/:id` with a real id read from mem). The
466
+ * escape hatch for the few real cases with no click path to get
467
+ * there (a deep link from an email, a public share link) - not for
468
+ * skipping past navigation an agent didn't feel like finding. Prefer
469
+ * `click` whenever the target app actually has one.
470
+ */
471
+ url: string | ((mem: MemPage) => string);
472
+ click?: never;
473
+ } | {
474
+ /**
475
+ * Navigates by clicking a real element already on the page (a nav
476
+ * link, a sidebar item) instead of teleporting straight to a URL -
477
+ * the opinionated default. Accepts a Playwright selector string, or
478
+ * a function `(mem) => selector` for a target that depends on mem
479
+ * (e.g. clicking the row for a specific id). Verifying arrival is
480
+ * `verify`'s job (typically `Trait.url(...)`), same as a `url`-based
481
+ * NavBlock - the click itself is only ever awaited for its own
482
+ * completion, not for whatever navigation it triggers.
483
+ */
484
+ click: string | ((mem: MemPage) => string);
485
+ url?: never;
486
+ });
487
+ /**
488
+ * Builds a {@link NavBlock}. The generated `act()` is always exactly
489
+ * `page.goto(url)` or `page.locator(click).click()` - never anything else,
490
+ * never author-supplied. Callers get a real `Page` here internally (the one
491
+ * legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
492
+ * narrowing is what discourages navigation everywhere else, and never
493
+ * applies to this generated function since no author ever writes it by
494
+ * hand.
495
+ * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
496
+ * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
497
+ * @example defineNavBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
498
+ */
499
+ export declare function defineNavBlock<Out extends Checkpoint<string>>(options: NavBlockOptions<Out>): NavBlock<Out>;
443
500
  /**
444
501
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
445
502
  * same routing - but with `verify` replaced. For giving one flow stricter (or
package/dist/engine.js CHANGED
@@ -341,6 +341,32 @@ export function withTitle(flow, title) {
341
341
  modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
342
342
  };
343
343
  }
344
+ /**
345
+ * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
346
+ * login attempt genuinely failing, demonstrating the app's own real
347
+ * behavior rather than a bug in the demo. Non-destructive, same pattern as
348
+ * {@link withTitle}.
349
+ *
350
+ * By itself this changes nothing about how the Flow runs - it still throws
351
+ * exactly as it would otherwise. It's a hint a step-through overlay
352
+ * (waygraph's `chain --step`) reads when that throw happens, to show an
353
+ * informational "this is the point" panel instead of one indistinguishable
354
+ * from a genuine break - the distinction a demo author needs so a viewer
355
+ * (or the author, mid-build) can tell "the app just did what it's supposed
356
+ * to" apart from "something in this demo is actually broken."
357
+ * @example const viewerBlockedFlow = withExpectedFailure(
358
+ * engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
359
+ * "locked_out_user can never reach /inventory.html - this is the block working as intended.",
360
+ * );
361
+ */
362
+ export function withExpectedFailure(flow, reason) {
363
+ return {
364
+ ...flow,
365
+ expectedFailureReason: reason,
366
+ withBlockVerify: (block, verify) => withExpectedFailure(flow.withBlockVerify(block, verify), reason),
367
+ modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
368
+ };
369
+ }
344
370
  function locateInChain(flows, block) {
345
371
  if (typeof block === "number") {
346
372
  let remaining = block;
@@ -583,21 +609,16 @@ export function defineBlock(base) {
583
609
  };
584
610
  }
585
611
  /**
586
- * Builds a {@link NavBlock}. `url` accepts a plain string or a function
587
- * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
588
- * with a real id read from mem). `checkpoint` is the tag this NavBlock
589
- * resolves to once navigation completes - a NavBlock never branches on
590
- * observed evidence the way a regular Block can, so there's no `resolve`
591
- * logic to author; it always just declares "arrived."
592
- *
593
- * The generated `act()` is always exactly `page.goto(url)` - never anything
594
- * else, never author-supplied. Callers get a real `Page` here internally
595
- * (the one legitimate place navigation belongs); `defineBlock`'s own
596
- * `ActionPage` narrowing is what discourages navigation everywhere else,
597
- * and never applies to this generated function since no author ever writes
598
- * it by hand.
612
+ * Builds a {@link NavBlock}. The generated `act()` is always exactly
613
+ * `page.goto(url)` or `page.locator(click).click()` - never anything else,
614
+ * never author-supplied. Callers get a real `Page` here internally (the one
615
+ * legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
616
+ * narrowing is what discourages navigation everywhere else, and never
617
+ * applies to this generated function since no author ever writes it by
618
+ * hand.
599
619
  * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
600
620
  * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
621
+ * @example defineNavBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
601
622
  */
602
623
  export function defineNavBlock(options) {
603
624
  const built = defineBlock({
@@ -606,8 +627,14 @@ export function defineNavBlock(options) {
606
627
  ...(options.requires ? { requires: options.requires } : {}),
607
628
  instruction: {
608
629
  async act(page, _input, mem) {
609
- const url = typeof options.url === "function" ? options.url(mem) : options.url;
610
- await page.goto(url);
630
+ if (options.url !== undefined) {
631
+ const url = typeof options.url === "function" ? options.url(mem) : options.url;
632
+ await page.goto(url);
633
+ }
634
+ else {
635
+ const selector = typeof options.click === "function" ? options.click(mem) : options.click;
636
+ await page.locator(selector).click();
637
+ }
611
638
  },
612
639
  resolve: () => checkpoint(options.checkpoint),
613
640
  ...(options.verify ? { verify: options.verify } : {}),
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./type
2
2
  export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
6
- export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock } from "./engine.js";
5
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions } from "./engine.js";
7
7
  export type { ActionPage } from "./types.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
4
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
- "bin"
19
+ "bin",
20
+ "templates"
20
21
  ],
21
22
  "repository": {
22
23
  "type": "git",
@@ -0,0 +1,18 @@
1
+ import { defineBlock, checkpoint, Trait } from "waygraph";
2
+ import type { LoggedIn, ItemInCart } from "../../states/quickstart.states.js";
3
+
4
+ export const AddToCartBlock = defineBlock<LoggedIn, ItemInCart>({
5
+ name: "add-to-cart",
6
+ description: "Adds the Sauce Labs Backpack to the cart and confirms the cart badge shows 1 item.",
7
+ instruction: {
8
+ async act(page) {
9
+ await page.locator("#add-to-cart-sauce-labs-backpack").click();
10
+ },
11
+ resolve: () => checkpoint("ItemInCart"),
12
+ verify: [Trait.text(".shopping_cart_badge", "1")],
13
+ // Author-declared highlights - independent of verify's own pass/fail
14
+ // check. "Remember this" moments a step-through overlay can call out
15
+ // even when there's nothing to assert, just something worth pointing at.
16
+ highlights: () => [{ selector: ".shopping_cart_badge", label: "remember this: cart now has 1 item" }],
17
+ },
18
+ });
@@ -0,0 +1,19 @@
1
+ import { defineBlock, checkpoint, Trait } from "waygraph";
2
+ import type { ItemInCart, CheckoutInfoFilled } from "../../states/quickstart.states.js";
3
+
4
+ export const CheckoutInfoBlock = defineBlock<ItemInCart, CheckoutInfoFilled>({
5
+ name: "checkout-info",
6
+ description: "Goes to the cart, starts checkout, and fills in the shipping info form.",
7
+ instruction: {
8
+ async act(page) {
9
+ await page.locator(".shopping_cart_link").click();
10
+ await page.locator("#checkout").click();
11
+ await page.locator("#first-name").fill("Ada");
12
+ await page.locator("#last-name").fill("Lovelace");
13
+ await page.locator("#postal-code").fill("1000");
14
+ await page.locator("#continue").click();
15
+ },
16
+ resolve: () => checkpoint("CheckoutInfoFilled"),
17
+ verify: [Trait.url({ pathname: "/checkout-step-two.html" })],
18
+ },
19
+ });
@@ -0,0 +1,18 @@
1
+ import { defineBlock, checkpoint, Trait, narrate } from "waygraph";
2
+ import type { CheckoutInfoFilled, OrderComplete } from "../../states/quickstart.states.js";
3
+
4
+ export const FinishOrderBlock = defineBlock<CheckoutInfoFilled, OrderComplete>({
5
+ name: "finish-order",
6
+ description: "Places the order and confirms the 'Thank you for your order!' completion page.",
7
+ instruction: {
8
+ async act(page) {
9
+ // narrate() captions an explicit action for a step-through overlay,
10
+ // instead of it guessing a generic label from the element's own text.
11
+ const finishButton = page.locator("#finish");
12
+ await narrate(finishButton, "Placing the order", () => finishButton.click());
13
+ },
14
+ resolve: () => checkpoint("OrderComplete"),
15
+ verify: [Trait.text(".complete-header", "Thank you for your order!")],
16
+ highlights: () => [{ selector: ".complete-header", label: "remember this: order confirmed" }],
17
+ },
18
+ });
@@ -0,0 +1,22 @@
1
+ import { defineBlock, checkpoint, Trait } from "waygraph";
2
+ import type { LoginPage, LoggedIn } from "../../states/quickstart.states.js";
3
+
4
+ // Credentials hardcoded on purpose, to keep this first look at waygraph
5
+ // self-contained - no setup step, nothing to type before you can watch it
6
+ // run. A real project reaches for a MemKey/keyGroup instead (see waygraph's
7
+ // own README, or projects_waygraph/saucedemo in this same workspace) so
8
+ // different scenarios (an Owner vs. a blocked Viewer, say) can hand a Flow
9
+ // different credentials at run time without editing the Block itself.
10
+ export const SubmitLoginActionBlock = defineBlock<LoginPage, LoggedIn>({
11
+ name: "submit-login",
12
+ description: "Logs a standard user into Swag Labs and confirms the inventory page loads.",
13
+ instruction: {
14
+ async act(page) {
15
+ await page.locator("#user-name").fill("standard_user");
16
+ await page.locator("#password").fill("secret_sauce");
17
+ await page.locator("#login-button").click();
18
+ },
19
+ resolve: () => checkpoint("LoggedIn"),
20
+ verify: [Trait.url({ pathname: "/inventory.html" })],
21
+ },
22
+ });
@@ -0,0 +1,15 @@
1
+ import { defineNavBlock, Trait } from "waygraph";
2
+ import type { LoginPage } from "../states/quickstart.states.js";
3
+
4
+ // Navigation lives in its own Block, built by defineNavBlock - its act() is
5
+ // ALWAYS exactly page.goto(url), generated for you, never hand-written. A
6
+ // regular defineBlock() Block that calls page.goto/reload/goBack/goForward
7
+ // instead gets a struck-through @deprecated warning in your editor, pointing
8
+ // back here - see waygraph's ActionPage type and `waygraph check`.
9
+ export const NavLoginBlock = defineNavBlock<LoginPage>({
10
+ name: "nav-login",
11
+ description: "Goes to the Swag Labs login page.",
12
+ checkpoint: "LoginPage",
13
+ url: "/",
14
+ verify: [Trait.visible("#login-button")],
15
+ });
@@ -0,0 +1,19 @@
1
+ import { Engine, start, end, withTitle } from "waygraph";
2
+ import { NavLoginBlock } from "../blocks/nav-login.block.js";
3
+ import { SubmitLoginActionBlock } from "../blocks/actions/submit-login.action.block.js";
4
+ import { AddToCartBlock } from "../blocks/actions/add-to-cart.action.block.js";
5
+ import { CheckoutInfoBlock } from "../blocks/actions/checkout-info.action.block.js";
6
+ import { FinishOrderBlock } from "../blocks/actions/finish-order.action.block.js";
7
+
8
+ const engine = new Engine();
9
+
10
+ // This is it - a whole real waygraph Flow, five Blocks wide, generated by
11
+ // `waygraph try` but otherwise no different from a Flow you'd hand-write:
12
+ // nav-* Blocks (defineNavBlock) navigate, action-* Blocks (defineBlock) do
13
+ // everything else, connect() (here, defineFlow's own array form) chains
14
+ // them into one typed graph, each Block's own Trait verify confirms it
15
+ // actually landed where it says it did.
16
+ export const quickstartFlow = withTitle(
17
+ engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, AddToCartBlock, CheckoutInfoBlock, FinishOrderBlock, end]),
18
+ "waygraph quickstart - swag labs demo",
19
+ );
@@ -0,0 +1,11 @@
1
+ import type { Checkpoint } from "waygraph";
2
+
3
+ // One Checkpoint per real state the target app can be in - the whole shape
4
+ // of this quickstart's Flow, readable in one place before you ever open a
5
+ // Block file. `Start` is waygraph's own exported type, not redeclared here.
6
+
7
+ export type LoginPage = Checkpoint<"LoginPage">;
8
+ export type LoggedIn = Checkpoint<"LoggedIn">;
9
+ export type ItemInCart = Checkpoint<"ItemInCart">;
10
+ export type CheckoutInfoFilled = Checkpoint<"CheckoutInfoFilled">;
11
+ export type OrderComplete = Checkpoint<"OrderComplete">;