waygraph 0.12.19 → 0.12.21

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
@@ -228,11 +228,14 @@ real acts). `--ff-expand` is the older expand-only flag (same flatten). Saucedem
228
228
  `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
229
229
  Sign In narration.
230
230
 
231
- `waygraph traverse` walks the Block graph (Phase B+D). `--parallel N` (default
231
+ `waygraph traverse` walks the Block graph (Phase B-E). `--parallel N` (default
232
232
  `--session clone`) bootstraps one context, forks N workers with `storageState` + mem
233
233
  snapshot, partitions edges by hash, and claims via in-process edge leases (audit under
234
- `.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. See
235
- `docs/proposals/traverse-ffcompose-rfc.md`.
234
+ `.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. Phase E
235
+ writes `.waygraph-traverse/coverage.json` and prints `[Coverage edges=H/T ratio=R%...]`;
236
+ `--min-edge-coverage 80%` fails the suite with exit 2 when the ratio is below the gate
237
+ (even if every worker leaf-PASSed). `--coverage-out PATH` / `--no-coverage-report` adjust
238
+ the JSON. See `docs/proposals/traverse-ffcompose-rfc.md`.
236
239
 
237
240
  **Richer demo narration:** automation rings are **gray**; authored stubs/slides can use
238
241
  `tone: "info" | "warning" | "danger" | "success"` (iconified captions), plus
@@ -194,20 +194,23 @@ async function runOneBlock(engine, entry, context, page, mem, headful) {
194
194
  const flow = engine.defineFlow([start, entry.block, end]);
195
195
  const BLOCK_TIMEOUT_MS = 45_000;
196
196
  let timer;
197
+ let result;
197
198
  try {
198
- await Promise.race([
199
+ const ran = await Promise.race([
199
200
  flow.run(context, mem, { page, closeOnFinish: false }),
200
201
  new Promise((_, reject) => {
201
202
  timer = setTimeout(() => reject(new Error(`Block "${entry.block.name}" timed out after ${BLOCK_TIMEOUT_MS / 1000}s - ` +
202
203
  "click Quit and retry, or check network / mem credentials")), BLOCK_TIMEOUT_MS);
203
204
  }),
204
205
  ]);
206
+ result = ran.result;
205
207
  }
206
208
  finally {
207
209
  if (timer)
208
210
  clearTimeout(timer);
209
211
  }
210
212
  await page.evaluate("delete window.__wgPendingNavClickLabel").catch(() => { });
213
+ return result;
211
214
  }
212
215
  const PANEL_CSS = "#wg-auto-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
213
216
  "max-width:min(92vw,720px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
@@ -540,13 +543,16 @@ export async function runAutoExplore(projectDir, options = {}) {
540
543
  }
541
544
  wiredPage = target;
542
545
  }
546
+ let here = null;
543
547
  try {
544
548
  for (;;) {
545
549
  page = await ensureLivePage(context, page, startUrl);
546
550
  await wirePage(page);
547
551
  const pickWait = armPickWait();
548
552
  pickSlot.resolve = pickWait.resolve;
549
- const here = await detectHere(page, library.navBlocks);
553
+ if (here === null) {
554
+ here = await detectHere(page, library.navBlocks);
555
+ }
550
556
  if (cli)
551
557
  console.error("waygraph auto: scanning page for moves ...");
552
558
  const menu = await buildExploreMenu(page, graph, library, here);
@@ -595,10 +601,11 @@ export async function runAutoExplore(projectDir, options = {}) {
595
601
  }
596
602
  const pickLabel = edge.label ?? entry.block.name;
597
603
  console.error(`waygraph auto: running [${pick.index + 1}] ${pickLabel} ...`);
598
- await runOneBlock(engine, entry, context, page, mem, !cli);
599
- const landed = await detectHere(page, library.navBlocks);
600
- lastRunNote = `${entry.block.name} -> ${landed ?? edge.to}`;
601
- console.error(`waygraph auto: ran ${entry.block.name} -> ${landed ?? edge.to}`);
604
+ const result = await runOneBlock(engine, entry, context, page, mem, !cli);
605
+ // Trust block output over locate() - nav verify traits collide on /dashboard.
606
+ here = result.__state ?? edge.to;
607
+ lastRunNote = `${entry.block.name} -> ${here}`;
608
+ console.error(`waygraph auto: ran ${entry.block.name} -> ${here}`);
602
609
  }
603
610
  catch (err) {
604
611
  lastRunNote = `${entry.block.name} failed`;
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./gra
21
21
  import { runAutoExplore } from "./auto-explore-run.js";
22
22
  import { runAgentDive } from "./agent-dive.js";
23
23
  import { runTraverse } from "./traverse-run.js";
24
+ import { parseMinEdgeCoverage } from "./traverse-coverage.js";
24
25
  import { isFileSelectToken, parseBlocksSelect, } from "./blocks-select.js";
25
26
  // ---------------------------------------------------------------------------
26
27
  // Filesystem
@@ -3950,7 +3951,7 @@ Primary (less is more):
3950
3951
 
3951
3952
  Also:
3952
3953
  waygraph list | nav | validate | check | graph | init <name>
3953
- waygraph traverse [project] Graph crawl (Phase B+D)
3954
+ waygraph traverse [project] Graph crawl (Phase B-E)
3954
3955
  --blocks <glob|/re/|sub> Phase C: filter *.block.ts discovery
3955
3956
  --parallel N Phase D: N clone workers (max 4)
3956
3957
  --session clone|inherit Phase D: default clone
@@ -4076,6 +4077,9 @@ async function main() {
4076
4077
  let blocksFilter;
4077
4078
  let parallel;
4078
4079
  let session;
4080
+ let minEdgeCoverageRaw;
4081
+ let coverageOut;
4082
+ let noCoverageReport = false;
4079
4083
  let projectDir = process.cwd();
4080
4084
  for (let i = 0; i < rest.length; i++) {
4081
4085
  const a = rest[i];
@@ -4115,6 +4119,22 @@ async function main() {
4115
4119
  session = v;
4116
4120
  continue;
4117
4121
  }
4122
+ if (a === "--min-edge-coverage" || a.startsWith("--min-edge-coverage=")) {
4123
+ minEdgeCoverageRaw = a.startsWith("--min-edge-coverage=")
4124
+ ? a.slice("--min-edge-coverage=".length)
4125
+ : rest[++i];
4126
+ continue;
4127
+ }
4128
+ if (a === "--coverage-out" || a.startsWith("--coverage-out=")) {
4129
+ coverageOut = a.startsWith("--coverage-out=")
4130
+ ? a.slice("--coverage-out=".length)
4131
+ : rest[++i];
4132
+ continue;
4133
+ }
4134
+ if (a === "--no-coverage-report") {
4135
+ noCoverageReport = true;
4136
+ continue;
4137
+ }
4118
4138
  if (a === "--max-steps" || a.startsWith("--max-steps=")) {
4119
4139
  const v = a.startsWith("--max-steps=") ? a.slice(12) : rest[++i];
4120
4140
  maxSteps = Number(v);
@@ -4143,12 +4163,15 @@ async function main() {
4143
4163
  if (a === "--help" || a === "-h") {
4144
4164
  console.log(`waygraph traverse [project] [flags]
