waygraph 0.16.1 → 0.16.4

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.
Files changed (154) hide show
  1. package/README.md +12 -0
  2. package/dist/analysis/practices-check.d.ts +1 -1
  3. package/dist/analysis/practices-check.js +36 -0
  4. package/dist/beta.d.ts +6 -0
  5. package/dist/beta.js +5 -0
  6. package/dist/cli/commands/codemod-flows.js +2 -2
  7. package/dist/cli/commands/codemod-stubs.js +132 -2
  8. package/dist/cli/commands/pilot.js +3 -16
  9. package/dist/cli/commands/session-ops.js +26 -1
  10. package/dist/cli/flags.d.ts +1 -1
  11. package/dist/cli/flags.js +1 -1
  12. package/dist/cli/run-chain.js +2 -2
  13. package/dist/cli/usage.js +6 -0
  14. package/dist/cli/util.js +1 -1
  15. package/dist/demo/narration/demo-pace.d.ts +1 -1
  16. package/dist/demo/narration/demo-pace.js +1 -1
  17. package/dist/demo/narration/merge.d.ts +1 -1
  18. package/dist/demo/narration/merge.js +1 -1
  19. package/dist/demo/narration/narration.d.ts +2 -6
  20. package/dist/demo/narration/narration.js +5 -17
  21. package/dist/demo/narration/stub-ctx.js +126 -124
  22. package/dist/demo/overlay/cursor.js +1 -1
  23. package/dist/demo/overlay/inpage/banner.js +5 -0
  24. package/dist/demo/overlay/inpage/core.js +1 -6
  25. package/dist/demo/overlay/inpage/network.js +1 -1
  26. package/dist/demo/overlay/inpage/todos.js +0 -15
  27. package/dist/demo/overlay/network-capture.js +2 -2
  28. package/dist/demo/overlay/overlay-css.js +19 -76
  29. package/dist/demo/overlay/overlay-stage.js +1 -1
  30. package/dist/demo/overlay/overlay.js +3 -2
  31. package/dist/demo/overlay/phase-fixtures.js +5 -5
  32. package/dist/demo/overlay/pilot/shell.js +7 -5
  33. package/dist/demo/overlay/rings.d.ts +0 -8
  34. package/dist/demo/overlay/rings.js +0 -15
  35. package/dist/demo/overlay/ui/components/ring.js +2 -2
  36. package/dist/demo/overlay/ui/overlay.css +136 -33
  37. package/dist/demo/overlay/ui/shadow.js +69 -2
  38. package/dist/demo/stepper/after.js +4 -17
  39. package/dist/demo/stepper/before.js +3 -1
  40. package/dist/demo/stepper/demo-log.js +8 -8
  41. package/dist/demo/stepper/interaction-patch.js +3 -15
  42. package/dist/demo/stepper/panel-after.js +1 -1
  43. package/dist/demo/stepper/slides.d.ts +3 -0
  44. package/dist/demo/stepper/slides.js +18 -2
  45. package/dist/demo/stepper/stepper.js +1 -1
  46. package/dist/demo/stepper/teardown.js +0 -1
  47. package/dist/demo/verbose-log.js +1 -1
  48. package/dist/engine/block/block.d.ts +1 -12
  49. package/dist/engine/block/kind.d.ts +3 -0
  50. package/dist/engine/block/kind.js +11 -0
  51. package/dist/engine/block/kinds/method.d.ts +0 -2
  52. package/dist/engine/block/kinds/method.js +0 -1
  53. package/dist/engine/block/kinds/nav.js +1 -1
  54. package/dist/engine/errors.d.ts +12 -0
  55. package/dist/engine/errors.js +12 -0
  56. package/dist/engine/read/read-block.d.ts +61 -0
  57. package/dist/engine/read/read-block.js +94 -0
  58. package/dist/engine/read/read-key.d.ts +32 -0
  59. package/dist/engine/read/read-key.js +34 -0
  60. package/dist/engine/read/read-page.d.ts +19 -0
  61. package/dist/engine/read/read-page.js +50 -0
  62. package/dist/engine/read/readers.d.ts +82 -0
  63. package/dist/engine/read/readers.js +43 -0
  64. package/dist/engine/read/run-read.d.ts +17 -0
  65. package/dist/engine/read/run-read.js +156 -0
  66. package/dist/engine/run/connect.js +3 -2
  67. package/dist/engine/run/events.d.ts +28 -10
  68. package/dist/engine/run/events.js +52 -38
  69. package/dist/engine/run/run.d.ts +1 -19
  70. package/dist/engine/run/run.js +21 -97
  71. package/dist/engine/stub/ring.d.ts +2 -4
  72. package/dist/engine/stub/ring.js +1 -0
  73. package/dist/engine/stub/stub.d.ts +38 -25
  74. package/dist/index.d.ts +9 -7
  75. package/dist/index.js +11 -6
  76. package/dist/live/auto/auto.js +2 -3
  77. package/dist/live/auto/menu.js +3 -2
  78. package/dist/live/auto-session/auto-session.d.ts +9 -2
  79. package/dist/live/auto-session/auto-session.js +23 -20
  80. package/dist/live/auto-session/feed.d.ts +16 -0
  81. package/dist/live/auto-session/feed.js +71 -0
  82. package/dist/live/auto-session/helpers.d.ts +7 -0
  83. package/dist/live/auto-session/helpers.js +9 -0
  84. package/dist/live/auto-session/ipc.d.ts +15 -47
  85. package/dist/live/auto-session/ipc.js +34 -4
  86. package/dist/live/auto-session/protocol.d.ts +127 -0
  87. package/dist/live/auto-session/protocol.js +6 -0
  88. package/dist/live/auto-session/read.d.ts +25 -0
  89. package/dist/live/auto-session/read.js +50 -0
  90. package/dist/live/traverse/worker.d.ts +1 -0
  91. package/dist/live/traverse/worker.js +6 -1
  92. package/dist/manifest/manifest.d.ts +22 -2
  93. package/dist/manifest/manifest.js +27 -1
  94. package/dist/manifest/project.d.ts +10 -1
  95. package/dist/manifest/project.js +9 -12
  96. package/dist/manifest/read-blocks.d.ts +2 -0
  97. package/dist/manifest/read-blocks.js +23 -0
  98. package/dist/manifest/scan.d.ts +3 -2
  99. package/dist/manifest/scan.js +8 -4
  100. package/dist/runner/plan/find.js +2 -2
  101. package/dist/runner/plan/plan.js +1 -1
  102. package/dist/runner/plan/seed-mem.js +5 -5
  103. package/dist/runner/plan/spec.js +1 -1
  104. package/dist/runner/runner.d.ts +0 -1
  105. package/dist/runner/runner.js +6 -6
  106. package/dist/runtime/full-launch.d.ts +2 -1
  107. package/dist/runtime/full-launch.js +29 -27
  108. package/dist/runtime/run-log.d.ts +2 -0
  109. package/dist/runtime/run-log.js +13 -6
  110. package/examples/saucedemo/README.md +2 -2
  111. package/examples/saucedemo/src/blocks/SITE-MAP.md +3 -0
  112. package/examples/saucedemo/src/blocks/saucedemo-web/cart/cart.read.block.ts +26 -0
  113. package/examples/saucedemo/src/blocks/saucedemo-web/checkout-step-two/methods/finish-order.method.block.ts +2 -34
  114. package/examples/saucedemo/src/blocks/saucedemo-web/header.read.block.ts +25 -0
  115. package/examples/saucedemo/src/blocks/saucedemo-web/inventory/inventory.read.block.ts +28 -0
  116. package/examples/saucedemo/src/flows/api-demo.flow.ts +1 -1
  117. package/examples/saucedemo/src/flows/cart-bulk.flow.ts +1 -1
  118. package/examples/saucedemo/src/flows/cart-continue.flow.ts +1 -1
  119. package/examples/saucedemo/src/flows/catalog-logout.flow.ts +1 -1
  120. package/examples/saucedemo/src/flows/checkout-then-browse-again.flow.ts +3 -2
  121. package/examples/saucedemo/src/flows/checkout.flow.ts +13 -12
  122. package/examples/saucedemo/src/flows/login.flow.ts +1 -1
  123. package/examples/saucedemo/src/flows/shop.flow.ts +3 -2
  124. package/examples/saucedemo/src/flows/stub-on-error.flow.ts +1 -1
  125. package/examples/saucedemo/src/flows/viewer-blocked.flow.ts +1 -1
  126. package/examples/saucedemo/src/states/checkout.reads.ts +9 -0
  127. package/examples/saucedemo/src/stubs/device-showcase.ts +70 -76
  128. package/examples/saucedemo/src/stubs/finish-order.ts +38 -0
  129. package/examples/saucedemo/src/stubs/login.ts +5 -5
  130. package/package.json +5 -1
  131. package/templates/quickstart/src/blocks/saucedemo-web/checkout-step-two/methods/finish-order.method.block.ts +2 -34
  132. package/templates/quickstart/src/flows/api-demo.flow.ts +1 -1
  133. package/templates/quickstart/src/flows/cart-bulk.flow.ts +1 -1
  134. package/templates/quickstart/src/flows/cart-continue.flow.ts +1 -1
  135. package/templates/quickstart/src/flows/catalog-logout.flow.ts +1 -1
  136. package/templates/quickstart/src/flows/checkout-then-browse-again.flow.ts +3 -2
  137. package/templates/quickstart/src/flows/checkout.flow.ts +13 -12
  138. package/templates/quickstart/src/flows/login.flow.ts +1 -1
  139. package/templates/quickstart/src/flows/shop.flow.ts +3 -2
  140. package/templates/quickstart/src/flows/stub-on-error.flow.ts +1 -1
  141. package/templates/quickstart/src/flows/viewer-blocked.flow.ts +1 -1
  142. package/templates/quickstart/src/stubs/device-showcase.ts +70 -76
  143. package/templates/quickstart/src/stubs/finish-order.ts +38 -0
  144. package/templates/quickstart/src/stubs/login.ts +5 -5
  145. package/templates/scaffold/src/flows/(external)/hoppscotch/auth.flow.ts +1 -1
  146. package/templates/scaffold/src/flows/(external)/hoppscotch/get.flow.ts +1 -1
  147. package/templates/scaffold/src/flows/(external)/hoppscotch/post.flow.ts +1 -1
  148. package/templates/scaffold/src/flows/(user-stories)/email-verification.flow.ts +4 -4
  149. package/templates/scaffold/src/flows/README.md +1 -1
  150. package/templates/scaffold/src/flows/home/cart.flow.ts +7 -7
  151. package/templates/scaffold/src/flows/home/hello.flow.ts +10 -7
  152. package/templates/scaffold/src/map/(app_base)/home/_methods/assert-hello.method.block.ts +1 -9
  153. package/templates/skills/waygraph-convention.skill.md +10 -2
  154. package/templates/skills/waygraph-pilot.skill.md +7 -0
