waygraph 0.7.2 → 0.7.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.
- package/README.md +25 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +355 -19
- package/dist/engine.d.ts +46 -1
- package/dist/engine.js +62 -0
- package/dist/graph.d.ts +49 -0
- package/dist/graph.js +237 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/package.json +3 -2
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
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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
|
-
|
|
1859
|
-
|
|
1860
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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-
|
|
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
|
package/dist/graph.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.7.4",
|
|
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"
|