waygraph 0.7.2 → 0.7.3

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
@@ -9,6 +9,18 @@ sites/apps (saucedemo.com, zsign-app) live in the sibling `projects_waygraph/` r
9
9
  See [ROADMAP.md](./ROADMAP.md) for what's shipped, spec'd, and planned by version, and
10
10
  `openspec/changes/` for the actual planning artifacts behind each roadmap item.
11
11
 
12
+ ## Docs (GitHub Pages)
13
+
14
+ Browser docs (quick start, engine handout, consumer layout, deploy path):
15
+
16
+ - **Published:** https://deviate-dv8.github.io/waygraph/ (after repo Pages source is set to GitHub Actions once)
17
+ - **Source:** [`docs/`](./docs/) — static HTML, no build step
18
+ - **Local preview:** `npm run docs:preview` → http://127.0.0.1:4173/
19
+ - **Deploy:** push to `main` touching `docs/**` runs [`.github/workflows/pages.yml`](./.github/workflows/pages.yml) (`workflow_dispatch` also works)
20
+
21
+ This README stays the in-repo API narrative; Pages is the same material for eyeballing and cross-mesh handoff.
22
+
23
+
12
24
  ## Install
13
25
 
14
26
  ```bash
@@ -157,6 +169,19 @@ at the namespace root; no invented `landing/`/`root/`; sidebar = chrome + edges)
157
169
 
158
170
  Deliberately **not yet implemented** (tracked on the project board):
159
171
  - Interstitials/Watchers - background overlay handling (cookie banners, popups).
172
+ - Split `requires` (externally-supplied vs producedBy) recovery hints.
173
+ - Federated multi-package pool of waygraphs.
174
+
175
+ **Available now:** `waygraph auto [project]` discovers the app's Checkpoint/Block
176
+ state graph (JSON or `--mermaid`); `locate(page, library)` answers "where am I"
177
+ from NavBlock verify Traits. Unattended JSON execution is `chain` + `WAYGRAPH_JSON=1`
178
+ (not named `auto`).
179
+
180
+ **Friendly demo run:** `waygraph demo <flow> [project]` defaults to STEP+headed.
181
+ Flags beat env: `--step` / `--autoplay` / `--base-url` / `--title`. BASE_URL falls
182
+ back to `package.json` `waygraph.baseUrl` then `playwright.config` `baseURL`. Prefer
183
+ flags (or `npm run demo*`) over a pile of `WAYGRAPH_*` vars. NavBlock `click` shows
184
+ demo cursor travel + pulse before the real click; `url` NavBlocks still `goto`.
160
185
 
161
186
  ## Example
162
187
 
package/dist/cli.d.ts CHANGED
@@ -8,8 +8,11 @@
8
8
  * validate [project] Import and validate all flows
9
9
  * run <flow> [project] Run a named flow with a fresh browser
10
10
  * chain <spec> [project] Run one or more Blocks by name, ad hoc, no flow file needed
11
+ * demo <flow|spec> [project] Friendly step-mode run (flags beat env; defaults STEP+headed)
12
+ * try [project] Bundled quickstart showcase
11
13
  *
12
14
  * "project" defaults to the current directory.
13
15
  * A project is any directory containing *.flow.ts files (typically under src/flows/).
16
+ * Run flags (--step/--autoplay/--base-url/--title) beat WAYGRAPH_* env when present.
14
17
  */
15
18
  export {};
package/dist/cli.js CHANGED
@@ -8,15 +8,19 @@
8
8
  * validate [project] Import and validate all flows
9
9
  * run <flow> [project] Run a named flow with a fresh browser
10
10
  * chain <spec> [project] Run one or more Blocks by name, ad hoc, no flow file needed
11
+ * demo <flow|spec> [project] Friendly step-mode run (flags beat env; defaults STEP+headed)
12
+ * try [project] Bundled quickstart showcase
11
13
  *
12
14
  * "project" defaults to the current directory.
13
15
  * A project is any directory containing *.flow.ts files (typically under src/flows/).
16
+ * Run flags (--step/--autoplay/--base-url/--title) beat WAYGRAPH_* env when present.
14
17
  */
15
18
  import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "node:fs";
16
19
  import { resolve, relative, join, basename, dirname } from "node:path";
17
20
  import { fileURLToPath } from "node:url";
18
21
  import { spawn } from "node:child_process";
19
22
  import { MemPage } from "./mem-page.js";
23
+ import { discoverGraph, toMermaid } from "./graph.js";
20
24
  // ---------------------------------------------------------------------------
21
25
  // Filesystem
22
26
  // ---------------------------------------------------------------------------
@@ -197,6 +201,7 @@ function isFlowLike(val) {
197
201
  const CHAIN_RUNNER_SCRIPT = `
198
202
  import { readdirSync } from "node:fs";
199
203
  import { join } from "node:path";
204
+ import { tmpdir } from "node:os";
200
205
 
201
206
  function walkDir(dir, pattern) {
202
207
  const results = [];
@@ -1260,11 +1265,18 @@ async function teardownOverlay(page) {
1260
1265
  async function moveCursorTo(page, box, ms) {
1261
1266
  const x = box.x + box.width / 2;
1262
1267
  const y = box.y + box.height / 2;
1268
+ const travel = Math.max(0, Number(ms) || 0);
1263
1269
  await page
1264
1270
  .evaluate(({ x, y, ms }) => {
1265
1271
  if (window.__wgMoveCursorTo) window.__wgMoveCursorTo(x, y, ms);
1266
- }, { x, y, ms })
1272
+ }, { x, y, ms: travel })
1267
1273
  .catch(() => {});
1274
+ // CSS transition is async - wait the full travel so the cursor is ON the
1275
+ // target before ring/pulse/real click (NavBlock click nav was skipping
1276
+ // this and looked like a teleport).
1277
+ if (travel > 0) {
1278
+ await new Promise((res) => setTimeout(res, travel));
1279
+ }
1268
1280
  return { x, y };
1269
1281
  }
1270
1282
 
@@ -1386,6 +1398,14 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1386
1398
  let clickPoint = null;