@@ -0,0 +1,156 @@
1
+ import { ignorePageGone, ignoreTimeout } from "../page-gone.js";
2
+ import { Reader } from "./readers.js";
3
+ /** In the page (self-contained, shipped by evaluateAll): reads a field, list rows or a table from the matched elements. */
4
+ function readInPage(matched, ask) {
5
+ const shown = (el) => {
6
+ const box = el.getBoundingClientRect();
7
+ const style = getComputedStyle(el);
8
+ return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none";
9
+ };
10
+ // textContent, whitespace collapsed: innerText forces layout per element (125ms vs 3ms for 8,000 table cells).
11
+ const textOf = (el) => (el.textContent ?? "").replace(/\s+/g, " ").trim();
12
+ const take = (els, plan) => {
13
+ const first = els[0];
14
+ switch (plan.kind) {
15
+ case "count":
16
+ return els.length;
17
+ case "visible":
18
+ return els.some(shown);
19
+ case "texts":
20
+ return els.map(textOf);
21
+ case "attr":
22
+ return first ? first.getAttribute(plan.attr ?? "") : null;
23
+ case "value":
24
+ return first && "value" in first ? String(first.value) : null;
25
+ default:
26
+ // text, and number (parsed in Node from the text)
27
+ return first ? textOf(first) : null;
28
+ }
29
+ };
30
+ const within = (root, plan) => (plan.selector ? [...root.querySelectorAll(plan.selector)] : [root]);
31
+ if (ask.mode === "field")
32
+ return take(matched, ask.plan);
33
+ if (ask.mode === "list") {
34
+ return matched.map((row) => {
35
+ const out = {};
36
+ for (const [name, plan] of Object.entries(ask.fields))
37
+ out[name] = take(within(row, plan), plan);
38
+ return out;
39
+ });
40
+ }
41
+ const table = matched[0];
42
+ if (!table)
43
+ return null;
44
+ const HEAD = "th, [role=columnheader]";
45
+ const CELL = "td, th, [role=cell], [role=gridcell], [role=rowheader], [role=columnheader]";
46
+ const rows = [...table.querySelectorAll("tr, [role=row]")];
47
+ const headRow = rows.find((r) => r.querySelector(HEAD) && !r.querySelector("td, [role=cell], [role=gridcell]"));
48
+ const headers = headRow ? [...headRow.querySelectorAll(CELL)].map(textOf) : [];
49
+ const index = {};
50
+ const absent = [];
51
+ for (const [name, header] of Object.entries(ask.columns)) {
52
+ const at = headers.findIndex((h) => h.toLowerCase() === header.toLowerCase());
53
+ if (at < 0)
54
+ absent.push(name);
55
+ else
56
+ index[name] = at;
57
+ }
58
+ const body = rows.filter((r) => r !== headRow && r.querySelector("td, [role=cell], [role=gridcell], [role=rowheader]"));
59
+ const out = body.map((r) => {
60
+ const cells = [...r.querySelectorAll(CELL)];
61
+ const row = {};
62
+ for (const name of Object.keys(ask.columns)) {
63
+ const cell = index[name] === undefined ? undefined : cells[index[name]];
64
+ row[name] = cell ? textOf(cell) : null;
65
+ }
66
+ return row;
67
+ });
68
+ const declared = Number(table.getAttribute("aria-rowcount"));
69
+ return { rows: out, absent, rowCount: Number.isFinite(declared) && declared > 0 ? declared - (headRow ? 1 : 0) : out.length };
70
+ }
71
+ /** In the page: resolves once the DOM has had no change for `quietMs` (or after `capMs`, whichever comes first). */
72
+ function domQuiet({ quietMs, capMs }) {
73
+ return new Promise((resolve) => {
74
+ const done = () => {
75
+ observer.disconnect();
76
+ clearTimeout(quiet);
77
+ clearTimeout(cap);
78
+ resolve();
79
+ };
80
+ let quiet = setTimeout(done, quietMs);
81
+ const cap = setTimeout(done, capMs);
82
+ const observer = new MutationObserver(() => {
83
+ clearTimeout(quiet);
84
+ quiet = setTimeout(done, quietMs);
85
+ });
86
+ observer.observe(document, { subtree: true, childList: true, characterData: true, attributes: true });
87
+ });
88
+ }
89
+ /**
90
+ * Waits for the page to stop changing before a read. A step's verify can pass before the app has rendered where it
91
+ * landed (a URL check passes the moment a client-side route changes), so a read right after it can see the old page.
92
+ */
93
+ export async function settlePage(page, quietMs = 150, capMs = 2000) {
94
+ await page.waitForLoadState("domcontentloaded", { timeout: capMs }).catch(ignoreTimeout);
95
+ await page.evaluate(domQuiet, { quietMs, capMs }).catch(ignorePageGone);
96
+ }
97
+ /** The first number in a display text: `"$1,299.00"` -> 1299, `"41 likes"` -> 41, no number -> null. */
98
+ export function firstNumber(text) {
99
+ if (typeof text !== "string")
100
+ return null;
101
+ const m = /-?\d[\d,]*(?:\.\d+)?/.exec(text);
102
+ return m ? Number(m[0].replace(/,/g, "")) : null;
103
+ }
104
+ function finishField(plan, raw) {
105
+ return plan.kind === "number" ? firstNumber(raw) : raw;
106
+ }
107
+ async function readOne(page, mem, plan, path, notes) {
108
+ if (plan.kind === "fn")
109
+ return (await plan.fn(page, mem)) ?? null;
110
+ const matched = page.locator(plan.selector);
111
+ if (plan.kind === "list") {
112
+ const rows = (await matched.evaluateAll(readInPage, { mode: "list", fields: plan.fields }));
113
+ return rows.map((row) => {
114
+ for (const [name, field] of Object.entries(plan.fields))
115
+ row[name] = finishField(field, row[name]);
116
+ return row;
117
+ });
118
+ }
119
+ if (plan.kind === "table") {
120
+ const got = (await matched.evaluateAll(readInPage, { mode: "table", columns: plan.columns }));
121
+ if (!got)
122
+ return null;
123
+ const why = [];
124
+ if (got.absent.length)
125
+ why.push(`no column ${got.absent.map((c) => JSON.stringify(plan.columns[c])).join(", ")}`);
126
+ if (got.rowCount > got.rows.length)
127
+ why.push(`${got.rows.length} of ${got.rowCount} rows rendered (scroll or page to read more)`);
128
+ if (why.length)
129
+ notes.set(path, why.join("; "));
130
+ return got.rows;
131
+ }
132
+ return finishField(plan, await matched.evaluateAll(readInPage, { mode: "field", plan }));
133
+ }
134
+ /** Reads every field of every spec, in parallel, into leaves by path. */
135
+ export async function runReadSpecs(page, mem, specs) {
136
+ const values = new Map();
137
+ const notes = new Map();
138
+ const jobs = [];
139
+ const walk = (fields, prefix) => {
140
+ for (const [name, value] of Object.entries(fields)) {
141
+ if (value === undefined)
142
+ continue;
143
+ const path = `${prefix}.${name}`;
144
+ if (value instanceof Reader) {
145
+ values.set(path, null);
146
+ jobs.push(readOne(page, mem, value.plan, path, notes).then((v) => void values.set(path, v)));
147
+ }
148
+ else
149
+ walk(value, path);
150
+ }
151
+ };
152
+ for (const spec of specs)
153
+ walk(spec.fields, spec.namespace);
154
+ await Promise.all(jobs);
155
+ return { values, notes };
156
+ }
@@ -1,5 +1,6 @@
1
1
  import { runVerify, runPrecondition } from "../trait/trait.js";
