waygraph 0.12.41 → 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/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.41",
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
  );