waygraph 0.12.42 → 0.12.43

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
@@ -3,7 +3,7 @@
3
3
  A typed graph of reusable Blocks for driving a browser through E2E flows, in place of a
4
4
  flat pile of ad hoc helper functions.
5
5
 
6
- **Current:** `0.10.9` — demo carousel stepper (default), `--fast` / `--full`, Hide = compact `N / M`.
6
+ **Current:** `0.12.43` — overlay beacons (blind-agent gate), expected-failure panel on branched LoginPage, `--mini`, Hide stickiness.
7
7
 
8
8
  This repo is the `waygraph` npm package itself. The full live **Sauce Demo** example
9
9
  (Page inventory hub, Effect Add/Remove, MemNav Open details, `waygraph auto`) ships
@@ -429,6 +429,25 @@ npm run test # playwright test
429
429
  npm run build # tsc -p tsconfig.build.json, emits dist/
430
430
  ```
431
431
 
432
+ ### Blind-agent overlay gate (anti-blank)
433
+
434
+ Every live modal (`#wg-panel`, `#wg-banner`, `#wg-auto-panel`) stamps
435
+ `data-wg-ui` / `data-wg-modal` / `data-wg-ready="1"`. Do **not** claim the
436
+ stepper works unless one of these passes:
437
+
438
+ ```bash
439
+ # Playwright helper (exported from waygraph)
440
+ import { assertWgOverlayReady } from "waygraph";
441
+ await assertWgOverlayReady(page); // throws on blank / opacity-0 / missing stamp
442
+
443
+ # Live demo prove (exit 0 = painted, exit 2 = blank)
444
+ WAYGRAPH_PROVE_EXIT=1 WAYGRAPH_PROVE_SHOT=/tmp/wg.png \
445
+ waygraph demo --blocks loginFlow --data '...'
446
+ # stderr: WAYGRAPH_PROVE {"ok":true,"beacons":[...],"readyAttr":"1"}
447
+ ```
448
+
449
+ Tests: `tests/cli/overlay-beacon.spec.ts`. Helpers: `src/overlay-beacon.ts`.
450
+
432
451
  ## Layout
433
452
 