2
2
  import { isConnected, lastLeaf, stepEnded, stepStarted } from "./events.js";
3
+ import { blockKindOf } from "../block/kind.js";
3
4
  /**
4
5
  * Composes two Blocks into one, typechecked so this only compiles when A's output
5
6
  * tag equals B's input tag. The composed Block's act runs A's full instruction
@@ -25,7 +26,7 @@ export function connect(a, b, layouts) {
25
26
  instruction: {
26
27
  async act(page, input, mem) {
27
28
  if (!isConnected(a))
28
- stepStarted(a.name, input.__state);
29
+ stepStarted(a.name, blockKindOf(a), input.__state);
29
30
  await runPrecondition(a.instruction.precondition, input, page, mem, a.name);
30
31
  await a.instruction.act(page, input, mem);
31
32
  const aObserved = a.instruction.observe
@@ -42,7 +43,7 @@ export function connect(a, b, layouts) {
42
43
  }
43
44
  stepEnded(lastLeaf(a), mid.__state);
44
45
  if (!isConnected(b))
45
- stepStarted(b.name, mid.__state);
46
+ stepStarted(b.name, blockKindOf(b), mid.__state);
46
47
  await runPrecondition(b.instruction.precondition, mid, page, mem, b.name);
47
48
  await b.instruction.act(page, mid, mem);
48
49
  },
@@ -1,3 +1,4 @@
1
+ import type { BlockKind } from "../block/kind.js";
1
2
  export type RunEvent = {
2
3
  type: "run:start";
3
4
  runId: string;
@@ -9,6 +10,7 @@ export type RunEvent = {
9
10
  at: number;
10
11
  index: number;
11
12
  block: string;
13
+ kind: BlockKind;
12
14
  from: string;
13
15
  } | {
14
16
  type: "step:end";
@@ -16,6 +18,7 @@ export type RunEvent = {
16
18
  at: number;
17
19
  index: number;
18
20
  block: string;
21
+ kind: BlockKind;
19
22
  to: string;
20
23
  ms: number;
21
24
  } | {
@@ -24,9 +27,18 @@ export type RunEvent = {
24
27
  at: number;
25
28
  index: number;
26
29
  block: string;
30
+ kind: BlockKind;
27
31
  error: string;
28
32
  ms: number;
29
- } | {
33
+ } | ({
34
+ type: "step:read";
35
+ runId: string;
36
+ at: number;
37
+ index: number;
38
+ block: string;
39
+ kind: BlockKind;
40
+ checkpoint: string;
41
+ } & StepRead) | {
30
42
  type: "run:end";
31
43
  runId: string;
32
44
  at: number;
@@ -41,22 +53,28 @@ export type RunEvent = {
41
53
  kind: "video" | "trace" | "screenshot";
42
54
  path: string;
43
55
  };
56
+ /** What the page showed after a step, from the Read Blocks for the Checkpoint it landed on (beta). */
57
+ export interface StepRead {
58
+ read: Record<string, unknown>;
59
+ missing: string[];
60
+ notes: Record<string, string>;
61
+ readBlocks: string[];
62
+ /** A Read Block that failed (two reading one key, say): the step still ran; the read is empty. */
63
+ error?: string;
64
+ }
44
65
  /** What {@link onRunEvent} calls with each event. */