1387
1399
  try {
1388
1400
  await installOverlay(page);
1401
+ // Wait until the target is visible BEFORE measuring - otherwise
1402
+ // defineNavBlock({ click }) (and any slow-to-appear control) skips
1403
+ // the cursor entirely: boundingBox was null, then originalClick
1404
+ // waited and clicked with no demo animation.
1405
+ await this.waitFor({
1406
+ state: "visible",
1407
+ timeout: (options && options.timeout) || 30000,
1408
+ }).catch(() => {});
1389
1409
  const box = await this.boundingBox();
1390
1410
  const narrated = box ? await wasJustNarrated(page) : false;
1391
1411
  if (box && !narrated) {
@@ -1396,6 +1416,14 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
1396
1416
  } catch {
1397
1417
  // element has no simple text (an icon button, say) - generic label is fine
1398
1418
  }
1419
+ // Prefer "nav: …" when this locator is a NavBlock click target
1420
+ // (set on page by runStepMode just before act).
1421
+ try {
1422
+ const navLabel = await page.evaluate(() => window.__wgPendingNavClickLabel || null);
1423
+ if (navLabel) label = String(navLabel);
1424
+ } catch {
1425
+ /* ignore */
1426
+ }
1399
1427
  clickPoint = await moveCursorTo(page, box, cursorMs(600));
1400
1428
  await showRing(page, box, label);
1401
1429
  // "pop for a few seconds" - Dan's own phrase, matching the
@@ -1685,6 +1713,23 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1685
1713
  }
1686
1714
  }
1687
1715
  await markStepRunning(page);
1716
+ // NavBlock click-nav: label the upcoming Locator.click demo cursor as
1717
+ // "nav: <block>" so pia/demo watchers see cursor+pulse on click nav
1718
+ // (not only on regular Block clicks).
1719
+ const navClick = r.block.__waygraphNavClick;
1720
+ if (r.block.__waygraphKind === "nav" && navClick !== undefined) {
1721
+ await page
1722
+ .evaluate((label) => {
1723
+ window.__wgPendingNavClickLabel = label;
1724
+ }, "nav: " + (r.block.name || "click"))
1725
+ .catch(() => {});
1726
+ } else {
1727
+ await page
1728
+ .evaluate(() => {
1729
+ delete window.__wgPendingNavClickLabel;
1730
+ })
1731
+ .catch(() => {});
1732
+ }
1688
1733
  const stepFlow = engine.defineFlow([start, r.block, end]);
1689
1734
  // { closeOnFinish: false } makes Flow.run return { result, page }, not
1690
1735
  // the plain checkpoint - destructure it, don't treat the wrapper as the
@@ -1694,6 +1739,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1694
1739
  try {
1695
1740
  stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
1696
1741
  } catch (err) {
1742
+ await page
1743
+ .evaluate(() => {
1744
+ delete window.__wgPendingNavClickLabel;
1745
+ })
1746
+ .catch(() => {});
1697
1747
  // A thrown act()/observe()/a failed verify Trait used to just crash
1698
1748
  // the whole Node process with a raw stack trace - the browser closes
1699
1749
  // (main()'s own try/finally) before a human watching ever sees WHY.
@@ -1723,6 +1773,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1723
1773
  }
1724
1774
  throw err;
1725
1775
  }
1776
+ await page
1777
+ .evaluate(() => {
1778
+ delete window.__wgPendingNavClickLabel;
1779
+ })
1780
+ .catch(() => {});
1726
1781
  result = stepOutcome.result;
1727
1782
  const highlights = extractVerifyHighlights(r.block, result.__state);
1728
1783
  await renderAfterStep(page, {
@@ -1747,11 +1802,65 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1747
1802
  return result;
1748
1803
  }
1749
1804
 
1805
+ /**
1806
+ * "chain WAYGRAPH_JSON=1" - the unattended counterpart to "chain --step": no
1807
+ * human gate, no overlay, headless by default. The same execution --step
1808
+ * already has (session-reset boundaries, per-segment mem-seeding) but
1809
+ * reporting built for an agent reading stdout afterward, not a human
1810
+ * watching the browser live - a flat JSON object naming exactly which Block
1811
+ * failed, what every EARLIER Block resolved to, and a screenshot at the
1812
+ * exact moment of failure, since there's no live browser for a human to
1813
+ * glance at instead. (Previously its own "waygraph auto" verb - renamed once
1814
+ * "auto" came to mean the state-machine discovery tool instead; this is a
1815
+ * chain reporting mode, not a distinct command.)
1816
+ */
1817
+ async function runJsonReportMode(engine, start, end, context, page, mem, resolved, baseURL) {
1818
+ const steps = [];
1819
+ for (let i = 0; i < resolved.length; i++) {
1820
+ const r = resolved[i];
1821
+ if (r.seedMem) r.seedMem();
1822
+ if (r.resetSession && i > 0) {
1823
+ await resetPageState(context, page, baseURL);
1824
+ }
1825
+ const startedAt = Date.now();
1826
+ const stepFlow = engine.defineFlow([start, r.block, end]);
1827
+ try {
1828
+ const outcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
1829
+ steps.push({ name: r.block.name, checkpoint: outcome.result, ms: Date.now() - startedAt });
1830
+ } catch (err) {
1831
+ let screenshot = null;
1832
+ try {
1833
+ screenshot = join(tmpdir(), "waygraph-chain-failure-" + Date.now() + ".png");
1834
+ await page.screenshot({ path: screenshot, fullPage: true });
1835
+ } catch {
1836
+ screenshot = null;
1837
+ }
1838
+ return {
1839
+ ok: false,
1840
+ failedAt: r.block.name,
1841
+ stepIndex: i,
1842
+ totalSteps: resolved.length,
1843
+ error: err && err.message ? err.message : String(err),
1844
+ steps,
1845
+ screenshot,
1846
+ };
1847
+ }
1848
+ }
1849
+ return { ok: true, result: steps.length > 0 ? steps[steps.length - 1].checkpoint : null, steps };
1850
+ }
1851
+
1750
1852
  async function main() {
1751
1853
  const projectDir = process.argv[2];
1752
1854
  const spec = process.argv[3];
1753
1855
  const { connect, MemPage, Engine, start, end, chainFlow } = await import("waygraph");
1754
1856
  const mem = new MemPage();
1857
+ // Computed early - JSON-report mode's stdout is meant to be ONE parseable
1858
+ // JSON object for whatever's reading it afterward (a script, an agent),
1859
+ // not a human's console. Every informational console.log below is
1860
+ // skipped for it; only the final JSON report (or a thrown Error, for a
1861
+ // truly unexpected failure outside runJsonReportMode's own try/catch)
1862
+ // reaches stdout.
1863
+ const jsonReport = process.env.WAYGRAPH_JSON === "1";
1755
1864
  let resolved = [];
1756
1865
  // A bare identifier (no "(", no "then") might name an existing Flow
1757
1866
  // that's already wired up (e.g. loginFlow) - try that FIRST so pointing
@@ -1766,11 +1875,13 @@ async function main() {
1766
1875
  exportName: bi.name,
1767
1876
  expectedFailureReason: flow.expectedFailureReason,
1768
1877
  }));