434
453
  ```
@@ -177,6 +177,19 @@ async function setAutoPanelRunning(page, label) {
177
177
  if (typeof window.__wgWirePanelChrome === "function") {
178
178
  window.__wgWirePanelChrome(panel, "wg-auto-panel-hidden", "waygraph auto");
179
179
  }
180
+ if (typeof window.__wgStampModal === "function") {
181
+ window.__wgStampModal(panel, "auto-panel", {
182
+ phase: "running",
183
+ block: label,
184
+ ready: true,
185
+ });
186
+ }
187
+ else {
188
+ panel.setAttribute("data-wg-ui", "1");
189
+ panel.setAttribute("data-wg-modal", "auto-panel");
190
+ panel.setAttribute("data-wg-phase", "running");
191
+ panel.setAttribute("data-wg-ready", "1");
192
+ }
180
193
  }, label)
181
194
  .catch(() => { });
182
195
  }
@@ -399,6 +412,7 @@ async function headfulPick(page, menu, library, mem, waitPick) {
399
412
  }
400
413
  const apply = (hidden) => {
401
414
  el.classList.toggle("wg-collapsed", hidden);
415
+ el.setAttribute("data-wg-collapsed", hidden ? "1" : "0");
402
416
  const t = el.querySelector("[data-wg-toggle]");
403
417
  if (t) t.textContent = hidden ? "Show" : "Hide";
404
418
  try { localStorage.setItem(storageKey, hidden ? "1" : "0"); } catch {}
@@ -416,6 +430,41 @@ async function headfulPick(page, menu, library, mem, waitPick) {
416
430
  }
417
431
  };
418
432
  window.__wgWirePanelChrome(root, "wg-auto-panel-hidden", "waygraph auto");
433
+ if (typeof window.__wgStampModal === "function") {
434
+ window.__wgStampModal(root, "auto-panel", { phase: "pick", ready: true });
435
+ } else {
436
+ root.setAttribute("data-wg-ui", "1");
437
+ root.setAttribute("data-wg-modal", "auto-panel");
438
+ root.setAttribute("data-wg-phase", "pick");
439
+ root.setAttribute(
440
+ "data-wg-collapsed",
441
+ root.classList.contains("wg-collapsed") ? "1" : "0",
442
+ );
443
+ root.setAttribute("data-wg-ready", "1");
444
+ window.__wgOverlayBeacon =
445
+ window.__wgOverlayBeacon ||
446
+ (() =>
447
+ Array.from(document.querySelectorAll('[data-wg-ui="1"]')).map((el) => {
448
+ const r = el.getBoundingClientRect();
449
+ const st = getComputedStyle(el);
450
+ return {
451
+ id: el.id || null,
452
+ modal: el.getAttribute("data-wg-modal"),
453
+ ready: el.getAttribute("data-wg-ready") === "1",
454
+ phase: el.getAttribute("data-wg-phase"),
455
+ step: el.getAttribute("data-wg-step"),
456
+ block: el.getAttribute("data-wg-block"),
457
+ collapsed:
458
+ el.getAttribute("data-wg-collapsed") === "1" ||
459
+ el.classList.contains("wg-collapsed"),
460
+ opacity: st.opacity,
461
+ textLen: (el.innerText || "").trim().length,
462
+ w: Math.round(r.width),
463
+ h: Math.round(r.height),
464
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
465
+ };
466
+ }));
467
+ }
419
468
  root.querySelectorAll("button[data-idx]").forEach((btn) => {
420
469
  btn.addEventListener("click", () => {
421
470
  const idx = Number(btn.getAttribute("data-idx"));
package/dist/cli.js CHANGED
@@ -858,6 +858,47 @@ async function installOverlay(page, title) {
858
858
  document.head.appendChild(iconLink);
859
859
  }
860
860
  if (iconLink.href !== favicon) iconLink.href = favicon;
861
+ // Blind-agent overlay beacons: every modal root gets data-wg-ui /
862
+ // data-wg-modal / data-wg-ready so tests can fail on a blank panel.
863
+ window.__wgStampModal = (el, kind, meta) => {
864
+ if (!el) return;
865
+ meta = meta || {};
866
+ el.setAttribute("data-wg-ui", "1");
867
+ el.setAttribute("data-wg-modal", kind || "panel");
868
+ if (meta.phase != null) el.setAttribute("data-wg-phase", String(meta.phase));
869
+ if (meta.step != null) el.setAttribute("data-wg-step", String(meta.step));
870
+ if (meta.total != null) el.setAttribute("data-wg-total", String(meta.total));
871
+ if (meta.block != null) el.setAttribute("data-wg-block", String(meta.block));
872
+ el.setAttribute(
873
+ "data-wg-collapsed",
874
+ el.classList.contains("wg-collapsed") ? "1" : "0",
875
+ );
876
+ el.setAttribute("data-wg-ready", meta.ready === false ? "0" : "1");
877
+ };
878
+ window.__wgOverlayBeacon = () => {
879
+ return Array.from(document.querySelectorAll("[data-wg-ui=\\"1\\"]")).map((el) => {
880
+ const r = el.getBoundingClientRect();
881
+ const st = getComputedStyle(el);
882
+ return {
883
+ id: el.id || null,
884
+ modal: el.getAttribute("data-wg-modal"),
885
+ ready: el.getAttribute("data-wg-ready") === "1",
886
+ phase: el.getAttribute("data-wg-phase"),
887
+ step: el.getAttribute("data-wg-step"),
888
+ block: el.getAttribute("data-wg-block"),
889
+ collapsed:
890
+ el.getAttribute("data-wg-collapsed") === "1" ||
891
+ el.classList.contains("wg-collapsed"),
892
+ opacity: st.opacity,
893
+ textLen: (el.innerText || "").trim().length,
894
+ w: Math.round(r.width),
895
+ h: Math.round(r.height),
896
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
897
+ };
898
+ });
899
+ };
900
+ const existingBanner = document.getElementById("wg-banner");
901
+ if (existingBanner) window.__wgStampModal(existingBanner, "banner", { ready: true });
861
902
  // Hide/Show for #wg-panel - call after every panel.innerHTML refresh.
862
903
  // Collapsed = "N / M · block" + Next (when not auto). Never pass
863
904
  // forceCollapsed:false - omit so Hide/localStorage wins.
@@ -937,6 +978,7 @@ async function installOverlay(page, title) {
937
978
  };
938
979
  const apply = (hidden, persist) => {
939
980
  panel.classList.toggle("wg-collapsed", hidden);
981
+ panel.setAttribute("data-wg-collapsed", hidden ? "1" : "0");
940
982
  const t = panel.querySelector("[data-wg-toggle]");
941
983
  const titleEl = panel.querySelector(".wg-chrome-title");
942
984
  const label = panel.dataset.wgStepLabel || stepLabel;
@@ -1027,6 +1069,8 @@ async function renderBeforeStep(page, info) {
1027
1069
  if (window.__wgHideCursor) window.__wgHideCursor();
1028
1070
  })
1029
1071
  .catch(() => {});
1072
+ // miniStepLabel / todosHtmlFromInfo are Node-only — browser evaluate has no closure.
1073
+ const payload = { ...info, stepLabel: miniStepLabel(info) };
1030
1074
  await page
1031
1075
  .evaluate((info) => {
1032
1076
  // Reused in place, not removed + recreated, every step - the old
@@ -1093,6 +1137,33 @@ async function renderBeforeStep(page, info) {
1093
1137
  esc(info.paceLabel || "") +
1094
1138
  "</span></div>"
1095
1139
  : "";
1140
+ const todosHtml = (() => {
1141
+ const list = info.todos || [];
1142
+ if (!list.length) return "";
1143
+ return (
1144
+ '<ul id=\\"wg-todos\\">' +
1145
+ list
1146
+ .map((t) => {
1147
+ const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1148
+ const mark = t.done
1149
+ ? String.fromCharCode(0x2713)
1150
+ : t.current
1151
+ ? String.fromCharCode(0x2192)
1152
+ : String.fromCharCode(0x25cb);
1153
+ return (
1154
+ '<li class=\\"' +
1155
+ cls +
1156
+ '\\"><span class=\\"wg-todo-mark\\">' +
1157
+ mark +
1158
+ '</span><span>' +
1159
+ esc(t.text || "") +
1160
+ "</span></li>"
1161
+ );
1162
+ })
1163
+ .join("") +
1164
+ "</ul>"
1165
+ );
1166
+ })();
1096
1167
  let html =
1097
1168
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1098
1169
  episodesHtml +
@@ -1100,7 +1171,7 @@ async function renderBeforeStep(page, info) {
1100
1171
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1101
1172
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
1102
1173
  narrationHtml +
1103
- todosHtmlFromInfo(info, esc);
1174
+ todosHtml;
1104
1175
  if (info.keys.length === 0) {
1105
1176
  html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
1106
1177
  }
@@ -1130,13 +1201,24 @@ async function renderBeforeStep(page, info) {
1130
1201
  if (isNewPanel) {
1131
1202
  document.documentElement.appendChild(panel);
1132
1203
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1204
+ } else {
1205
+ panel.classList.add("wg-in");
1133
1206
  }
1134
1207
  if (window.__wgWirePanelChrome) {
1135
1208
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1136
- stepLabel: miniStepLabel(info),
1209
+ stepLabel: info.stepLabel || "",
1137
1210
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1138
1211
  });
1139
1212
  }
1213
+ if (window.__wgStampModal) {
1214
+ window.__wgStampModal(panel, "panel", {
1215
+ phase: "before",
1216
+ step: info.index + 1,
1217
+ total: info.total,
1218
+ block: info.blockName || "",
1219
+ ready: true,
1220
+ });
1221
+ }
1140
1222
  // Live, human-readable preview of what a MemKey's raw JSON will
1141
1223
  // actually write - object fields become "Field: value" lines
1142
1224
  // (camelCase split the same way state tags already are); a
@@ -1206,8 +1288,40 @@ async function renderBeforeStep(page, info) {
1206
1288
  if (auto) auto.style.display = cb.checked ? "" : "none";
1207
1289
  });
1208
1290
  }
1209
- }, info)
1291
+ }, payload)
1210
1292
  .catch(() => {});
1293
+ if (process.env.WAYGRAPH_PROVE_SHOT || process.env.WAYGRAPH_PROVE_EXIT === "1") {
1294
+ // Default: prove once on first painted panel. PROVE_EVERY=1 re-checks
1295
+ // every step (noisy; useful when hunting a mid-chain blank).
1296
+ const proveEvery = process.env.WAYGRAPH_PROVE_EVERY === "1";
1297
+ const already = globalThis.__wgProveDone;
1298
+ if (!already || proveEvery || process.env.WAYGRAPH_PROVE_EXIT === "1") {
1299
+ await page.waitForTimeout(500).catch(() => {});
1300
+ if (process.env.WAYGRAPH_PROVE_SHOT) {
1301
+ await page.screenshot({ path: process.env.WAYGRAPH_PROVE_SHOT }).catch(() => {});
1302
+ }
1303
+ const ok = await page
1304
+ .evaluate(() => {
1305
+ const beacons =
1306
+ typeof window.__wgOverlayBeacon === "function" ? window.__wgOverlayBeacon() : [];
1307
+ const panel = beacons.find((b) => b.modal === "panel" && b.ready && b.visible);
1308
+ const textOk = panel && panel.textLen >= 12;
1309
+ const opacityOk = panel && Number(panel.opacity) > 0.5;
1310
+ return {
1311
+ ok: !!(panel && textOk && opacityOk),
1312
+ beacons,
1313
+ hasRun: !!document.getElementById("wg-run"),
1314
+ readyAttr: document.getElementById("wg-panel")?.getAttribute("data-wg-ready") || null,
1315
+ };
1316
+ })
1317
+ .catch((e) => ({ ok: false, reason: String(e && e.message ? e.message : e) }));
1318
+ console.error("WAYGRAPH_PROVE " + JSON.stringify(ok));
1319
+ globalThis.__wgProveDone = true;
1320
+ if (process.env.WAYGRAPH_PROVE_EXIT === "1") {
1321
+ process.exit(ok && ok.ok ? 0 : 2);
1322
+ }
1323
+ }
1324
+ }
1211
1325
  }
1212
1326
 
1213
1327
  async function ensureSelectorInView(page, selector) {
@@ -1335,6 +1449,85 @@ function miniStepLabel(info) {
1335
1449
  );
1336
1450
  }
1337
1451
 
1452
+ /**
1453
+ * stubOnError rings + amber "expected outcome" / red error panel, then wait
1454
+ * for Stop/Retry. Used when a step throws OR when withExpectedFailure's last
1455
+ * block succeeds on the intentional fail branch (e.g. LoginPage + error banner).
1456
+ */
1457
+ async function presentFailPanel(page, opts) {
1458
+ const {
1459
+ block,
1460
+ fixtures,
1461
+ error,
1462
+ index,
1463
+ total,
1464
+ blockName,
1465
+ message,
1466
+ allNames,
1467
+ allDescriptions,
1468
+ moduleIndex,
1469
+ allEpisodes,
1470
+ title,
1471
+ episodeNumber,
1472
+ episodeTitle,
1473
+ expectedFailureReason,
1474
+ stepperMode,
1475
+ gatesFast,
1476
+ demoPace,
1477
+ gate,
1478
+ } = opts;
1479
+ if (hasAuthoredStubOnError(block, fixtures)) {
1480
+ await page
1481
+ .evaluate(() => {
1482
+ if (window.__wgRingTrack) {
1483
+ window.removeEventListener("resize", window.__wgRingTrack);
1484
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
1485
+ window.__wgRingTrack = null;
1486
+ }
1487
+ })
1488
+ .catch(() => {});
1489
+ const errPhase = await runStubPhase(block, "stubOnError", {
1490
+ fixtures,
1491
+ error,
1492
+ out: opts.out,
1493
+ });
1494
+ const errHighlights = errPhase.highlights.map((h) => {
1495
+ const styled = applyHighlightStyleDefaults(h, opts.highlightStyle);
1496
+ return {
1497
+ selector: styled.selector,
1498
+ label: formatHighlightCaption(styled),
1499
+ duration: styled.duration,
1500
+ fastMode: styled.fastMode,
1501
+ tone: styled.tone,
1502
+ size: styled.size,
1503
+ weight: styled.weight,
1504
+ zoom: styled.zoom,
1505
+ };
1506
+ });
1507
+ await cycleHighlightRings(page, errHighlights, !!gatesFast, {
1508
+ defaultHoldMs: 2000,
1509
+ pace: demoPace,
1510
+ todos: errPhase.todos,
1511
+ });
1512
+ }
1513
+ await renderStepError(page, {
1514
+ index,
1515
+ total,
1516
+ blockName,
1517
+ message,
1518
+ allNames,
1519
+ allDescriptions,
1520
+ moduleIndex,
1521
+ allEpisodes,
1522
+ title,
1523
+ episodeNumber,
1524
+ episodeTitle,
1525
+ expectedFailureReason,
1526
+ stepperMode,
1527
+ });
1528
+ return gate();
1529
+ }
1530
+
1338
1531
  async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1339
1532
  const list = highlights || [];
1340
1533
  // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
@@ -1447,7 +1640,9 @@ async function renderAfterStep(page, info) {
1447
1640
  // 900ms / 200ms holds when set.
1448
1641
  await cycleHighlightRings(page, highlights, gatesFast, {
1449
1642
  pace: info.pace,
1643
+ stepLabel: miniStepLabel(info),
1450
1644
  });
1645
+ const afterPayload = { ...info, stepLabel: miniStepLabel(info) };
1451
1646
  await page
1452
1647
  .evaluate((info) => {
1453
1648
  // Reused in place - see renderBeforeStep's own comment on this.
@@ -1525,25 +1720,63 @@ async function renderAfterStep(page, info) {
1525
1720
  escA(info.paceLabel || "") +
1526
1721
  "</span></div>"
1527
1722
  : "";
1723
+ const todosHtml = (() => {
1724
+ const list = info.todos || [];
1725
+ if (!list.length) return "";
1726
+ return (
1727
+ '<ul id=\\"wg-todos\\">' +
1728
+ list
1729
+ .map((t) => {
1730
+ const cls = t.current ? "wg-todo-current" : t.done ? "wg-todo-done" : "wg-todo-pending";
1731
+ const mark = t.done
1732
+ ? String.fromCharCode(0x2713)
1733
+ : t.current
1734
+ ? String.fromCharCode(0x2192)
1735
+ : String.fromCharCode(0x25cb);
1736
+ return (
1737
+ '<li class=\\"' +
1738
+ cls +
1739
+ '\\"><span class=\\"wg-todo-mark\\">' +
1740
+ mark +
1741
+ '</span><span>' +
1742
+ escA(t.text || "") +
1743
+ "</span></li>"
1744
+ );
1745
+ })
1746
+ .join("") +
1747
+ "</ul>"
1748
+ );
1749
+ })();
1528
1750
  panel.innerHTML =
1529
1751
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1530
1752
  episodesHtml +
1531
1753
  paceHtml +
1532
1754
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1533
1755
  "<h3>" + heading + "</h3>" +
1534
- todosHtmlFromInfo(info, escA) +
1756
+ todosHtml +
1535
1757
  resultHtml +
1536
1758
  gateHtml;
1537
1759
  if (isNewPanel) {
1538
1760
  document.documentElement.appendChild(panel);
1539
1761
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1762
+ } else {
1763
+ panel.classList.add("wg-in");
1540
1764
  }
1541
1765
  if (window.__wgWirePanelChrome) {
1542
1766
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1543
- stepLabel: miniStepLabel(info),
1767
+ stepLabel: info.stepLabel || "",
1544
1768
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1545
1769
  });
1546
1770
  }
1771
+ if (window.__wgStampModal) {
1772
+ window.__wgStampModal(panel, "panel", {
1773
+ phase: "after",
1774
+ step: info.index + 1,
1775
+ total: info.total,
1776
+ block: info.blockName || "",
1777
+ ready: true,
1778
+ });
1779
+ }
1547
1780
  panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
1548
1781
  btn.addEventListener("click", () => {
1549
1782
  const wantPretty = btn.getAttribute("data-mode") === "pretty";
@@ -1570,7 +1803,7 @@ async function renderAfterStep(page, info) {
1570
1803
  if (auto) auto.style.display = cb.checked ? "" : "none";
1571
1804
  });
1572
1805
  }
1573
- }, info)
1806
+ }, afterPayload)
1574
1807
  .catch(() => {});
1575
1808
  }
1576
1809
 
@@ -1582,6 +1815,7 @@ async function renderAfterStep(page, info) {
1582
1815
  */
1583
1816
  async function renderStepError(page, info) {
1584
1817
  await installOverlay(page, info.title);
1818
+ const errorPayload = { ...info, stepLabel: miniStepLabel(info) };
1585
1819
  await page
1586
1820
  .evaluate((info) => {
1587
1821
  // Reused in place - see renderBeforeStep's own comment on this.
@@ -1639,18 +1873,29 @@ async function renderStepError(page, info) {
1639
1873
  if (isNewPanel) {
1640
1874
  document.documentElement.appendChild(panel);
1641
1875
  requestAnimationFrame(() => panel.classList.add("wg-in"));
1876
+ } else {
1877
+ panel.classList.add("wg-in");
1642
1878
  }
1643
1879
  if (window.__wgWirePanelChrome) {
1644
1880
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1645
- stepLabel: miniStepLabel(info),
1881
+ stepLabel: info.stepLabel || "",
1646
1882
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1647
1883
  });
1648
1884
  }
1885
+ if (window.__wgStampModal) {
1886
+ window.__wgStampModal(panel, "panel", {
1887
+ phase: "error",
1888
+ step: info.index + 1,
1889
+ total: info.total,
1890
+ block: info.blockName || "",
1891
+ ready: true,
1892
+ });
1893
+ }
1649
1894
  const runBtn = document.getElementById("wg-run");
1650
1895
  if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
1651
1896
  const retryBtn = document.getElementById("wg-error-retry");
1652
1897
  if (retryBtn) retryBtn.addEventListener("click", () => window.__wgNext({ __wgRetry: "1" }));
1653
- }, info)
1898
+ }, errorPayload)
1654
1899
  .catch(() => {});
1655
1900
  }
1656
1901
 
@@ -1761,6 +2006,14 @@ async function markStepRunning(page, _opts) {
1761
2006
  const panel = document.getElementById("wg-panel");
1762
2007
  if (panel) {
1763
2008
  panel.classList.add("wg-collapsed");
2009
+ panel.setAttribute("data-wg-collapsed", "1");
2010
+ panel.setAttribute("data-wg-phase", "running");
2011
+ if (typeof window.__wgStampModal === "function") {
2012
+ window.__wgStampModal(panel, "panel", {
2013
+ phase: "running",
2014
+ ready: true,
2015
+ });
2016
+ }
1764
2017
  const toggle = panel.querySelector("[data-wg-toggle]");
1765
2018
  if (toggle) toggle.textContent = "Show";
1766
2019
  const titleEl = panel.querySelector(".wg-chrome-title");
@@ -1978,6 +2231,15 @@ async function presentSlides(page, slides, _gate, opts) {
1978
2231
  forceCollapsed: info.forceCollapsed === true ? true : undefined,
1979
2232
  });
1980
2233
  }
2234
+ if (window.__wgStampModal) {
2235
+ window.__wgStampModal(panel, "panel", {
2236
+ phase: "slide",
2237
+ step: info.index + 1,
2238
+ total: info.total,
2239
+ block: info.blockName || "",
2240
+ ready: true,
2241
+ });
2242
+ }
1981
2243
  let autoNow = false;
1982
2244
  try {
1983
2245
  autoNow = localStorage.getItem("wg-autoplay") === "1";
@@ -2451,7 +2713,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2451
2713
  // installOverlay only seeds localStorage when the key is null (so mid-run
2452
2714
  // checkbox clicks survive navigations). Without this, a prior --autoplay
2453
2715
  // session sticks forever and agents cannot flip back with --no-autoplay.
2454
- if (process.env.WAYGRAPH_AUTOPLAY !== undefined) {
2716
+ // Must run on the *demo origin* (after goto), not about:blank - localStorage
2717
+ // is origin-scoped.
2718
+ const syncAutoplayFromEnv = async () => {
2719
+ if (process.env.WAYGRAPH_AUTOPLAY === undefined) return;
2455
2720
  const on = process.env.WAYGRAPH_AUTOPLAY === "1";
2456
2721
  await page
2457
2722
  .evaluate((want) => {
@@ -2462,7 +2727,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2462
2727
  }
2463
2728
  }, on)
2464
2729
  .catch(() => {});
2465
- }
2730
+ };
2731
+ await syncAutoplayFromEnv();
2466
2732
  // Some real Blocks (e.g. zsign-all's login.block.ts) call
2467
2733
  // page.setViewportSize({ width: 1280, height: 720 }) inside their own
2468
2734
  // act() - a hardcoded override for THEIR OWN testing consistency, with no
@@ -2616,6 +2882,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2616
2882
  // still rendered on screen.
2617
2883
  if ((clearSession || r.resetSession) && i > 0) {
2618
2884
  await resetPageState(context, page, baseURL);
2885
+ // clear() wiped origin storage - re-apply CLI autoplay default.
2886
+ await syncAutoplayFromEnv();
2619
2887
  }
2620
2888
  // Episode pace: block > flow > CLI --fast (only when no authored pace).
2621
2889
  // Authoring wins: withDemoPace("slow"|2|4500) keeps slow dwell even under --fast.
@@ -2748,47 +3016,12 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2748
3016
  delete window.__wgPendingNavClickLabel;
2749
3017
  })
2750
3018
  .catch(() => {});
2751
- // stubOnError: ring the broken UI BEFORE the error panel (PIA RFC).
2752
- // stubAfter stays success-only - distinct BUG/FAIL copy on the fail path.
2753
- const fixturesOnError = r.highlightFixtures;
2754
- if (hasAuthoredStubOnError(r.block, fixturesOnError)) {
2755
- await page
2756
- .evaluate(() => {
2757
- if (window.__wgRingTrack) {
2758
- window.removeEventListener("resize", window.__wgRingTrack);
2759
- window.removeEventListener("scroll", window.__wgRingTrack, true);
2760
- window.__wgRingTrack = null;
2761
- }
2762
- })
2763
- .catch(() => {});
2764
- const errPhase = await runStubPhase(r.block, "stubOnError", {
2765
- fixtures: fixturesOnError,
2766
- error: err,
2767
- });
2768
- const errHighlights = errPhase.highlights.map((h) => {
2769
- const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2770
- return {
2771
- selector: styled.selector,
2772
- label: formatHighlightCaption(styled),
2773
- duration: styled.duration,
2774
- fastMode: styled.fastMode,
2775
- tone: styled.tone,
2776
- size: styled.size,
2777
- weight: styled.weight,
2778
- zoom: styled.zoom,
2779
- };
2780
- });
2781
- await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2782
- defaultHoldMs: 2000,
2783
- pace: pacing.demoPace,
2784
- todos: errPhase.todos,
2785
- });
2786
- }
2787
- // A thrown act()/observe()/a failed verify Trait used to just crash
2788
- // the whole Node process with a raw stack trace - the browser closes
2789
- // (main()'s own try/finally) before a human watching ever sees WHY.
2790
- // Show it on-screen and let a real click acknowledge it first.
2791
- await renderStepError(page, {
3019
+ // stubOnError + error/expected panel BEFORE acknowledging (PIA RFC).
3020
+ const errEdits = await presentFailPanel(page, {
3021
+ block: r.block,
3022
+ fixtures: r.highlightFixtures,
3023
+ highlightStyle: r.highlightStyle,
3024
+ error: err,
2792
3025
  index: i,
2793
3026
  total: resolved.length,
2794
3027
  blockName: r.block.name,
@@ -2802,14 +3035,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2802
3035
  episodeTitle: r.episodeTitle,
2803
3036
  expectedFailureReason: r.expectedFailureReason,
2804
3037
  stepperMode,
2805
- // Omit forceCollapsed on errors so Hide is not stomped.
3038
+ gatesFast: pacing.gatesFast,
3039
+ demoPace: pacing.demoPace,
3040
+ gate,
2806
3041
  });
2807
- const errEdits = await gate();
2808
3042
  if (errEdits && errEdits.__wgRetry) {
2809
- // Same clean slate a normal episode boundary gets - not just
2810
- // re-running the failed block against whatever broken/half-
2811
- // navigated state it left the page in.
2812
3043
  await resetPageState(context, page, baseURL);
3044
+ await syncAutoplayFromEnv();
2813
3045
  i = episodeStartIndex - 1;
2814
3046
  continue;
2815
3047
  }
@@ -2831,6 +3063,50 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2831
3063
  })
2832
3064
  .catch(() => {});
2833
3065
  result = stepOutcome.result;
3066
+ // withExpectedFailure last block that SUCCEEDS on the intentional fail
3067
+ // branch (e.g. submit-login -> LoginPage + error banner). Still show
3068
+ // stubOnError rings + amber "expected outcome" panel - branching no
3069
+ // longer throws, but the demo must not skip the educational overlay.
3070
+ if (r.expectedFailureReason) {
3071
+ const errEdits = await presentFailPanel(page, {
3072
+ block: r.block,
3073
+ fixtures: r.highlightFixtures,
3074
+ highlightStyle: r.highlightStyle,
3075
+ error: undefined,
3076
+ out: result,
3077
+ index: i,
3078
+ total: resolved.length,
3079
+ blockName: r.block.name,
3080
+ message:
3081
+ "Resolved " +
3082
+ (result && result.__state ? result.__state : JSON.stringify(result)) +
3083
+ " (expected failure path)",
3084
+ allNames: moduleNames,
3085
+ allDescriptions: moduleDescriptions,
3086
+ moduleIndex,
3087
+ allEpisodes,
3088
+ title,
3089
+ episodeNumber: r.episodeNumber,
3090
+ episodeTitle: r.episodeTitle,
3091
+ expectedFailureReason: r.expectedFailureReason,
3092
+ stepperMode,
3093
+ gatesFast: pacing.gatesFast,
3094
+ demoPace: pacing.demoPace,
3095
+ gate,
3096
+ });
3097
+ if (errEdits && errEdits.__wgRetry) {
3098
+ await resetPageState(context, page, baseURL);
3099
+ await syncAutoplayFromEnv();
3100
+ i = episodeStartIndex - 1;
3101
+ continue;
3102
+ }
3103
+ if (process.env.WAYGRAPH_JSON !== "1") {
3104
+ console.log(
3105
+ "waygraph: expected failure on " + r.block.name + " - " + r.expectedFailureReason,
3106
+ );
3107
+ }
3108
+ break;
3109
+ }
2834
3110
  const fixturesAfter = r.highlightFixtures;
2835
3111
  const slides = resolveSlides(r.block, { out: result, fixtures: fixturesAfter }).map((s) =>
2836
3112
  applyHighlightStyleDefaults(s, r.highlightStyle),
package/dist/engine.d.ts CHANGED
@@ -292,15 +292,15 @@ export declare function withBlockPace<In extends Checkpoint<string>, Out extends
292
292
  * {@link withTitle}.
293
293
  *
294
294
  * By itself this changes nothing about how the Flow runs - it still throws
295
- * exactly as it would otherwise. It's a hint a step-through overlay
296
- * (waygraph's `chain --step`) reads when that throw happens, to show an
297
- * informational "this is the point" panel instead of one indistinguishable
298
- * from a genuine break - the distinction a demo author needs so a viewer
299
- * (or the author, mid-build) can tell "the app just did what it's supposed
300
- * to" apart from "something in this demo is actually broken."
295
+ * Marks a Flow whose last step is an intentional product failure (demo overlay).
296
+ * Step mode shows stubOnError rings + an amber "expected outcome" panel when:
297
+ * - the last block **throws**, or
298
+ * - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
299
+ * after submit-login branched instead of throwing).
300
+ * Headless `run()` is unchanged - branching success returns the checkpoint.
301
301
  * @example const viewerBlockedFlow = withExpectedFailure(
302
- * engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
303
- * "locked_out_user can never reach /inventory.html - this is the block working as intended.",
302
+ * engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
303
+ * "locked_out_user stays on LoginPage with the error banner - working as intended.",
304
304
  * );
305
305
  */
306
306
  export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
package/dist/engine.js CHANGED
@@ -405,15 +405,15 @@ export function withBlockPace(block, pace) {
405
405
  * {@link withTitle}.
406
406
  *
407
407
  * By itself this changes nothing about how the Flow runs - it still throws
408
- * exactly as it would otherwise. It's a hint a step-through overlay
409
- * (waygraph's `chain --step`) reads when that throw happens, to show an
410
- * informational "this is the point" panel instead of one indistinguishable
411
- * from a genuine break - the distinction a demo author needs so a viewer
412
- * (or the author, mid-build) can tell "the app just did what it's supposed
413
- * to" apart from "something in this demo is actually broken."
408
+ * Marks a Flow whose last step is an intentional product failure (demo overlay).
409
+ * Step mode shows stubOnError rings + an amber "expected outcome" panel when:
410
+ * - the last block **throws**, or
411
+ * - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
412
+ * after submit-login branched instead of throwing).
413
+ * Headless `run()` is unchanged - branching success returns the checkpoint.
414
414
  * @example const viewerBlockedFlow = withExpectedFailure(
415
- * engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
416
- * "locked_out_user can never reach /inventory.html - this is the block working as intended.",
415
+ * engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
416
+ * "locked_out_user stays on LoginPage with the error banner - working as intended.",
417
417
  * );
418
418
  */
419
419
  export function withExpectedFailure(flow, reason) {
package/dist/index.d.ts CHANGED
@@ -16,3 +16,5 @@ export { runTraverse } from "./traverse-run.js";
16
16
  export type { TraverseOptions, TraverseSessionMode, TraverseWorkerResult, } from "./traverse-run.js";
17
17
  export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
18
18
  export type { TraverseCoverageReport } from "./traverse-coverage.js";
19
+ export { WG_UI_SEL, WG_READY_PANEL_SEL, WG_READY_AUTO_SEL, WG_READY_BANNER_SEL, readWgOverlayBeacon, readWgOverlayBeaconInPage, assertWgOverlayReady, } from "./overlay-beacon.js";
20
+ export type { WgModalKind, WgBeaconRow } from "./overlay-beacon.js";
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp,
8
8
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
9
9
  export { runTraverse } from "./traverse-run.js";
10
10
  export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
11
+ export { WG_UI_SEL, WG_READY_PANEL_SEL, WG_READY_AUTO_SEL, WG_READY_BANNER_SEL, readWgOverlayBeacon, readWgOverlayBeaconInPage, assertWgOverlayReady, } from "./overlay-beacon.js";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Blind-agent / Playwright checks for waygraph overlay modals.
3
+ *
4
+ * Every live modal root must carry:
5
+ * data-wg-ui="1"
6
+ * data-wg-modal="panel|banner|auto-panel"
7
+ * data-wg-ready="1" when painted and readable
8
+ *
9
+ * Agents: do not claim the stepper works unless
10
+ * `assertWgOverlayReady(page)` (or WAYGRAPH_PROVE_EXIT=1) passes.
11
+ */
12
+ import type { Page } from "@playwright/test";
13
+ export declare const WG_UI_SEL = "[data-wg-ui=\"1\"]";
14
+ export declare const WG_READY_PANEL_SEL = "[data-wg-modal=\"panel\"][data-wg-ready=\"1\"]";
15
+ export declare const WG_READY_AUTO_SEL = "[data-wg-modal=\"auto-panel\"][data-wg-ready=\"1\"]";
16
+ export declare const WG_READY_BANNER_SEL = "[data-wg-modal=\"banner\"][data-wg-ready=\"1\"]";
17
+ export type WgModalKind = "panel" | "banner" | "auto-panel";
18
+ export type WgBeaconRow = {
19
+ id: string | null;
20
+ modal: string | null;
21
+ ready: boolean;
22
+ phase: string | null;
23
+ step: string | null;
24
+ block: string | null;
25
+ collapsed: boolean;
26
+ opacity: string;
27
+ textLen: number;
28
+ w: number;
29
+ h: number;
30
+ visible: boolean;
31
+ };
32
+ /** Browser-side beacon reader (also installed as window.__wgOverlayBeacon). */
33
+ export declare function readWgOverlayBeaconInPage(): WgBeaconRow[];
34
+ export declare function readWgOverlayBeacon(page: Page): Promise<WgBeaconRow[]>;
35
+ /**
36
+ * Hard gate: a blank / missing / opacity-0 modal fails.
37
+ * Use after demo/auto paints its first overlay.
38
+ */
39
+ export declare function assertWgOverlayReady(page: Page, opts?: {
40
+ modal?: WgModalKind;
41
+ minTextLen?: number;
42
+ /** When true, collapsed mini chrome is allowed (still needs visible box + text). */
43
+ allowCollapsed?: boolean;
44
+ }): Promise<WgBeaconRow>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Blind-agent / Playwright checks for waygraph overlay modals.
3
+ *
4
+ * Every live modal root must carry:
5
+ * data-wg-ui="1"
6
+ * data-wg-modal="panel|banner|auto-panel"
7
+ * data-wg-ready="1" when painted and readable
8
+ *
9
+ * Agents: do not claim the stepper works unless
10
+ * `assertWgOverlayReady(page)` (or WAYGRAPH_PROVE_EXIT=1) passes.
11
+ */
12
+ export const WG_UI_SEL = '[data-wg-ui="1"]';
13
+ export const WG_READY_PANEL_SEL = '[data-wg-modal="panel"][data-wg-ready="1"]';
14
+ export const WG_READY_AUTO_SEL = '[data-wg-modal="auto-panel"][data-wg-ready="1"]';
15
+ export const WG_READY_BANNER_SEL = '[data-wg-modal="banner"][data-wg-ready="1"]';
16
+ /** Browser-side beacon reader (also installed as window.__wgOverlayBeacon). */
17
+ export function readWgOverlayBeaconInPage() {
18
+ const w = globalThis;
19
+ if (typeof w.__wgOverlayBeacon === "function")
20
+ return w.__wgOverlayBeacon();
21
+ return Array.from(document.querySelectorAll(WG_UI_SEL)).map((el) => {
22
+ const r = el.getBoundingClientRect();
23
+ const st = getComputedStyle(el);
24
+ return {
25
+ id: el.id || null,
26
+ modal: el.getAttribute("data-wg-modal"),
27
+ ready: el.getAttribute("data-wg-ready") === "1",
28
+ phase: el.getAttribute("data-wg-phase"),
29
+ step: el.getAttribute("data-wg-step"),
30
+ block: el.getAttribute("data-wg-block"),
31
+ collapsed: el.getAttribute("data-wg-collapsed") === "1" || el.classList.contains("wg-collapsed"),
32
+ opacity: st.opacity,
33
+ textLen: (el.textContent || "").trim().length,
34
+ w: Math.round(r.width),
35
+ h: Math.round(r.height),
36
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
37
+ };
38
+ });
39
+ }
40
+ export async function readWgOverlayBeacon(page) {
41
+ return page.evaluate(() => {
42
+ const w = window;
43
+ if (typeof w.__wgOverlayBeacon === "function") {
44
+ return w.__wgOverlayBeacon();
45
+ }
46
+ return Array.from(document.querySelectorAll('[data-wg-ui="1"]')).map((el) => {
47
+ const r = el.getBoundingClientRect();
48
+ const st = getComputedStyle(el);
49
+ return {
50
+ id: el.id || null,
51
+ modal: el.getAttribute("data-wg-modal"),
52
+ ready: el.getAttribute("data-wg-ready") === "1",
53
+ phase: el.getAttribute("data-wg-phase"),
54
+ step: el.getAttribute("data-wg-step"),
55
+ block: el.getAttribute("data-wg-block"),
56
+ collapsed: el.getAttribute("data-wg-collapsed") === "1" || el.classList.contains("wg-collapsed"),
57
+ opacity: st.opacity,
58
+ textLen: (el.textContent || "").trim().length,
59
+ w: Math.round(r.width),
60
+ h: Math.round(r.height),
61
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
62
+ };
63
+ });
64
+ });
65
+ }
66
+ /**
67
+ * Hard gate: a blank / missing / opacity-0 modal fails.
68
+ * Use after demo/auto paints its first overlay.
69
+ */
70
+ export async function assertWgOverlayReady(page, opts) {
71
+ const modal = opts?.modal ?? "panel";
72
+ const minText = opts?.minTextLen ?? 12;
73
+ const rows = await readWgOverlayBeacon(page);
74
+ const hit = rows.find((r) => r.modal === modal &&
75
+ r.ready &&
76
+ r.visible &&
77
+ r.textLen >= minText &&
78
+ (opts?.allowCollapsed || !r.collapsed || r.textLen >= minText));
79
+ if (!hit) {
80
+ throw new Error(`waygraph overlay NOT ready (modal=${modal}, minTextLen=${minText}). ` +
81
+ `beacons=${JSON.stringify(rows)}. ` +
82
+ `Do not claim stepper/modal works.`);
83
+ }
84
+ return hit;
85
+ }
@@ -18,5 +18,20 @@ declare global {
18
18
  __wgHideRing?: () => void;
19
19
  __wgPendingNavClickLabel?: string;
20
20
  __wgWirePanelChrome?: (panel: HTMLElement, storageKey: string, chromeTitle?: string) => void;
21
+ __wgStampModal?: (el: Element, kind: string, meta?: Record<string, unknown>) => void;
22
+ __wgOverlayBeacon?: () => Array<{
23
+ id: string | null;
24
+ modal: string | null;
25
+ ready: boolean;
26
+ phase: string | null;
27
+ step: string | null;
28
+ block: string | null;
29
+ collapsed: boolean;
30
+ opacity: string;
31
+ textLen: number;
32
+ w: number;
33
+ h: number;
34
+ visible: boolean;
35
+ }>;
21
36
  }
22
37
  }
@@ -146,6 +146,17 @@ export async function installDemoChrome(page, title = "waygraph auto", options =
146
146
  banner.innerHTML =
147
147
  "<span class='wg-banner-tag'>waygraph auto</span><span>" + title + "</span>";
148
148
  document.documentElement.appendChild(banner);
149
+ banner.setAttribute("data-wg-ui", "1");
150
+ banner.setAttribute("data-wg-modal", "banner");
151
+ banner.setAttribute("data-wg-ready", "1");
152
+ }
153
+ else {
154
+ const existing = document.getElementById("wg-banner");
155
+ if (existing) {
156
+ existing.setAttribute("data-wg-ui", "1");
157
+ existing.setAttribute("data-wg-modal", "banner");
158
+ existing.setAttribute("data-wg-ready", "1");
159
+ }
149
160
  }
150
161
  if (showBanner && !document.title.startsWith("[waygraph] ")) {
151
162
  document.title = "[waygraph] " + document.title;
@@ -17,7 +17,7 @@
17
17
  }
18
18
  },
19
19
  "../..": {
20
- "version": "0.12.24",
20
+ "version": "0.12.43",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
23
  "tsx": "^4.0.0"
@@ -78,6 +78,22 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
78
78
  });
79
79
  },
80
80
  stubAfter: (ctx) => {
81
+ // Fail branch lands on LoginPage with Epic sadface - do not ring the
82
+ // inventory shelf. Success (LoggedIn) hands off to shopFlow.
83
+ if (ctx.out && ctx.out.__state === "LoginPage") {
84
+ ctx.title("Login blocked");
85
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
86
+ ctx.todoIndex(2);
87
+ ctx.ring("error", {
88
+ selector: '[data-test="error"]',
89
+ label: "Login error banner",
90
+ tone: "danger",
91
+ focus: true,
92
+ color: "#f87171",
93
+ duration: true,
94
+ });
95
+ return;
96
+ }
81
97
  ctx.banner("Inventory");
82
98
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
83
99
  ctx.todoIndex(2);
@@ -1,4 +1,4 @@
1
- import { Engine, start, end, withTitle, withSessionReset } from "waygraph";
1
+ import { Engine, start, end, withTitle, withSessionReset, withExpectedFailure } from "waygraph";
2
2
  import { NavLoginBlock } from "../blocks/saucedemo-web/nav-login.block.js";
3
3
  import { SubmitLoginActionBlock } from "../blocks/saucedemo-web/methods/submit-login.method.block.js";
4
4
 
@@ -16,10 +16,14 @@ const engine = new Engine();
16
16
  // genuinely fresh cookies/storage, or the previous user's session would
17
17
  // still be live and this login attempt wouldn't mean anything.
18
18
  //
19
- // submit-login now branches: observe sees no /inventory.html navigation,
20
- // resolve -> LoginPage, verify confirms the error banner - the flow completes
21
- // at LoginPage (not a thrown verify on a fake LoggedIn resolve).
22
- export const viewerBlockedFlow = withTitle(
23
- withSessionReset(engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end])),
24
- "Viewer: Blocked Login Attempt",
19
+ // submit-login branches: observe sees no /inventory.html navigation,
20
+ // resolve -> LoginPage, verify confirms the error banner. Demo still wraps
21
+ // withExpectedFailure so step 9/9 shows stubOnError rings + amber
22
+ // "expected outcome" panel (branching no longer throws).
23
+ export const viewerBlockedFlow = withExpectedFailure(
24
+ withTitle(
25
+ withSessionReset(engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end])),
26
+ "Viewer: Blocked Login Attempt",
27
+ ),
28
+ "locked_out_user stays on LoginPage with the error banner - this is the product working as intended.",
25
29
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.42",
3
+ "version": "0.12.43",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -78,6 +78,22 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
78
78
  });
79
79
  },
80
80
  stubAfter: (ctx) => {
81
+ // Fail branch lands on LoginPage with Epic sadface - do not ring the
82
+ // inventory shelf. Success (LoggedIn) hands off to shopFlow.
83
+ if (ctx.out && ctx.out.__state === "LoginPage") {
84
+ ctx.title("Login blocked");
85
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
86
+ ctx.todoIndex(2);
87
+ ctx.ring("error", {
88
+ selector: '[data-test="error"]',
89
+ label: "Login error banner",
90
+ tone: "danger",
91
+ focus: true,
92
+ color: "#f87171",
93
+ duration: true,
94
+ });
95
+ return;
96
+ }
81
97
  ctx.banner("Inventory");
82
98
  ctx.todos(["Enter username", "Enter password", "Click Login"]);
83
99
  ctx.todoIndex(2);
@@ -1,4 +1,4 @@
1
- import { Engine, start, end, withTitle, withSessionReset } from "waygraph";
1
+ import { Engine, start, end, withTitle, withSessionReset, withExpectedFailure } from "waygraph";
2
2
  import { NavLoginBlock } from "../blocks/saucedemo-web/nav-login.block.js";
3
3
  import { SubmitLoginActionBlock } from "../blocks/saucedemo-web/methods/submit-login.method.block.js";
4
4
 
@@ -16,10 +16,14 @@ const engine = new Engine();
16
16
  // genuinely fresh cookies/storage, or the previous user's session would
17
17
  // still be live and this login attempt wouldn't mean anything.
18
18
  //
19
- // submit-login now branches: observe sees no /inventory.html navigation,
20
- // resolve -> LoginPage, verify confirms the error banner - the flow completes
21
- // at LoginPage (not a thrown verify on a fake LoggedIn resolve).
22
- export const viewerBlockedFlow = withTitle(
23
- withSessionReset(engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end])),
24
- "Viewer: Blocked Login Attempt",
19
+ // submit-login branches: observe sees no /inventory.html navigation,
20
+ // resolve -> LoginPage, verify confirms the error banner. Demo still wraps
21
+ // withExpectedFailure so step 9/9 shows stubOnError rings + amber
22
+ // "expected outcome" panel (branching no longer throws).
23
+ export const viewerBlockedFlow = withExpectedFailure(
24
+ withTitle(
25
+ withSessionReset(engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end])),
26
+ "Viewer: Blocked Login Attempt",
27
+ ),
28
+ "locked_out_user stays on LoginPage with the error banner - this is the product working as intended.",
25
29
  );