45
66
  export type RunEventListener = (event: RunEvent) => void;
46
67
  /** Subscribes to every run event in this process; returns the unsubscribe function. */
47
68
  export declare function onRunEvent(listener: RunEventListener): () => void;
48
69
  /**
49
- * Opens a run, or joins the one already open (a flow nested in another, or a runner driving a chain step by step). Returns
50
- * whether this call opened it, so only the opener closes it.
70
+ * Runs `body` as a run: opens one (`run:start` ... `run:end`), or joins the run its caller is already in (a flow nested in
71
+ * another, a runner driving a chain step by step). `resultOf` names the outcome for `run:end`.
51
72
  */
52
- export declare function beginRun(label?: string): boolean;
53
- /** Closes the run when the matching `beginRun` opened it. */
54
- export declare function endRun(opened: boolean, outcome: {
55
- result?: string;
56
- error?: unknown;
57
- }): void;
58
- export declare function stepStarted(block: string, from: string): void;
73
+ export declare function inRun<T>(label: string | undefined, body: () => Promise<T>, resultOf?: (value: T) => string | undefined): Promise<T>;
74
+ export declare function stepStarted(block: string, kind: BlockKind, from: string): void;
59
75
  export declare function stepEnded(block: string, to: string): void;
76
+ /** What the page showed after the current step (emitted between its verify and its `step:end`). */
77
+ export declare function stepRead(checkpoint: string, read: StepRead): void;
60
78
  /** The step that started last failed. */