4145
4165
 
4146
- Graph crawl (RFC Phase B+D). Walks unused legal edges until a leaf,
4166
+ Graph crawl (RFC Phase B-E). Walks unused legal edges until a leaf,
4147
4167
  budget kill, or first broken edge. --parallel N uses session clone + edge leases.
4148
4168
 
4149
4169
  --blocks <glob|/regex/|substr> Phase C: filter *.block.ts discovery
4150
4170
  --parallel N Phase D: N clone workers (default 1, max 4)
4151
4171
  --session clone|inherit Phase D: default clone; inherit refused if parallel>1
4172
+ --min-edge-coverage X Phase E: suite gate 0.8 | 80% | 80 (exit 2 if below)
4173
+ --coverage-out PATH Phase E: write JSON (default .waygraph-traverse/coverage.json)
4174
+ --no-coverage-report Phase E: print Coverage line only (no JSON file)
4152
4175
  --from <Checkpoint> start checkpoint (optional)
4153
4176
  --data '{...}' Mem seed JSON
4154
4177
  --max-steps N default 50 (per worker)
@@ -4160,9 +4183,11 @@ budget kill, or first broken edge. --parallel N uses session clone + edge leases
4160
4183
  Examples:
4161
4184
  waygraph traverse --blocks '**/mailpit/**/*.block.ts'