1769
- console.log(
1770
- "waygraph: running existing flow \\"" + bareRef + "\\" - " +
1771
- resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
1772
- (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
1773
- );
1878
+ if (!jsonReport) {
1879
+ console.log(
1880
+ "waygraph: running existing flow \\"" + bareRef + "\\" - " +
1881
+ resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
1882
+ (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
1883
+ );
1884
+ }
1774
1885
  }
1775
1886
  }
1776
1887
  if (resolved.length === 0) {
@@ -1855,10 +1966,12 @@ async function main() {
1855
1966
  isFirstOfSegment = false;
1856
1967
  return entry;
1857
1968
  });
1858
- console.log(
1859
- "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
1860
- " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
1861
- );
1969
+ if (!jsonReport) {
1970
+ console.log(
1971
+ "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
1972
+ " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
1973
+ );
1974
+ }
1862
1975
  }
1863
1976
  const step = process.env.WAYGRAPH_STEP === "1";
1864
1977
  // Stepping through headless defeats the point - a human can't watch it.
@@ -1900,12 +2013,12 @@ async function main() {
1900
2013
  // the same limitation "chain" always had; only --step's own runStepMode
1901
2014
  // loop actually needs (and correctly supports, via resolved[i].seedMem)
1902
2015
  // per-segment differentiated values.
1903
- if (!step) {
2016
+ if (!step && !jsonReport) {
1904
2017
  for (const r of resolved) {
1905
2018
  if (r.seedMem) r.seedMem();
1906
2019
  }
1907
2020
  }
1908
- if (step || baseURL) {
2021
+ if (step || jsonReport || baseURL) {
1909
2022
  const { chromium } = await import("playwright");
1910
2023
  // --start-maximized (step mode only): viewport: null alone only made
1911
2024
  // the PAGE content track the window - the actual browser WINDOW still
@@ -1935,6 +2048,15 @@ async function main() {
1935
2048
  await page.goto(baseURL).catch(() => {});
1936
2049
  }
1937
2050
  result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL);
2051
+ } else if (jsonReport) {
2052
+ const page = await context.newPage();
2053
+ if (baseURL) {
2054
+ await page.goto(baseURL).catch(() => {});
2055
+ }
2056
+ const report = await runJsonReportMode(engine, start, end, context, page, mem, resolved, baseURL);
2057
+ console.log(JSON.stringify(report, null, 2));
2058
+ if (!report.ok) process.exitCode = 1;
2059
+ return;
1938
2060
  } else {
1939
2061
  const blocks = resolved.map((r) => r.block);
1940
2062
  const chained = blocks.reduce((a, b) => connect(a, b));
@@ -2244,9 +2366,119 @@ async function runFlow(projectDir, flowName) {
2244
2366
  console.error(`waygraph: no flow named "${flowName}" found under ${projectDir}`);
2245
2367
  process.exitCode = 1;
2246
2368
  }
2247
- // ---------------------------------------------------------------------------
2248
- // CLI plumbing
2249
- // ---------------------------------------------------------------------------
2369
+ /**
2370
+ * Pulls `--step` / `--no-step` / `--autoplay` / `--no-autoplay` /
2371
+ * `--base-url <url>` / `--title <text>` out of argv. Unknown `--*` stay as
2372
+ * positionals so callers like `auto --mermaid` keep working when they parse
2373
+ * their own flags.
2374
+ */
2375
+ function parseRunFlags(argv) {
2376
+ const out = { positionals: [] };
2377
+ for (let i = 0; i < argv.length; i++) {
2378
+ const a = argv[i];
2379
+ if (a === "--step") {
2380
+ out.step = true;
2381
+ }
2382
+ else if (a === "--no-step") {
2383
+ out.step = false;
2384
+ }
2385
+ else if (a === "--autoplay") {
2386
+ out.autoplay = true;
2387
+ }
2388
+ else if (a === "--no-autoplay") {
2389
+ out.autoplay = false;
2390
+ }
2391
+ else if (a === "--base-url" || a.startsWith("--base-url=")) {
2392
+ const v = a.includes("=") ? a.slice("--base-url=".length) : argv[++i];
2393
+ if (!v || v.startsWith("-")) {
2394
+ console.error("waygraph: --base-url needs a URL value");
2395
+ process.exit(1);
2396
+ }
2397
+ out.baseUrl = v;
2398
+ }
2399
+ else if (a === "--title" || a.startsWith("--title=")) {
2400
+ const v = a.includes("=") ? a.slice("--title=".length) : argv[++i];
2401
+ if (v === undefined || (v.startsWith("-") && !a.includes("="))) {
2402
+ console.error('waygraph: --title needs a string value');
2403
+ process.exit(1);
2404
+ }
2405
+ out.title = v;
2406
+ }
2407
+ else {
2408
+ out.positionals.push(a);
2409
+ }
2410
+ }
2411
+ return out;
2412
+ }
2413
+ /** Writes flag values into process.env so the chain child inherits them. Flags beat prior env. */
2414
+ function applyRunFlags(flags) {
2415
+ if (flags.step === true) {
2416
+ process.env.WAYGRAPH_STEP = "1";
2417
+ process.env.WAYGRAPH_HEADED = "1";
2418
+ }
2419
+ else if (flags.step === false) {
2420
+ process.env.WAYGRAPH_STEP = "0";
2421
+ }
2422
+ if (flags.autoplay === true) {
2423
+ process.env.WAYGRAPH_AUTOPLAY = "1";
2424
+ }
2425
+ else if (flags.autoplay === false) {
2426
+ process.env.WAYGRAPH_AUTOPLAY = "0";
2427
+ }
2428
+ if (flags.baseUrl !== undefined) {
2429
+ process.env.WAYGRAPH_BASE_URL = flags.baseUrl;
2430
+ }
2431
+ if (flags.title !== undefined) {
2432
+ process.env.WAYGRAPH_TITLE = flags.title;
2433
+ }
2434
+ }
2435
+ /**
2436
+ * BASE_URL when neither --base-url nor WAYGRAPH_BASE_URL is set:
2437
+ * package.json `waygraph.baseUrl` (or `baseURL`), then playwright.config `baseURL`.
2438
+ */
2439
+ function resolveBaseUrl(projectDir) {
2440
+ const pkgPath = join(projectDir, "package.json");
2441
+ if (existsSync(pkgPath)) {
2442
+ try {
2443
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
2444
+ const fromPkg = pkg.waygraph?.baseUrl ??
2445
+ pkg.waygraph?.baseURL ??
2446
+ pkg.config?.waygraph?.baseUrl ??
2447
+ pkg.config?.waygraph?.baseURL;
2448
+ if (typeof fromPkg === "string" && fromPkg.trim())
2449
+ return fromPkg.trim();
2450
+ }
2451
+ catch {
2452
+ // ignore malformed package.json - fall through to playwright
2453
+ }
2454
+ }
2455
+ for (const name of ["playwright.config.ts", "playwright.config.mts", "playwright.config.js", "playwright.config.mjs"]) {
2456
+ const p = join(projectDir, name);
2457
+ if (!existsSync(p))
2458
+ continue;
2459
+ const src = readFileSync(p, "utf-8");
2460
+ const m = src.match(/baseURL\s*:\s*["']([^"']+)["']/);
2461
+ if (m?.[1])
2462
+ return m[1];
2463
+ }
2464
+ return undefined;
2465
+ }
2466
+ /**
2467
+ * Friendly demo defaults: STEP+headed on, BASE_URL from package/env/playwright.
2468
+ * Call after applyRunFlags so explicit flags already won.
2469
+ */
2470
+ function applyDemoDefaults(projectDir) {
2471
+ if (process.env.WAYGRAPH_STEP === undefined)
2472
+ process.env.WAYGRAPH_STEP = "1";
2473
+ if (process.env.WAYGRAPH_STEP === "1" && process.env.WAYGRAPH_HEADED === undefined) {
2474
+ process.env.WAYGRAPH_HEADED = "1";
2475
+ }
2476
+ if (!process.env.WAYGRAPH_BASE_URL) {
2477
+ const resolved = resolveBaseUrl(projectDir);
2478
+ if (resolved)
2479
+ process.env.WAYGRAPH_BASE_URL = resolved;
2480
+ }
2481
+ }
2250
2482
  const args = process.argv.slice(2);
2251
2483
  const command = args[0];
2252
2484
  function usage() {
@@ -2258,7 +2490,51 @@ Usage:
2258
2490
  waygraph validate [project] Import and validate all flows
2259
2491
  waygraph run <flow> [project] Run a named flow
2260
2492
  waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
2493
+ waygraph demo <flow|spec> [project]
2494
+ Friendly human-watched run. Defaults:
2495
+ --step (headed overlay). BASE_URL from
2496
+ --base-url, else WAYGRAPH_BASE_URL, else
2497
+ package.json waygraph.baseUrl, else
2498
+ playwright.config baseURL.
2499
+ waygraph auto [project] Discover state graph (JSON or --mermaid)
2261
2500
  waygraph check [project] Warn about navigation outside a NavBlock
2501
+ waygraph try [project] One-shot bundled quickstart showcase
2502
+
2503
+ Run flags (chain / demo / try) - flags beat WAYGRAPH_* env:
2504
+ --step / --no-step human-verification overlay (implies headed)
2505
+ --autoplay / --no-autoplay panel Auto-advance starting state
2506
+ --base-url <url> base URL for relative page.goto()
2507
+ --title <text> persistent overlay banner title
2508
+
2509
+ Prefer flags (or npm scripts that pass them) over an ENV soup of WAYGRAPH_*.
2510
+
2511
+ WAYGRAPH_JSON=1 reports for a script/agent instead of a human: no
2512
+ --step overlay, no informational console.log, headless by default -
2513
+ ONE JSON object on stdout, nothing else. Success: { ok: true, result,
2514
+ steps: [{name, checkpoint, ms}, ...] }. Failure: { ok: false, failedAt,
2515
+ stepIndex, totalSteps, error, steps (every EARLIER Block that
2516
+ succeeded), screenshot (a PNG path captured at the exact moment of
2517
+ failure - there's no live browser for anyone to glance at instead) }.
2518
+ Exit code matches ok. Same session-reset boundaries and per-segment
2519
+ mem-seeding --step already has.
2520
+
2521
+ waygraph auto [project] Walks every *.block.ts in [project]
2522
+ (default: cwd) and builds the app's own
2523
+ state graph: Checkpoints are nodes, Blocks
2524
+ are edges. A NavBlock's own runtime
2525
+ "checkpoint" gives its Out tag for free
2526
+ (In is always "*" - reachable from
2527
+ anywhere); a regular defineBlock<In, Out>
2528
+ gets its tags via the same lightweight
2529
+ regex source-reading list/nav/check
2530
+ already do, resolving each type identifier
2531
+ back to its literal Checkpoint tag (a
2532
+ union Out - a branching action Block -
2533
+ becomes one edge per member). Prints the
2534
+ graph as JSON by default, or --mermaid for
2535
+ a stateDiagram-v2 text export. Reports how
2536
+ many Blocks' Out couldn't be resolved
2537
+ (skipped, not crashed).
2262
2538
  waygraph try [project] One-shot quickstart showcase - genuinely
2263
2539
  "npx waygraph try", no separate init step.
2264
2540
  Checks (a real launch+close, not a guess)
@@ -2298,7 +2574,7 @@ Usage:
2298
2574
 
2299
2575
  Runs in a child process rooted at "project" (its own waygraph/playwright,
2300
2576
  never this CLI's) so a project's real Blocks resolve against its own
2301
- node_modules. Env vars, all optional:
2577
+ node_modules. Env vars still work (flags override them when both set):
2302
2578
  WAYGRAPH_BASE_URL base URL for Blocks using relative page.goto()
2303
2579
  WAYGRAPH_HEADED=1 show the browser instead of headless
2304
2580
  WAYGRAPH_SLOWMO=ms slow down each Playwright action, for watching a run
@@ -2342,6 +2618,13 @@ Usage:
2342
2618
  still cycles and remembers via localStorage
2343
2619
 
2344
2620
  "project" defaults to the current directory.
2621
+
2622
+ Examples:
2623
+ waygraph demo shopFlow .
2624
+ waygraph demo shopFlow . --autoplay --title "Shop demo"
2625
+ waygraph demo loginFlow . --base-url https://www.saucedemo.com
2626
+ waygraph chain "login then nav-cart" . --step --no-autoplay
2627
+ npm run demo # scaffold: waygraph demo exampleFlow .
2345
2628
  `);
2346
2629
  process.exit(0);
2347
2630
  }
@@ -2394,17 +2677,70 @@ async function main() {
2394
2677
  break;
2395
2678
  }
2396
2679
  case "chain": {
2397
- const spec = args[1];
2680
+ const flags = parseRunFlags(args.slice(1));
2681
+ applyRunFlags(flags);
2682
+ const spec = flags.positionals[0];
2398
2683
  if (!spec) {
2399
2684
  console.error('waygraph chain: missing <spec>, e.g. waygraph chain "login({...}) then overview-metrics"');
2400
2685
  process.exit(1);
2401
2686
  }
2402
- const proj = resolve(args[2] ?? process.cwd());
2687
+ const proj = resolve(flags.positionals[1] ?? process.cwd());
2688
+ if (!process.env.WAYGRAPH_BASE_URL) {
2689
+ const resolved = resolveBaseUrl(proj);
2690
+ if (resolved)
2691
+ process.env.WAYGRAPH_BASE_URL = resolved;
2692
+ }
2403
2693
  await runChain(proj, spec);
2404
2694
  break;
2405
2695
  }
2696
+ case "demo": {
2697
+ const flags = parseRunFlags(args.slice(1));
2698
+ applyRunFlags(flags);
2699
+ const spec = flags.positionals[0];
2700
+ if (!spec) {
2701
+ console.error('waygraph demo: missing <flow|spec>, e.g. waygraph demo shopFlow .\n' +
2702
+ " Flags: --step/--no-step --autoplay/--no-autoplay --base-url <url> --title <text>");
2703
+ process.exit(1);
2704
+ }
2705
+ const proj = resolve(flags.positionals[1] ?? process.cwd());
2706
+ if (!existsSync(proj)) {
2707
+ console.error(`waygraph: no such directory: ${proj}`);
2708
+ process.exit(1);
2709
+ }
2710
+ // Friendly defaults after flags: STEP+headed on; BASE_URL from package/playwright.
2711
+ if (flags.step === undefined && process.env.WAYGRAPH_STEP === undefined) {
2712
+ process.env.WAYGRAPH_STEP = "1";
2713
+ }
2714
+ applyDemoDefaults(proj);
2715
+ console.error(`waygraph demo: STEP=${process.env.WAYGRAPH_STEP === "1" ? "on" : "off"}` +
2716
+ ` AUTOPLAY=${process.env.WAYGRAPH_AUTOPLAY === "1" ? "on" : "off"}` +
2717
+ ` BASE_URL=${process.env.WAYGRAPH_BASE_URL ?? "(unset)"}` +
2718
+ (process.env.WAYGRAPH_TITLE ? ` TITLE=${JSON.stringify(process.env.WAYGRAPH_TITLE)}` : ""));
2719
+ await runChain(proj, spec);
2720
+ break;
2721
+ }
2722
+ case "auto": {
2723
+ const mermaid = args.includes("--mermaid");
2724
+ const proj = resolve(args.find((a, i) => i >= 1 && !a.startsWith("--")) ?? process.cwd());
2725
+ if (!existsSync(proj)) {
2726
+ console.error(`waygraph: no such directory: ${proj}`);
2727
+ process.exit(1);
2728
+ }
2729
+ const graph = await discoverGraph(proj);
2730
+ if (mermaid) {
2731
+ console.log(toMermaid(graph));
2732
+ }
2733
+ else {
2734
+ console.log(JSON.stringify(graph, null, 2));
2735
+ }
2736
+ console.error(`waygraph auto: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s), ` +
2737
+ `${graph.skipped.length} Block(s) skipped (Out not resolvable)`);
2738
+ break;
2739
+ }
2406
2740
  case "try": {
2407
- const proj = resolve(args[1] ?? process.cwd());
2741
+ const flags = parseRunFlags(args.slice(1));
2742
+ applyRunFlags(flags);
2743
+ const proj = resolve(flags.positionals[0] ?? process.cwd());
2408
2744
  if (!existsSync(proj)) {
2409
2745
  console.error(`waygraph: no such directory: ${proj}`);
2410
2746
  process.exit(1);
package/dist/engine.d.ts CHANGED
@@ -371,7 +371,7 @@ export declare class Engine {
371
371
  * Builds a runnable {@link Flow} from `[start, ...Blocks, end]`, typechecked
372
372
  * so each Block's `In` must match the previous Block's `Out` - the same
373
373
  * check `connect()` does, just declared as a flat array instead of hand-nested
374
- * calls. Overloaded for 2-7 array slots (1-6 real Blocks between `start`/`end`)
374
+ * calls. Overloaded for 2-9 array slots (1-8 real Blocks between `start`/`end`)
375
375
  * rather than one fully-generic recursive tuple type, so each arity is as
376
376
  * reliably checked as `connect<A,B,C>` itself.
377
377
  * @example engine.defineFlow([start, LoginBlock, AddToCartBlock, end])
@@ -399,6 +399,29 @@ export declare class Engine {
399
399
  Block<F, G>,
400
400
  EndMarker
401
401
  ]): Flow<G>;
402
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, G extends Checkpoint<string>, H extends Checkpoint<string>>(blocks: readonly [
403
+ StartMarker,
404
+ Block<S, B>,
405
+ Block<B, C>,
406
+ Block<C, D>,
407
+ Block<D, E>,
408
+ Block<E, F>,
409
+ Block<F, G>,
410
+ Block<G, H>,
411
+ EndMarker
412
+ ]): Flow<H>;
413
+ defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, G extends Checkpoint<string>, H extends Checkpoint<string>, I extends Checkpoint<string>>(blocks: readonly [
414
+ StartMarker,
415
+ Block<S, B>,
416
+ Block<B, C>,
417
+ Block<C, D>,
418
+ Block<D, E>,
419
+ Block<E, F>,
420
+ Block<F, G>,
421
+ Block<G, H>,
422
+ Block<H, I>,
423
+ EndMarker
424
+ ]): Flow<I>;
402
425
  }
403
426
  /**
404
427
  * Attaches routing to `block` without touching its act/observe/resolve/verify -
@@ -497,6 +520,28 @@ export type NavBlockOptions<Out extends Checkpoint<string>> = {
497
520
  * @example defineNavBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
498
521
  */
499
522
  export declare function defineNavBlock<Out extends Checkpoint<string>>(options: NavBlockOptions<Out>): NavBlock<Out>;
523
+ /**
524
+ * "Where am I" - reverse-matches a live `page` against every NavBlock in
525
+ * `library`, in order, returning the first one whose own `verify` Traits all
526
+ * pass (its `checkpoint`), or `null` if none match. Deliberately NavBlocks
527
+ * only: a NavBlock has exactly one fixed `checkpoint` and a `verify` list
528
+ * whose whole job is already "confirm arrival here," so it's a reliable
529
+ * fingerprint - a regular `defineBlock` can branch to different `Out` tags
530
+ * depending on runtime evidence, so its `verify` alone doesn't identify a
531
+ * single state the way a NavBlock's does.
532
+ *
533
+ * The primitive `waygraph auto`'s discovered graph is *for* - the graph says
534
+ * what pages exist and how to reach them; `locate()` is how an autonomous
535
+ * run figures out which one it's actually looking at right now, without a
536
+ * human telling it.
537
+ *
538
+ * More than one NavBlock matching is a real possibility (two Checkpoints
539
+ * whose `verify` Traits are both satisfied by the same page) - `locate()`
540
+ * returns the first match rather than treating that as an error; resolving
541
+ * genuine ambiguity is a follow-up, not solved here.
542
+ * @example const here = await locate(page, [NavLoginBlock, NavDashboardBlock]);
543
+ */
544
+ export declare function locate(page: Page, library: readonly NavBlock<any>[]): Promise<string | null>;
500
545
  /**
501
546
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
502
547
  * same routing - but with `verify` replaced. For giving one flow stricter (or
package/dist/engine.js CHANGED
@@ -633,6 +633,9 @@ export function defineNavBlock(options) {
633
633
  await page.goto(url);
634
634
  }
635
635
  else {
636
+ // Always Locator.click() so step-mode's demo cursor/pulse patch
637
+ // (instrumentInteractionHighlighting) sees NavBlock click-nav -
638
+ // never page.click() / goto teleport.
636
639
  const selector = typeof options.click === "function" ? options.click(mem) : options.click;
637
640
  await page.locator(selector).click();
638
641
  }
@@ -652,8 +655,67 @@ export function defineNavBlock(options) {
652
655
  enumerable: false,
653
656
  configurable: false,
654
657
  });
658
+ // Demo/step tooling: remember the click selector (or factory) so
659
+ // overlays can label "nav click" without re-parsing source.
660
+ if (options.click !== undefined) {
661
+ Object.defineProperty(built, "__waygraphNavClick", {
662
+ value: options.click,
663
+ enumerable: false,
664
+ configurable: false,
665
+ });
666
+ }
655
667
  return built;
656
668
  }
669
+ /**
670
+ * "Where am I" - reverse-matches a live `page` against every NavBlock in
671
+ * `library`, in order, returning the first one whose own `verify` Traits all
672
+ * pass (its `checkpoint`), or `null` if none match. Deliberately NavBlocks
673
+ * only: a NavBlock has exactly one fixed `checkpoint` and a `verify` list
674
+ * whose whole job is already "confirm arrival here," so it's a reliable
675
+ * fingerprint - a regular `defineBlock` can branch to different `Out` tags
676
+ * depending on runtime evidence, so its `verify` alone doesn't identify a
677
+ * single state the way a NavBlock's does.
678
+ *
679
+ * The primitive `waygraph auto`'s discovered graph is *for* - the graph says
680
+ * what pages exist and how to reach them; `locate()` is how an autonomous
681
+ * run figures out which one it's actually looking at right now, without a
682
+ * human telling it.
683
+ *
684
+ * More than one NavBlock matching is a real possibility (two Checkpoints
685
+ * whose `verify` Traits are both satisfied by the same page) - `locate()`
686
+ * returns the first match rather than treating that as an error; resolving
687
+ * genuine ambiguity is a follow-up, not solved here.
688
+ * @example const here = await locate(page, [NavLoginBlock, NavDashboardBlock]);
689
+ */
690
+ export async function locate(page, library) {
691
+ const mem = new MemPage();
692
+ for (const block of library) {
693
+ try {
694
+ // NavBlock's own resolve() ignores whatever it's called with - always
695
+ // exactly `checkpoint(options.checkpoint)` - so this is safe to call
696
+ // before verify, unlike a regular Block where resolve depends on
697
+ // observed evidence.
698
+ const resolved = await block.instruction.resolve(undefined);
699
+ const verify = block.instruction.verify;
700
+ const traits = typeof verify === "function" ? verify(resolved) : (verify ?? []);
701
+ let allPass = true;
702
+ for (const trait of traits) {
703
+ // eslint-disable-next-line no-await-in-loop
704
+ if (!(await trait.check(page, mem))) {
705
+ allPass = false;
706
+ break;
707
+ }
708
+ }
709
+ if (allPass)
710
+ return resolved.__state;
711
+ }
712
+ catch {
713
+ // This NavBlock's own verify threw (e.g. a waitFor timing out) -
714
+ // treat that the same as "didn't match," try the next candidate.
715
+ }
716
+ }
717
+ return null;
718
+ }
657
719
  /**
658
720
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
659
721
  * same routing - but with `verify` replaced. For giving one flow stricter (or
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The state-machine shape `waygraph auto` discovers from a project's own
3
+ * Blocks - Checkpoints are nodes, Blocks are edges. Kept as plain data (not
4
+ * classes/functions) so it's trivially JSON-serializable for a script/agent
5
+ * reading `waygraph auto`'s stdout.
6
+ */
7
+ export interface WaygraphNode {
8
+ checkpoint: string;
9
+ }
10
+ export interface WaygraphEdge {
11
+ /** The Block's own runtime `.name`. */
12
+ block: string;
13
+ /** Path the Block was discovered in, relative to the project root. */
14
+ file: string;
15
+ /** `"*"` for a NavBlock - reachable from anywhere, by design. */
16
+ from: string;
17
+ to: string;
18
+ kind: "nav" | "action";
19
+ }
20
+ export interface SkippedBlock {
21
+ block: string;
22
+ file: string;
23
+ reason: string;
24
+ }
25
+ export interface WaygraphGraph {
26
+ nodes: WaygraphNode[];
27
+ edges: WaygraphEdge[];
28
+ /** Blocks whose Out tag couldn't be statically resolved - not a crash, just excluded. */
29
+ skipped: SkippedBlock[];
30
+ }
31
+ /**
32
+ * Renders a `WaygraphGraph` as Mermaid `stateDiagram-v2` text - paste
33
+ * straight into a `.md` file's ```mermaid fence or a live editor. `"*"`
34
+ * renders as Mermaid's own `[*]` start marker, matching how a NavBlock's
35
+ * `from` already means "reachable from anywhere."
36
+ * @example console.log(toMermaid(graph))
37
+ */
38
+ export declare function toMermaid(graph: WaygraphGraph): string;
39
+ /**
40
+ * Walks every `*.block.ts` file under `projectDir` and builds the app's own
41
+ * state graph. Shares the same in-process-import limitation `waygraph
42
+ * check` already has: a target project whose own `waygraph`/`playwright`
43
+ * are genuinely separate installs (not symlinked into this same
44
+ * node_modules resolution) can crash Playwright's own "second copy" guard
45
+ * on import - acceptable for an introspection command the same way it
46
+ * already is for `check`, unlike `chain`, which spawns a child process
47
+ * specifically because it also EXECUTES Blocks, not just imports them.
48
+ */
49
+ export declare function discoverGraph(projectDir: string): Promise<WaygraphGraph>;
package/dist/graph.js ADDED
@@ -0,0 +1,237 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join, dirname, relative, resolve } from "node:path";
3
+ /**
4
+ * Renders a `WaygraphGraph` as Mermaid `stateDiagram-v2` text - paste
5
+ * straight into a `.md` file's ```mermaid fence or a live editor. `"*"`
6
+ * renders as Mermaid's own `[*]` start marker, matching how a NavBlock's
7
+ * `from` already means "reachable from anywhere."
8
+ * @example console.log(toMermaid(graph))
9
+ */
10
+ export function toMermaid(graph) {
11
+ const lines = ["stateDiagram-v2"];
12
+ for (const edge of graph.edges) {
13
+ const from = edge.from === "*" ? "[*]" : edge.from;
14
+ lines.push(` ${from} --> ${edge.to} : ${edge.block}`);
15
+ }
16
+ return lines.join("\n");
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Discovery - deliberately self-contained (no import from cli.ts): cli.ts
20
+ // runs its own main() unconditionally at module load, so it can't safely be
21
+ // imported from a test (or from here) without also running the whole CLI.
22
+ // These are small, stable duplicates of cli.ts's own discoverBlocks/
23
+ // isBlockLike/isNavBlockMarked/importModule, not a shared abstraction -
24
+ // simpler and lower-risk than a circular import between the two modules.
25
+ // ---------------------------------------------------------------------------
26
+ function walkDir(dir, pattern) {
27
+ const results = [];
28
+ let entries;
29
+ try {
30
+ entries = readdirSync(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return results;
34
+ }
35
+ for (const entry of entries) {
36
+ const full = join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git")
39
+ continue;
40
+ results.push(...walkDir(full, pattern));
41
+ }
42
+ else if (pattern.test(entry.name)) {
43
+ results.push(full);
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ function discoverBlocks(projectDir) {
49
+ return walkDir(projectDir, /\.block\.ts$/);
50
+ }
51
+ async function importModule(filePath) {
52
+ const url = new URL(`file://${resolve(filePath)}`);
53
+ return (await import(url.href));
54
+ }
55
+ function isBlockLike(val) {
56
+ if (val === null || typeof val !== "object")
57
+ return false;
58
+ const obj = val;
59
+ if (typeof obj.name !== "string")
60
+ return false;
61
+ const instruction = obj.instruction;
62
+ return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
63
+ }
64
+ /** Set by `defineNavBlock` (non-enumerable) - see src/engine.ts. */
65
+ function isNavBlockMarked(val) {
66
+ return val.__waygraphKind === "nav";
67
+ }
68
+ /**
69
+ * Resolves `typeName` to every literal Checkpoint tag it can mean, within
70
+ * one `*.states.ts` file's own source text - `type X = Checkpoint<"Tag">`
71
+ * directly, or `type X = A | B | Checkpoint<"C">` (a union of other type
72
+ * names and/or inline Checkpoint literals) recursively. A cycle or unknown
73
+ * identifier just contributes no tags, rather than throwing - callers treat
74
+ * an empty result as "couldn't resolve," not a crash.
75
+ */
76
+ function resolveCheckpointTags(statesSrc, typeName, seen = new Set()) {
77
+ if (seen.has(typeName))
78
+ return [];
79
+ seen.add(typeName);
80
+ const direct = new RegExp(`type\\s+${typeName}\\s*=\\s*Checkpoint<"([^"]+)">`).exec(statesSrc);
81
+ if (direct)
82
+ return [direct[1]];
83
+ const aliased = new RegExp(`type\\s+${typeName}\\s*=\\s*([^;]+);`).exec(statesSrc);
84
+ if (!aliased)
85
+ return [];
86
+ const tags = [];
87
+ for (const member of aliased[1].split("|").map((s) => s.trim()).filter(Boolean)) {
88
+ const inline = /^Checkpoint<"([^"]+)">$/.exec(member);
89
+ if (inline) {
90
+ tags.push(inline[1]);
91
+ }
92
+ else if (/^[A-Za-z_$][\w]*$/.test(member)) {
93
+ tags.push(...resolveCheckpointTags(statesSrc, member, seen));
94
+ }
95
+ }
96
+ return tags;
97
+ }
98
+ /**
99
+ * Finds which file `blockFilePath`'s own `import type { ... }` statements
100
+ * say `typeName` comes from, resolved to a real path on disk (`.js` ->
101
+ * `.ts`, matching this project's own NodeNext import convention). Only
102
+ * follows relative imports - `typeName` imported from a package (e.g.
103
+ * `waygraph` itself, for `Checkpoint`) has no project file to read.
104
+ */
105
+ function findTypeSourceFile(blockFilePath, typeName) {
106
+ const src = readFileSync(blockFilePath, "utf-8");
107
+ const importRe = /import\s+type\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
108
+ let m;
109
+ while ((m = importRe.exec(src)) !== null) {
110
+ const names = m[1].split(",").map((s) => s.trim());
111
+ if (!names.includes(typeName))
112
+ continue;
113
+ const importPath = m[2];
114
+ if (!importPath.startsWith("."))
115
+ continue;
116
+ const resolved = join(dirname(blockFilePath), importPath.replace(/\.js$/, ".ts"));
117
+ if (existsSync(resolved))
118
+ return resolved;
119
+ }
120
+ return null;
121
+ }
122
+ /** `findTypeSourceFile` + `resolveCheckpointTags`, chained - `[]` on any failure along the way. */
123
+ function resolveTypeToTags(blockFilePath, typeName) {
124
+ const statesFile = findTypeSourceFile(blockFilePath, typeName);
125
+ if (!statesFile)
126
+ return [];
127
+ return resolveCheckpointTags(readFileSync(statesFile, "utf-8"), typeName);
128
+ }
129
+ /**
130
+ * Resolves one `defineBlock<In, Out>` generic argument, as captured by
131
+ * `extractDefineBlockGenerics` - either a plain type identifier (goes
132
+ * through `resolveTypeToTags`), an inline `Checkpoint<"Tag">` literal, or
133
+ * the documented wildcard forms `Checkpoint<string>`/`Checkpoint<any>` (a
134
+ * Block reused across several entry points, e.g. this project's own
135
+ * `AddToCartBlock` - reachable from anywhere, same as a NavBlock's own `"*"`).
136
+ */
137
+ function resolveGenericArg(blockFilePath, arg) {
138
+ if (arg === "Checkpoint<string>" || arg === "Checkpoint<any>")
139
+ return ["*"];
140
+ const inline = /^Checkpoint<"([^"]+)">$/.exec(arg);
141
+ if (inline)
142
+ return [inline[1]];
143
+ return resolveTypeToTags(blockFilePath, arg);
144
+ }
145
+ /** Every `defineBlock<In, Out>(` call site in a file's own source, in order. */
146
+ function extractDefineBlockGenerics(src) {
147
+ // Each argument is either a plain identifier or an inline `Checkpoint<...>`
148
+ // (its own angle brackets, so the outer defineBlock<...> pair alone won't
149
+ // balance correctly against a naive greedy match) - captured as a single
150
+ // alternation so both shapes come out of the same two capture groups.
151
+ const arg = String.raw `(Checkpoint<[^>]*>|[A-Za-z_$][\w]*)`;
152
+ const re = new RegExp(String.raw `defineBlock<\s*${arg}\s*,\s*${arg}\s*>`, "g");
153
+ const calls = [];
154
+ let m;
155
+ while ((m = re.exec(src)) !== null) {
156
+ calls.push({ in: m[1], out: m[2] });
157
+ }
158
+ return calls;
159
+ }
160
+ /**
161
+ * Walks every `*.block.ts` file under `projectDir` and builds the app's own
162
+ * state graph. Shares the same in-process-import limitation `waygraph
163
+ * check` already has: a target project whose own `waygraph`/`playwright`
164
+ * are genuinely separate installs (not symlinked into this same
165
+ * node_modules resolution) can crash Playwright's own "second copy" guard
166
+ * on import - acceptable for an introspection command the same way it
167
+ * already is for `check`, unlike `chain`, which spawns a child process
168
+ * specifically because it also EXECUTES Blocks, not just imports them.
169
+ */
170
+ export async function discoverGraph(projectDir) {
171
+ const files = discoverBlocks(projectDir);
172
+ const nodeTags = new Set();
173
+ const edges = [];
174
+ const skipped = [];
175
+ for (const file of files) {
176
+ const relFile = relative(projectDir, file);
177
+ let mod;
178
+ try {
179
+ mod = await importModule(file);
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ const src = readFileSync(file, "utf-8");
185
+ // Assumes source order matches export-object iteration order - true for
186
+ // this codebase's own convention of one Block per file; a file with
187
+ // several regular Blocks in an unusual order is a known edge case, not
188
+ // handled specially here.
189
+ const calls = extractDefineBlockGenerics(src);
190
+ let callIndex = 0;
191
+ for (const exported of Object.values(mod)) {
192
+ if (!isBlockLike(exported))
193
+ continue;
194
+ const blockName = exported.name;
195
+ if (isNavBlockMarked(exported)) {
196
+ try {
197
+ // NavBlock's own resolve() ignores whatever it's called with -
198
+ // always exactly `checkpoint(options.checkpoint)`.
199
+ const resolveFn = exported.instruction.resolve;
200
+ const resolved = await resolveFn(undefined);
201
+ const to = resolved.__state;
202
+ nodeTags.add(to);
203
+ edges.push({ block: blockName, file: relFile, from: "*", to, kind: "nav" });
204
+ }
205
+ catch {
206
+ skipped.push({ block: blockName, file: relFile, reason: "NavBlock's own resolve() threw" });
207
+ }
208
+ continue;
209
+ }
210
+ const call = calls[callIndex++];
211
+ if (!call) {
212
+ skipped.push({ block: blockName, file: relFile, reason: "no defineBlock<In, Out> generic call found" });
213
+ continue;
214
+ }
215
+ const fromTags = resolveGenericArg(file, call.in);
216
+ const toTags = resolveGenericArg(file, call.out);
217
+ if (fromTags.length === 0 || toTags.length === 0) {
218
+ const badName = fromTags.length === 0 ? call.in : call.out;
219
+ skipped.push({ block: blockName, file: relFile, reason: `couldn't resolve "${badName}" to a Checkpoint tag` });
220
+ continue;
221
+ }
222
+ for (const from of fromTags) {
223
+ for (const to of toTags) {
224
+ if (from !== "*")
225
+ nodeTags.add(from);
226
+ nodeTags.add(to);
227
+ edges.push({ block: blockName, file: relFile, from, to, kind: "action" });
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return {
233
+ nodes: [...nodeTags].sort().map((checkpoint) => ({ checkpoint })),
234
+ edges,
235
+ skipped,
236
+ };
237
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./type
2
2
  export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
5
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, locate, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
6
6
  export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions } from "./engine.js";
7
7
  export type { ActionPage } from "./types.js";
8
+ export { discoverGraph, toMermaid } from "./graph.js";
9
+ export type { WaygraphGraph, WaygraphNode, WaygraphEdge, SkippedBlock } from "./graph.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
4
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, locate, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
5
+ export { discoverGraph, toMermaid } from "./graph.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",
@@ -26,7 +26,8 @@
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.build.json",
28
28
  "typecheck": "tsc --noEmit",
29
- "test": "playwright test"
29
+ "test": "playwright test",
30
+ "docs:preview": "python3 -m http.server 4173 --directory docs"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@playwright/test": "^1.40.0"