61
79
  export declare function stepFailed(error: unknown): void;
62
80
  /** The id of the run open right now, if any. */
@@ -1,5 +1,6 @@
1
1
  // Run events: what happened, step by step, during a run. Core emits them; subscribers (the run log, the demo overlay,
2
2
  // reports, Waygraph UI) listen. Emitting never throws into the run.
3
+ import { AsyncLocalStorage } from "node:async_hooks";
3
4
  const listeners = new Set();
4
5
  /** Subscribes to every run event in this process; returns the unsubscribe function. */
5
6
  export function onRunEvent(listener) {
@@ -16,68 +17,81 @@ function emit(event) {
16
17
  }
17
18
  }
18
19
  }
19
- let open;
20
20
  /**
21
- * Opens a run, or joins the one already open (a flow nested in another, or a runner driving a chain step by step). Returns
22
- * whether this call opened it, so only the opener closes it.
21
+ * The run each async call chain is in. Per chain, not per process: two runs at once (parallel traverse workers, a UI
22
+ * hosting several) each get their own id and step counter; a flow nested in another joins its parent's run.
23
23
  */
24
- export function beginRun(label) {
25
- if (open) {
26
- open.depth += 1;
27
- return false;
28
- }
29
- const at = Date.now();
30
- open = { runId: `${new Date(at).toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`, startedAt: at, depth: 1, index: 0 };
31
- emit({ type: "run:start", runId: open.runId, at, ...(label ? { label } : {}) });
32
- return true;
33
- }
34
- /** Closes the run when the matching `beginRun` opened it. */
35
- export function endRun(opened, outcome) {
36
- if (!open)
37
- return;
38
- open.depth -= 1;
39
- if (!opened)
40
- return;
24
+ const scope = new AsyncLocalStorage();
25
+ /**
26
+ * Runs `body` as a run: opens one (`run:start` ... `run:end`), or joins the run its caller is already in (a flow nested in
27
+ * another, a runner driving a chain step by step). `resultOf` names the outcome for `run:end`.
28
+ */
29
+ export async function inRun(label, body, resultOf) {
30
+ if (scope.getStore())
31
+ return body();
41
32
  const at = Date.now();
42
- emit({
43
- type: "run:end",
44
- runId: open.runId,
45
- at,
46
- ok: outcome.error === undefined,
47
- ...(outcome.result !== undefined ? { result: outcome.result } : {}),
48
- ...(outcome.error !== undefined ? { error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) } : {}),
49
- ms: at - open.startedAt,
50
- });
51
- open = undefined;
33
+ const run = { runId: `${new Date(at).toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`, startedAt: at, index: 0 };
34
+ emit({ type: "run:start", runId: run.runId, at, ...(label ? { label } : {}) });
35
+ const end = (outcome) => {
36
+ const done = Date.now();
37
+ emit({
38
+ type: "run:end",
39
+ runId: run.runId,
40
+ at: done,
41
+ ok: !("error" in outcome),
42
+ ...(outcome.result !== undefined ? { result: outcome.result } : {}),
43
+ ...("error" in outcome ? { error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) } : {}),
44
+ ms: done - run.startedAt,
45
+ });
46
+ };
47
+ try {
48
+ const value = await scope.run(run, body);
49
+ end({ result: resultOf?.(value) });
50
+ return value;
51
+ }
52
+ catch (err) {
53
+ end({ error: err });
54
+ throw err;
55
+ }
52
56
  }