4162
4185
  waygraph traverse --parallel 2 --session clone --max-steps 20
4186
+ waygraph traverse --min-edge-coverage 80% --coverage-out ./cov.json
4163
4187
 
4164
4188
  PASS: [Reached Leaf Node[traverse-1] at=... steps=N]
4165
4189
  FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4190
+ COVER: [Coverage edges=H/T ratio=R% min=M% PASS|FAIL]
4166
4191
  `);
4167
4192
  process.exit(0);
4168
4193
  }
@@ -4177,6 +4202,15 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4177
4202
  const blocksSelect = blocksFilter
4178
4203
  ? parseBlocksSelect(blocksFilter)
4179
4204
  : undefined;
4205
+ let minEdgeCoverage;
4206
+ if (minEdgeCoverageRaw !== undefined) {
4207
+ const parsed = parseMinEdgeCoverage(minEdgeCoverageRaw);
4208
+ if (parsed === null) {
4209
+ console.error(`waygraph traverse: --min-edge-coverage expects 0.8, 80%, or 80 (got ${JSON.stringify(minEdgeCoverageRaw)})`);
4210
+ process.exit(1);
4211
+ }
4212
+ minEdgeCoverage = parsed;
4213
+ }
4180
4214
  const code = await runTraverse(projectDir, {
4181
4215
  ...(from ? { from } : {}),
4182
4216
  ...(data ? { data } : {}),
@@ -4192,6 +4226,9 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4192
4226
  ...(blocksSelect ? { blocksSelect } : {}),
4193
4227
  ...(parallel !== undefined && Number.isFinite(parallel) ? { parallel } : {}),
4194
4228
  ...(session ? { session } : {}),
4229
+ ...(minEdgeCoverage !== undefined ? { minEdgeCoverage } : {}),
4230
+ ...(coverageOut ? { coverageOut: resolve(coverageOut) } : {}),
4231
+ ...(noCoverageReport ? { noCoverageReport: true } : {}),
4195
4232
  });
4196
4233
  process.exit(code);
4197
4234
  }
package/dist/index.d.ts CHANGED
@@ -13,4 +13,6 @@ export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp,
13
13
  export type { BlocksSelect, BlocksSelectKind } from "./blocks-select.js";
14
14
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
15
15
  export { runTraverse } from "./traverse-run.js";
16
- export type { TraverseOptions, TraverseSessionMode } from "./traverse-run.js";
16
+ export type { TraverseOptions, TraverseSessionMode, TraverseWorkerResult, } from "./traverse-run.js";
17
+ export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
18
+ export type { TraverseCoverageReport } from "./traverse-coverage.js";
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./gra
7
7
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
8
8
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
9
9
  export { runTraverse } from "./traverse-run.js";
10
+ export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
@@ -0,0 +1,59 @@
1
+ export interface TraverseCoverageReport {
2
+ version: 1;
3
+ generatedAt: string;
4
+ projectDir: string;
5
+ parallel: number;
6
+ /** Unique edge keys in the discovered graph (block names). */
7
+ edgesTotal: number;
8
+ /** Graph edges successfully walked this suite (after instance normalize). */
9
+ edgesHit: number;
10
+ /** edgesHit / edgesTotal (0 when total is 0). */
11
+ ratio: number;
12
+ /** Optional suite gate that was applied (0-1). */
13
+ minEdgeCoverage?: number;
14
+ /** true when minEdgeCoverage was set and ratio met it. */
15
+ coveragePass?: boolean;
16
+ /** Sorted graph edge keys that were hit. */
17
+ hit: string[];
18
+ /** Sorted graph edge keys never walked. */
19
+ missed: string[];
20
+ /** Raw worker hit keys (may include block::instanceId). */
21
+ rawHit?: string[];
22
+ workers?: Array<{
23
+ traverseId: string;
24
+ exitCode: number;
25
+ steps: number;
26
+ edgesHit: number;
27
+ leaf?: string;
28
+ }>;
29
+ }
30
+ /** Static graph edge key = Block runtime name. */
31
+ export declare function edgeKeyFromGraphEdge(edge: {
32
+ block: string;
33
+ }): string;
34
+ /** Collect unique edge keys from a WaygraphGraph-like edges list. */
35
+ export declare function collectGraphEdgeKeys(edges: ReadonlyArray<{
36
+ block: string;
37
+ }>): string[];
38
+ /**
39
+ * Map a worker hit key onto the static graph key it covers.
40
+ * `add-to-cart::sku-1` -> `add-to-cart` when that block is in the graph.
41
+ */
42
+ export declare function normalizeHitKey(hitKey: string, graphKeys: ReadonlySet<string>): string | null;
43
+ /**
44
+ * Parse --min-edge-coverage: `0.8`, `80%`, `80` (as percent when > 1).
45
+ * Returns 0-1 fraction, or null when unset/invalid.
46
+ */
47
+ export declare function parseMinEdgeCoverage(raw: unknown): number | null;
48
+ export declare function buildCoverageReport(opts: {
49
+ projectDir: string;
50
+ parallel: number;
51
+ allEdgeKeys: readonly string[];
52
+ hitKeys: ReadonlySet<string> | readonly string[];
53
+ minEdgeCoverage?: number | null;
54
+ workers?: TraverseCoverageReport["workers"];
55
+ }): TraverseCoverageReport;
56
+ /** Greppable one-liner for agents/CI. */
57
+ export declare function formatCoverageLine(report: TraverseCoverageReport): string;
58
+ export declare function defaultCoverageOutPath(projectDir: string): string;
59
+ export declare function writeCoverageReport(path: string, report: TraverseCoverageReport): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Phase E: traverse edge coverage report + suite gate.
3
+ * Coverage is independent of leaf PASS - a green leaf can still fail CI
4
+ * when --min-edge-coverage is set and ratio is too low.
5
+ *
6
+ * Denominator = unique static graph edge block names (FF inners stay as their
7
+ * own graph edges when discovered; RFC open Q deferred - no covered-via-prefix yet).
8
+ * A hit of `block::instanceId` counts as covering `block`.
9
+ */
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ /** Static graph edge key = Block runtime name. */
13
+ export function edgeKeyFromGraphEdge(edge) {
14
+ return edge.block;
15
+ }
16
+ /** Collect unique edge keys from a WaygraphGraph-like edges list. */
17
+ export function collectGraphEdgeKeys(edges) {
18
+ const set = new Set();
19
+ for (const e of edges)
20
+ set.add(edgeKeyFromGraphEdge(e));
21
+ return [...set].sort();
22
+ }
23
+ /**
24
+ * Map a worker hit key onto the static graph key it covers.
25
+ * `add-to-cart::sku-1` -> `add-to-cart` when that block is in the graph.
26
+ */
27
+ export function normalizeHitKey(hitKey, graphKeys) {
28
+ if (graphKeys.has(hitKey))
29
+ return hitKey;
30
+ const base = hitKey.includes("::") ? hitKey.slice(0, hitKey.indexOf("::")) : hitKey;
31
+ if (graphKeys.has(base))
32
+ return base;
33
+ return null;
34
+ }
35
+ /**
36
+ * Parse --min-edge-coverage: `0.8`, `80%`, `80` (as percent when > 1).
37
+ * Returns 0-1 fraction, or null when unset/invalid.
38
+ */
39
+ export function parseMinEdgeCoverage(raw) {
40
+ if (raw === undefined || raw === null || raw === "")
41
+ return null;
42
+ if (typeof raw === "number" && Number.isFinite(raw)) {
43
+ if (raw < 0)
44
+ return null;
45
+ if (raw <= 1)
46
+ return raw;
47
+ if (raw <= 100)
48
+ return raw / 100;
49
+ return null;
50
+ }
51
+ if (typeof raw !== "string")
52
+ return null;
53
+ const t = raw.trim().toLowerCase();
54
+ if (!t)
55
+ return null;
56
+ if (t.endsWith("%")) {
57
+ const n = Number(t.slice(0, -1));
58
+ if (!Number.isFinite(n) || n < 0 || n > 100)
59
+ return null;
60
+ return n / 100;
61
+ }
62
+ const n = Number(t);
63
+ if (!Number.isFinite(n) || n < 0)
64
+ return null;
65
+ if (n <= 1)
66
+ return n;
67
+ if (n <= 100)
68
+ return n / 100;
69
+ return null;
70
+ }
71
+ export function buildCoverageReport(opts) {
72
+ const all = [...new Set(opts.allEdgeKeys)].sort();
73
+ const graphSet = new Set(all);
74
+ const rawList = Array.isArray(opts.hitKeys) ? [...opts.hitKeys] : [...opts.hitKeys];
75
+ const covered = new Set();
76
+ for (const raw of rawList) {
77
+ const n = normalizeHitKey(raw, graphSet);
78
+ if (n)
79
+ covered.add(n);
80
+ }
81
+ const hit = all.filter((k) => covered.has(k));
82
+ const missed = all.filter((k) => !covered.has(k));
83
+ const edgesTotal = all.length;
84
+ const edgesHit = hit.length;
85
+ const ratio = edgesTotal === 0 ? 0 : edgesHit / edgesTotal;
86
+ const min = opts.minEdgeCoverage != null && Number.isFinite(opts.minEdgeCoverage)
87
+ ? Math.min(1, Math.max(0, opts.minEdgeCoverage))
88
+ : undefined;
89
+ const coveragePass = min === undefined ? undefined : ratio + 1e-12 >= min;
90
+ const coverageExtra = min !== undefined && coveragePass !== undefined
91
+ ? { minEdgeCoverage: min, coveragePass }
92
+ : {};
93
+ return {
94
+ version: 1,
95
+ generatedAt: new Date().toISOString(),
96
+ projectDir: opts.projectDir,
97
+ parallel: opts.parallel,
98
+ edgesTotal,
99
+ edgesHit,
100
+ ratio: Math.round(ratio * 10000) / 10000,
101
+ ...coverageExtra,
102
+ hit,
103
+ missed,
104
+ rawHit: [...new Set(rawList)].sort(),
105
+ ...(opts.workers ? { workers: opts.workers } : {}),
106
+ };
107
+ }
108
+ /** Greppable one-liner for agents/CI. */
109
+ export function formatCoverageLine(report) {
110
+ const pct = (report.ratio * 100).toFixed(1);
111
+ const minPart = report.minEdgeCoverage !== undefined
112
+ ? ` min=${(report.minEdgeCoverage * 100).toFixed(1)}%` +
113
+ (report.coveragePass === false
114
+ ? " FAIL"
115
+ : report.coveragePass === true
116
+ ? " PASS"
117
+ : "")
118
+ : "";
119
+ return `[Coverage edges=${report.edgesHit}/${report.edgesTotal} ratio=${pct}%${minPart}]`;
120
+ }
121
+ export function defaultCoverageOutPath(projectDir) {
122
+ return join(projectDir, ".waygraph-traverse", "coverage.json");
123
+ }
124
+ export function writeCoverageReport(path, report) {
125
+ mkdirSync(dirname(path), { recursive: true });
126
+ writeFileSync(path, JSON.stringify(report, null, 2) + "\n", "utf-8");
127
+ }
@@ -1,4 +1,10 @@
1
1
  export type TraverseSessionMode = "clone" | "inherit";
2
+ export interface TraverseWorkerResult {
3
+ exitCode: number;
4
+ steps: number;
5
+ edgesHit: string[];
6
+ leaf?: string;
7
+ }
2
8
  export interface TraverseOptions {
3
9
  baseURL?: string;
4
10
  startUrl?: string;
@@ -24,8 +30,18 @@ export interface TraverseOptions {
24
30
  parallel?: number;
25
31
  /** Phase D: clone (default) or inherit. Inherit refused when parallel > 1. */
26
32
  session?: TraverseSessionMode;
33
+ /**
34
+ * Phase E: fail suite (exit 2) when hit/total ratio is below this 0-1 fraction.
35
+ * Leaf PASS alone does not satisfy the gate.
36
+ */
37
+ minEdgeCoverage?: number;
38
+ /** Phase E: write coverage JSON here (default `.waygraph-traverse/coverage.json`). */
39
+ coverageOut?: string;
40
+ /** Phase E: skip writing coverage.json (still prints the Coverage line). */
41
+ noCoverageReport?: boolean;
27
42
  }
28
43
  /**
29
- * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
44
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL + Coverage lines.
45
+ * Exit: 0 leaf ok (+ coverage ok), 1 break/timeout, 2 coverage gate fail (Phase E).
30
46
  */
31
47
  export declare function runTraverse(projectDir: string, options?: TraverseOptions): Promise<number>;
@@ -9,6 +9,7 @@ import { MemPage } from "./mem-page.js";
9
9
  import { buildExploreContext, buildExploreMenu, } from "./auto-explore.js";
10
10
  import { defaultMemValueForKey } from "./auto-explore-run.js";
11
11
  import { EdgeLeaseCoordinator } from "./traverse-lease.js";
12
+ import { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, writeCoverageReport, } from "./traverse-coverage.js";
12
13
  function resolveBaseUrl(projectDir) {
13
14
  if (process.env.WAYGRAPH_BASE_URL)
14
15
  return process.env.WAYGRAPH_BASE_URL;
@@ -132,6 +133,7 @@ async function runTraverseWorker(opts) {
132
133
  const startedAt = Date.now();
133
134
  let lastHere = opts.from ?? null;
134
135
  let exitCode = 0;
136
+ let leaf;
135
137
  try {
136
138
  for (;;) {
137
139
  if (Date.now() - startedAt > timeoutMs) {
@@ -171,6 +173,7 @@ async function runTraverseWorker(opts) {
171
173
  });
172
174
  if (candidates.length === 0) {
173
175
  const label = here ?? detected ?? "unknown";
176
+ leaf = label;
174
177
  console.log(`[Reached Leaf Node[${traverseId}] at=${label} steps=${steps}]`);
175
178
  console.error(`waygraph traverse[${traverseId}]: PASS leaf edgesHit=${edgesHit.size} steps=${steps}`);
176
179
  break;
@@ -227,10 +230,16 @@ async function runTraverseWorker(opts) {
227
230
  finally {
228
231
  console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
229
232
  }
230
- return exitCode;
233
+ return {
234
+ exitCode,
235
+ steps,
236
+ edgesHit: [...edgesHit],
237
+ ...(leaf ? { leaf } : {}),
238
+ };
231
239
  }
232
240
  /**
233
- * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
241
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL + Coverage lines.
242
+ * Exit: 0 leaf ok (+ coverage ok), 1 break/timeout, 2 coverage gate fail (Phase E).
234
243
  */
235
244
  export async function runTraverse(projectDir, options = {}) {
236
245
  let parallel = Math.max(1, Math.floor(options.parallel ?? 1));
@@ -250,11 +259,16 @@ export async function runTraverse(projectDir, options = {}) {
250
259
  const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
251
260
  const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
252
261
  const startUrl = options.startUrl ?? baseURL;
262
+ const minEdgeCoverage = options.minEdgeCoverage != null && Number.isFinite(options.minEdgeCoverage)
263
+ ? options.minEdgeCoverage
264
+ : undefined;
265
+ const coverageOut = options.coverageOut ?? defaultCoverageOutPath(projectDir);
253
266
  const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
254
267
  if (options.blocksSelect) {
255
268
  console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} -> ` +