53
- export function stepStarted(block, from) {
57
+ export function stepStarted(block, kind, from) {
58
+ const open = scope.getStore();
54
59
  if (!open)
55
60
  return;
56
61
  const at = Date.now();
57
- open.step = { index: open.index++, block, startedAt: at };
58
- emit({ type: "step:start", runId: open.runId, at, index: open.step.index, block, from });
62
+ open.step = { index: open.index++, block, kind, startedAt: at };
63
+ emit({ type: "step:start", runId: open.runId, at, index: open.step.index, block, kind, from });
59
64
  }
60
65
  export function stepEnded(block, to) {
66
+ const open = scope.getStore();
61
67
  if (!open?.step)
62
68
  return;
63
69
  const at = Date.now();
64
- emit({ type: "step:end", runId: open.runId, at, index: open.step.index, block, to, ms: at - open.step.startedAt });
70
+ emit({ type: "step:end", runId: open.runId, at, index: open.step.index, block, kind: open.step.kind, to, ms: at - open.step.startedAt });
65
71
  open.step = undefined;
66
72
  }
73
+ /** What the page showed after the current step (emitted between its verify and its `step:end`). */
74
+ export function stepRead(checkpoint, read) {
75
+ const open = scope.getStore();
76
+ if (!open?.step)
77
+ return;
78
+ emit({ type: "step:read", runId: open.runId, at: Date.now(), index: open.step.index, block: open.step.block, kind: open.step.kind, checkpoint, ...read });
79
+ }
67
80
  /** The step that started last failed. */
68
81
  export function stepFailed(error) {
82
+ const open = scope.getStore();
69
83
  if (!open?.step)
70
84
  return;
71
85
  const at = Date.now();
72
- const { index, block, startedAt } = open.step;
73
- emit({ type: "step:error", runId: open.runId, at, index, block, error: error instanceof Error ? error.message : String(error), ms: at - startedAt });
86
+ const { index, block, kind, startedAt } = open.step;
87
+ emit({ type: "step:error", runId: open.runId, at, index, block, kind, error: error instanceof Error ? error.message : String(error), ms: at - startedAt });
74
88
  open.step = undefined;
75
89
  }
76
90
  /** The id of the run open right now, if any. */
77
91
  export function currentRunId() {
78
- return open?.runId;
92
+ return scope.getStore()?.runId;
79
93
  }
80
- export function recordArtifact(kind, path, runId = open?.runId) {
94
+ export function recordArtifact(kind, path, runId = scope.getStore()?.runId) {
81
95
  if (runId)
82
96
  emit({ type: "run:artifact", runId, at: Date.now(), kind, path });
83
97
  }
@@ -2,7 +2,7 @@ import { MemPage } from "../mem/mem.js";
2
2
  import type { Block } from "../block/block.js";
3
3
  import type { Checkpoint } from "../checkpoint/checkpoint.js";
4
4
  import type { Trait } from "../trait/trait.js";
5
- import type { BrowserContext, Locator, Page } from "@playwright/test";
5
+ import type { BrowserContext, Page } from "@playwright/test";
6
6
  import type { SomeBlock } from "../block/block.js";
7
7
  /**
8
8
  * Checks `block.requires` against `mem` and throws, naming every missing key at
@@ -125,21 +125,3 @@ export interface RunGraphOptions {
125
125
  * }
126
126
  */
127
127
  export declare function spawnTab<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, page: Page, mem: MemPage, maxSteps?: number): Promise<TOut>;
128
- /**
129
- * Wraps one `act()` interaction with a caption and highlight, for a Block
130
- * author who wants to explicitly narrate a specific step rather than rely
131
- * on the generic per-fill/per-click narration `waygraph chain
132
- * WAYGRAPH_STEP=1` already does automatically -
133
- * `await narrate(submitButton, "confirms the order", () => submitButton.click())`
134
- * reads at the call site instead of being inferred.
135
- *
136
- * A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
137
- * for a `window.__wgPositionRing` hook the step-mode overlay installs on
138
- * the page before doing anything, so a headless/CI/automated run (nobody
139
- * watching, per-character delays and highlight pauses would just be wasted
140
- * time) pays zero extra cost - not even the boundingBox() lookup runs. The
141
- * `action` itself always runs regardless; only the narration is
142
- * conditional.
143
- * @example await narrate(submitButton, "confirms the order", () => submitButton.click())
144
- */
145
- export declare function narrate<T>(locator: Locator, caption: string, action: () => Promise<T>): Promise<T>;
@@ -1,8 +1,9 @@
1
1
  import { WaygraphError, MSG_PREFLIGHT_MISSING_KEY, MSG_RUNGRAPH_MAX_STEPS, MSG_RUNGRAPH_NOT_TERMINAL } from "../errors.js";
2
2
  import { checkpoint } from "../checkpoint/checkpoint.js";
3
- import { beginRun, endRun, isConnected, lastLeaf, stepEnded, stepFailed, stepStarted } from "./events.js";
3
+ import { inRun, isConnected, lastLeaf, stepEnded, stepFailed, stepRead, stepStarted } from "./events.js";
4
+ import { readBlocksFor, readCheckpoint } from "../read/read-block.js";
5
+ import { blockKindOf } from "../block/kind.js";
4
6
  import { runPrecondition, runVerify } from "../trait/trait.js";
5
- import { ignorePageGone } from "../page-gone.js";
6
7
  /**
7
8
  * Checks `block.requires` against `mem` and throws, naming every missing key at
8
9
  * once, if any are unset. `connect()` unions each Block's `requires` into the
@@ -79,9 +80,13 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
79
80
  preflight(mem, entry);
80
81
  const page = options?.page ?? (await context.newPage());
81
82
  const closeOnFinish = options?.closeOnFinish ?? true;
82
- const opened = beginRun();
83
- let result;
84
- let failure;
83
+ return inRun(undefined, () => playGraph(entry, terminals, page, mem, maxSteps, options), (out) => out.__state).finally(async () => {
84
+ if (closeOnFinish)
85
+ await page.close();
86
+ });
87
+ }
88
+ /** The step loop of {@link runGraph}, inside its run: act, observe, resolve, verify, then the next Block. */
89
+ async function playGraph(entry, terminals, page, mem, maxSteps, options) {
85
90
  try {
86
91
  let current = entry;
87
92
  let input = checkpoint("__start__");
@@ -91,7 +96,7 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
91
96
  throw new WaygraphError("WG_RUNGRAPH_MAX_STEPS", MSG_RUNGRAPH_MAX_STEPS(maxSteps));
92
97
  }
93
98
  if (!isConnected(current))
94
- stepStarted(current.name, input.__state);
99
+ stepStarted(current.name, blockKindOf(current), input.__state);
95
100
  await runPrecondition(current.instruction.precondition, input, page, mem, current.name);
96
101
  await current.instruction.act(page, input, mem);
97
102
  const observed = current.instruction.observe
@@ -100,13 +105,13 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
100
105
  const checkpoint = await current.instruction.resolve(observed);
101
106
  await runVerify(current.instruction.verify, checkpoint, page, mem, current.name);
102
107
  await runLayouts(options?.layouts, checkpoint.__state, checkpoint, page, mem, current.name);
108
+ await readLanding(page, mem, checkpoint.__state);
103
109
  stepEnded(lastLeaf(current), checkpoint.__state);
104
110
  const next = current.next?.(checkpoint);
105
111
  if (!next) {
106
112
  if (terminals && !terminals.has(checkpoint.__state)) {
107
113
  throw new WaygraphError("WG_RUNGRAPH_NOT_TERMINAL", MSG_RUNGRAPH_NOT_TERMINAL(current.name, checkpoint.__state, [...terminals].join(", ")));
108
114
  }
109
- result = checkpoint.__state;
110
115
  return checkpoint;
111
116
  }
112
117
  current = next;
@@ -114,16 +119,9 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
114
119
  }
115
120
  }
116
121
  catch (err) {
117
- failure = err;
118
122
  stepFailed(err);
119
123
  throw err;
120
124
  }
121
- finally {
122
- endRun(opened, failure === undefined ? { ...(result !== undefined ? { result } : {}) } : { error: failure });
123
- if (closeOnFinish) {
124
- await page.close();
125
- }
126
- }
127
125
  }
128
126
  /**
129
127
  * Drives a genuinely separate second tab through its own Block/Flow, in the
@@ -144,90 +142,16 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000,
144
142
  export async function spawnTab(entry, page, mem, maxSteps = 5000) {
145
143
  return runGraph(entry, undefined, page.context(), mem, maxSteps);
146
144
  }
147
- /**
148
- * Wraps one `act()` interaction with a caption and highlight, for a Block
149
- * author who wants to explicitly narrate a specific step rather than rely
150
- * on the generic per-fill/per-click narration `waygraph chain
151
- * WAYGRAPH_STEP=1` already does automatically -
152
- * `await narrate(submitButton, "confirms the order", () => submitButton.click())`
153
- * reads at the call site instead of being inferred.
154
- *
155
- * A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
156
- * for a `window.__wgPositionRing` hook the step-mode overlay installs on
157
- * the page before doing anything, so a headless/CI/automated run (nobody
158
- * watching, per-character delays and highlight pauses would just be wasted
159
- * time) pays zero extra cost - not even the boundingBox() lookup runs. The
160
- * `action` itself always runs regardless; only the narration is
161
- * conditional.
162
- * @example await narrate(submitButton, "confirms the order", () => submitButton.click())
163
- */
164
- export async function narrate(locator, caption, action) {
165
- const page = locator.page();
166
- const active = await page
167
- .evaluate(() => typeof globalThis.__wgPositionRing === "function")
168
- .catch(() => false);
169
- if (!active)
170
- return action();
171
- let owning = false;
172
- try {
173
- const box = await locator.boundingBox();
174
- if (box) {
175
- await page
176
- .evaluate(({ box, caption }) => {
177
- const w = globalThis;
178
- if (w.__wgPositionRing)
179
- w.__wgPositionRing(box, caption, "planned");
180
- // The CLI's own automatic per-fill/per-click narration checks
181
- // this before showing (and overwriting) its own guess - an
182
- // explicitly authored caption should win, not get immediately
183
- // replaced a moment later by the generic one.
184
- w.__wgLastNarrate = Date.now();
185
- // Also claim the ring itself: the auto-highlight click/fill
186
- // patches each hide the ring right after THEIR own step
187
- // finishes, which - if action() below does more than one thing
188
- // (e.g. a click then a wait for a toast) - would wipe this
189
- // caption after just the first sub-step instead of the whole
190
- // narrated action. Their hideRing() calls no-op while this is
191
- // true; narrate() below is the one that actually clears it.
192
- w.__wgNarrateOwnsRing = true;
193
- }, { box, caption })
194
- .catch(ignorePageGone);
195
- owning = true;
196
- // The ring alone is pointless if action() fires on the very next
197
- // tick - a click that navigates (e.g. a form submit) wipes the ring
198
- // before a human can read the caption at all. Give it the same
199
- // "pop for a few seconds" dwell as the CLI's own auto-highlight
200
- // clicks before the real action runs.
201
- await new Promise((resolve) => setTimeout(resolve, 650));
202
- // Re-stamp __wgLastNarrate right before handing off to action() - the
203
- // CLI's auto-highlight patches only treat a narrate() as "just
204
- // handled" within a short window (500ms), which the dwell above
205
- // already burned through. Without this, action()'s own click/fill
206
- // (same patched Locator prototype) would stomp this caption with its
207
- // own generic one a moment after it finally became visible.
208
- await page
209
- .evaluate(() => {
210
- globalThis.__wgLastNarrate = Date.now();
211
- })
212
- .catch(ignorePageGone);
213
- }
214
- }
215
- catch {
216
- // best-effort - the real action below still runs either way
217
- }
145
+ /** After a step's verify: what the page shows, from the Read Blocks for where it landed (none loaded: nothing to do). */
146
+ async function readLanding(page, mem, checkpoint) {
147
+ if (readBlocksFor(checkpoint).length === 0)
148
+ return;
218
149
  try {
219
- return await action();
150
+ const { read, blocks } = await readCheckpoint(page, mem, checkpoint);
151
+ stepRead(checkpoint, { read: read.toJSON(), missing: [...read.missing], notes: { ...read.notes }, readBlocks: blocks });
220
152
  }
221
- finally {
222
- if (owning) {
223
- await page
224
- .evaluate(() => {
225
- const w = globalThis;
226
- w.__wgNarrateOwnsRing = false;
227
- if (w.__wgHideRing)
228
- w.__wgHideRing();
229
- })
230
- .catch(ignorePageGone);
231
- }
153
+ catch (err) {
154
+ // A broken Read Block is reported on the step, never fails the run it observes.
155
+ stepRead(checkpoint, { read: {}, missing: [], notes: {}, readBlocks: [], error: err instanceof Error ? err.message : String(err) });
232
156
  }
233
157
  }
@@ -1,4 +1,3 @@
1
- import type { Checkpoint } from "../checkpoint/checkpoint.js";
2
1
  /** Shared dwell fields on stubs, fixtures, and yap slides. */
3
2
  export type FixtureDurationFields = {
4
3
  /**
@@ -83,8 +82,8 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
83
82
  /** A static stub phase: named ring slots to paint before or after a step. */
84
83
  export type HighlightStubPhase = Record<string, WaygraphHighlightStub>;
85
84
  /**
86
- * One yap/slide in a multi-step demo caption sequence.
87
- * Not block lifecycle - demo pauses (Next / auto-next) between slides.
85
+ * One yap/slide in a multi-step demo caption sequence, painted with {@link StubCtx.slides}.
86
+ * The demo pauses on each (Next, or auto-next) before moving on.
88
87
  */
89
88
  export interface WaygraphSlide extends FixtureDurationFields {
90
89
  /** Primary caption (required). */
@@ -119,7 +118,6 @@ export type HighlightStyleDefaults = {
119
118
  size?: HighlightSize;
120
119
  weight?: HighlightWeight;
121
120
  };
122
- export type WaygraphSlidesOrFn<Out extends Checkpoint<string>> = readonly WaygraphSlide[] | ((out: Out) => readonly WaygraphSlide[]);
123
121
  /** A ring or caption after its defaults and overrides are merged, ready to paint. */
124
122
  export type ResolvedHighlight = WaygraphHighlightStub;
125
123
  /** Map author aliases (`error` -> danger) to a canonical tone. */
@@ -1,3 +1,4 @@
1
+ // A ring: one highlighted element with its caption, and its tone, size and weight; slides between steps.
1
2
  /** Map author aliases (`error` -> danger) to a canonical tone. */
2
3
  export function normalizeHighlightTone(raw) {
3
4
  if (typeof raw !== "string")