256
269
  `${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
257
270
  }
271
+ const allEdgeKeys = collectGraphEdgeKeys(graph.edges);
258
272
  const leases = new EdgeLeaseCoordinator({ projectDir, persist: parallel > 1 });
259
273
  const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
260
274
  const { chromium } = await import("@playwright/test");
@@ -268,7 +282,46 @@ export async function runTraverse(projectDir, options = {}) {
268
282
  console.error(`waygraph traverse: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
269
283
  (baseURL ? ` base=${baseURL}` : "") +
270
284
  ` parallel=${parallel} session=${session}` +
271
- ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
285
+ ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}` +
286
+ (minEdgeCoverage !== undefined
287
+ ? ` minEdgeCoverage=${(minEdgeCoverage * 100).toFixed(1)}%`
288
+ : ""));
289
+ const emitCoverage = (workers) => {
290
+ const hitUnion = new Set();
291
+ for (const w of workers) {
292
+ for (const k of w.result.edgesHit)
293
+ hitUnion.add(k);
294
+ }
295
+ const report = buildCoverageReport({
296
+ projectDir,
297
+ parallel,
298
+ allEdgeKeys,
299
+ hitKeys: hitUnion,
300
+ ...(minEdgeCoverage !== undefined ? { minEdgeCoverage } : {}),
301
+ workers: workers.map(({ traverseId, result }) => ({
302
+ traverseId,
303
+ exitCode: result.exitCode,
304
+ steps: result.steps,
305
+ edgesHit: result.edgesHit.length,
306
+ ...(result.leaf ? { leaf: result.leaf } : {}),
307
+ })),
308
+ });
309
+ console.log(formatCoverageLine(report));
310
+ console.error(`waygraph traverse: coverage hit=${report.edgesHit}/${report.edgesTotal}` +
311
+ (report.missed.length
312
+ ? ` missed=${report.missed.slice(0, 8).join(",")}${report.missed.length > 8 ? "..." : ""}`
313
+ : "") +
314
+ (options.noCoverageReport ? "" : ` report=${coverageOut}`));
315
+ if (!options.noCoverageReport) {
316
+ writeCoverageReport(coverageOut, report);
317
+ }
318
+ if (report.coveragePass === false) {
319
+ console.error(`waygraph traverse: FAIL coverage gate ratio=${(report.ratio * 100).toFixed(1)}%` +
320
+ ` < min=${((report.minEdgeCoverage ?? 0) * 100).toFixed(1)}%`);
321
+ return 2;
322
+ }
323
+ return 0;
324
+ };
272
325
  try {
273
326
  if (parallel <= 1) {
274
327
  const contextOpts = {
@@ -284,8 +337,9 @@ export async function runTraverse(projectDir, options = {}) {
284
337
  await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
285
338
  await page.waitForLoadState("load").catch(() => { });
286
339
  }
287
- const code = await runTraverseWorker({
288
- traverseId: options.traverseId ?? "traverse-1",
340
+ const traverseId = options.traverseId ?? "traverse-1";
341
+ const result = await runTraverseWorker({
342
+ traverseId,
289
343
  workerIndex: 0,
290
344
  parallel: 1,
291
345
  maxSteps,
@@ -305,7 +359,10 @@ export async function runTraverse(projectDir, options = {}) {
305
359
  leases,
306
360
  });
307
361
  await context.close().catch(() => { });
308
- return code;
362
+ const coverageCode = emitCoverage([{ traverseId, result }]);
363
+ if (result.exitCode !== 0)
364
+ return result.exitCode;
365
+ return coverageCode;
309
366
  }
310
367
  // Phase D: bootstrap one context for storageState, then N clones.
311
368
  const bootOpts = {
@@ -325,7 +382,7 @@ export async function runTraverse(projectDir, options = {}) {
325
382
  const memSnap = snapshotMem(library.byName, bootMem);
326
383
  await bootContext.close().catch(() => { });
327
384
  console.error(`waygraph traverse: forked ${parallel} clone workers (storageState + mem snapshot)`);
328
- const codes = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
385
+ const workerMeta = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
329
386
  const traverseId = `traverse-${i + 1}`;
330
387
  const ctxOpts = {
331
388
  viewport: { width: 1280, height: 720 },
@@ -341,7 +398,7 @@ export async function runTraverse(projectDir, options = {}) {
341
398
  await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
342
399
  }
343
400
  try {
344
- return await runTraverseWorker({
401
+ const result = await runTraverseWorker({
345
402
  traverseId,
346
403
  workerIndex: i,
347
404
  parallel,
@@ -361,15 +418,20 @@ export async function runTraverse(projectDir, options = {}) {
361
418
  mem,
362
419
  leases,
363
420
  });
421
+ return { traverseId, result };
364
422
  }
365
423
  finally {
366
424
  await context.close().catch(() => { });
367
425
  }
368
426
  }));
369
- const worst = codes.reduce((a, b) => Math.max(a, b), 0);
370
- const pass = codes.filter((c) => c === 0).length;
427
+ const results = workerMeta.map((w) => w.result);
428
+ const worst = results.reduce((a, b) => Math.max(a, b.exitCode), 0);
429
+ const pass = results.filter((c) => c.exitCode === 0).length;
371
430
  console.error(`waygraph traverse: suite parallel=${parallel} pass=${pass}/${parallel} exit=${worst}`);
372
- return worst;
431
+ const coverageCode = emitCoverage(workerMeta);
432
+ if (worst !== 0)
433
+ return worst;
434
+ return coverageCode;
373
435
  }
374
436
  finally {
375
437
  await browser.close().catch(() => { });
@@ -34,6 +34,9 @@ npx playwright install chromium
34
34
  | Headless suite | `npm test` |
35
35
  | Interactive explore (headed panel) | `npm run auto` |
36
36
  | Interactive explore (terminal menu) | `npm run auto:cli` |
37
+ | Graph crawl (Phase B-E) | `npm run traverse` |
38
+ | Parallel crawl + leases | `npm run traverse:parallel` |
39
+ | Coverage JSON + min gate (10%) | `npm run traverse:coverage` |
37
40
  | Shop demo stepper | `npm run demo:step` |
38
41
  | Shop demo autoplay | `npm run demo:autoplay` |
39
42
  | **Cart bulk** demo (add-all → remove-all) | `npm run demo:bulk` |
@@ -47,6 +50,7 @@ npx playwright install chromium
47
50
  | **`run --blocks`** | `npm run run:bulk` — Block names + `--data` | No |
48
51
  | **`demo --blocks`** | `npm run demo:bulk` — named flow + `--data` | Optional (`cartBulkFlow`) |
49
52
  | **`auto`** | `npm run auto` / `auto:cli` | No — live menu |
53
+ | **`traverse`** | `npm run traverse` / `traverse:parallel` / `traverse:coverage` | No — graph crawl + `.waygraph-traverse/coverage.json` |
50
54
  | **`auto --blocks From To`** | path-find on graph | No — checkpoints only |
51
55
 
52
56
  QA watch+record: `npm run demo:bulk:video` (`--auto-play-video`).
@@ -14,6 +14,9 @@
14
14
  "auto": "waygraph auto",
15
15
  "auto:cli": "waygraph auto --cli --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\"",
16
16
  "prove:auto-cli": "node scripts/drive-auto-cli.mjs",
17
+ "traverse": "waygraph traverse --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 40",
18
+ "traverse:parallel": "waygraph traverse --parallel 2 --session clone --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 30",
19
+ "traverse:coverage": "waygraph traverse --min-edge-coverage 10% --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 40",
17
20
  "graph": "waygraph graph",
18
21
  "graph:mermaid": "waygraph graph --mermaid",
19
22
  "demo:stub-on-error": "waygraph demo --blocks stubOnErrorFlow --auto-next --fast --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"locked_out_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --title \"stubOnError prove\"",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.19",
3
+ "version": "0.12.21",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "tsx": "^4.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@playwright/test": "^1.55.0",
44
+ "@playwright/test": "^1.63.0",
45
45
  "@types/node": "^26.0.0",
46
46
  "typescript": "^5.6.0"
47
47
  }
@@ -9,6 +9,9 @@
9
9
  "check": "waygraph check",
10
10
  "auto": "waygraph auto",
11
11
  "auto:cli": "waygraph auto --cli --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\"",
12
+ "traverse": "waygraph traverse --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 40",
13
+ "traverse:parallel": "waygraph traverse --parallel 2 --session clone --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 30",
14
+ "traverse:coverage": "waygraph traverse --min-edge-coverage 10% --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --max-steps 40",
12
15
  "graph": "waygraph graph",
13
16
  "graph:mermaid": "waygraph graph --mermaid",
14
17
  "demo": "waygraph demo --blocks \"loginFlow then shopFlow({\\\"saucedemo.selectedItem\\\":{\\\"id\\\":\\\"sauce-labs-backpack\\\",\\\"name\\\":\\\"Sauce Labs Backpack\\\"}}) then viewerBlockedFlow({\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"locked_out_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}})\" --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\"",
@@ -23,7 +26,7 @@
23
26
  "baseUrl": "https://www.saucedemo.com"
24
27
  },
25
28
  "dependencies": {
26
- "waygraph": "^0.11.0"
29
+ "waygraph": "^0.12.20"
27
30
  },
28
31
  "devDependencies": {
29
32
  "@playwright/test": "^1.55.0",
@@ -24,7 +24,7 @@
24
24
  "baseUrl": "http://127.0.0.1:4177"
25
25
  },
26
26
  "dependencies": {
27
- "waygraph": "^0.12.7"
27
+ "waygraph": "^0.12.20"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@playwright/test": "^1.55.0",