waygraph 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,998 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * waygraph CLI -- tooling around this same package's engine.
4
+ *
5
+ * Commands:
6
+ * list [project] List discovered flows in a project
7
+ * nav [flow] [project] Print flow chain + navigation-only steps (goto / Trait.url)
8
+ * validate [project] Import and validate all flows
9
+ * run <flow> [project] Run a named flow with a fresh browser
10
+ * chain <spec> [project] Run one or more Blocks by name, ad hoc, no flow file needed
11
+ *
12
+ * "project" defaults to the current directory.
13
+ * A project is any directory containing *.flow.ts files (typically under src/flows/).
14
+ */
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ import { resolve, relative, join, basename } from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { MemPage } from "./mem-page.js";
19
+ // ---------------------------------------------------------------------------
20
+ // Filesystem
21
+ // ---------------------------------------------------------------------------
22
+ function walkDir(dir, pattern) {
23
+ const results = [];
24
+ let entries;
25
+ try {
26
+ entries = readdirSync(dir, { withFileTypes: true });
27
+ }
28
+ catch {
29
+ return results;
30
+ }
31
+ for (const entry of entries) {
32
+ const full = join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git")
35
+ continue;
36
+ results.push(...walkDir(full, pattern));
37
+ }
38
+ else if (pattern.test(entry.name)) {
39
+ results.push(full);
40
+ }
41
+ }
42
+ return results;
43
+ }
44
+ function discoverFlows(projectDir) {
45
+ return walkDir(projectDir, /\.flow\.ts$/);
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Static analysis for `list`/`nav` (no import, regex only)
49
+ // ---------------------------------------------------------------------------
50
+ function extractFlowNames(filePath) {
51
+ const src = readFileSync(filePath, "utf-8");
52
+ const names = [];
53
+ const re = /export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*[=:]/g;
54
+ let m;
55
+ while ((m = re.exec(src)) !== null) {
56
+ const name = m[1];
57
+ if (name !== undefined)
58
+ names.push(name);
59
+ }
60
+ return names;
61
+ }
62
+ /** `LoginBlock` -> `"login"`; `OverviewMetricsBlock` -> `"overview-metrics"`. */
63
+ function blockExportToLabel(exportId) {
64
+ const base = exportId.replace(/Block$/, "");
65
+ return base
66
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
67
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
68
+ .toLowerCase();
69
+ }
70
+ /** Every `...Block` identifier a `.flow.ts` file imports, in source order. */
71
+ function parseBlockImports(filePath) {
72
+ const src = readFileSync(filePath, "utf-8");
73
+ const names = [];
74
+ const re = /import\s*\{([^}]*)\}\s*from\s*["'][^"']*\.block(?:\.js)?["']/g;
75
+ let m;
76
+ while ((m = re.exec(src)) !== null) {
77
+ for (const part of m[1].split(",")) {
78
+ const name = part.trim().split(/\s+as\s+/)[0]?.trim();
79
+ if (name)
80
+ names.push(name);
81
+ }
82
+ }
83
+ return names;
84
+ }
85
+ /** Every `page.goto("...")` string literal in a file, in source order. */
86
+ function extractGotos(filePath) {
87
+ const src = readFileSync(filePath, "utf-8");
88
+ const results = [];
89
+ const re = /\.goto\(\s*["'`]([^"'`]*)["'`]/g;
90
+ let m;
91
+ while ((m = re.exec(src)) !== null) {
92
+ const url = m[1];
93
+ if (url !== undefined)
94
+ results.push(url);
95
+ }
96
+ return results;
97
+ }
98
+ /** Every `Trait.url({ pathname: "..." })` target in a file, in source order. */
99
+ function extractUrlTraits(filePath) {
100
+ const src = readFileSync(filePath, "utf-8");
101
+ const results = [];
102
+ const re = /Trait\.url\(\s*\{[^}]*pathname:\s*["'`]([^"'`]*)["'`]/g;
103
+ let m;
104
+ while ((m = re.exec(src)) !== null) {
105
+ const url = m[1];
106
+ if (url !== undefined)
107
+ results.push(url);
108
+ }
109
+ return results;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // `list`
113
+ // ---------------------------------------------------------------------------
114
+ function listCommand(projectDir) {
115
+ const files = discoverFlows(projectDir);
116
+ if (files.length === 0) {
117
+ console.log(`waygraph: no flows found under ${projectDir}`);
118
+ return;
119
+ }
120
+ for (const file of files) {
121
+ const rel = relative(projectDir, file);
122
+ for (const name of extractFlowNames(file)) {
123
+ console.log(`${rel} ${name}`);
124
+ }
125
+ }
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // `nav`
129
+ // ---------------------------------------------------------------------------
130
+ function navCommand(projectDir, flowName) {
131
+ const files = discoverFlows(projectDir);
132
+ const targets = flowName
133
+ ? files.filter((f) => extractFlowNames(f).includes(flowName))
134
+ : files;
135
+ if (targets.length === 0) {
136
+ console.error(flowName
137
+ ? `waygraph nav: no flow named "${flowName}" found under ${projectDir}`
138
+ : `waygraph: no flows found under ${projectDir}`);
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ for (const file of targets) {
143
+ for (const name of extractFlowNames(file)) {
144
+ console.log(`${name} (${relative(projectDir, file)})`);
145
+ const blockDir = projectDir;
146
+ for (const blockExport of parseBlockImports(file)) {
147
+ console.log(` -> ${blockExportToLabel(blockExport)}`);
148
+ const blockFile = walkDir(blockDir, /\.block\.ts$/).find((f) => readFileSync(f, "utf-8").includes(`export const ${blockExport}`));
149
+ if (!blockFile)
150
+ continue;
151
+ for (const url of extractGotos(blockFile))
152
+ console.log(` goto ${url}`);
153
+ for (const url of extractUrlTraits(blockFile))
154
+ console.log(` verify url ~ ${url}`);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Dynamic import + validation
161
+ // ---------------------------------------------------------------------------
162
+ async function importModule(filePath) {
163
+ const url = new URL(`file://${resolve(filePath)}`);
164
+ return (await import(url.href));
165
+ }
166
+ /** Kept as a named alias - `run`/`validate` below were written against this name first. */
167
+ const importFlowFile = importModule;
168
+ function isFlowLike(val) {
169
+ if (val === null || typeof val !== "object")
170
+ return false;
171
+ const obj = val;
172
+ return typeof obj.run === "function";
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // `chain`
176
+ // ---------------------------------------------------------------------------
177
+ /**
178
+ * `chain` cannot resolve Blocks by importing them straight into THIS
179
+ * process: this CLI's own `waygraph` (this same package) already loaded -
180
+ * and its engine does a top-level `import { chromium } from "playwright"`.
181
+ * A target project like zsign-all installs its OWN real, separate copy of
182
+ * both `waygraph` and `playwright` (never a symlink - see
183
+ * `install-links=true`), so importing one of its `.block.ts` files pulls in
184
+ * a second, physically different `playwright` package - which Playwright's
185
+ * own runtime refuses outright ("Requiring @playwright/test second time"),
186
+ * crashing every single import. Confirmed empirically against all 117 real
187
+ * zsign-all Blocks - 100% failure rate, not a corner case.
188
+ *
189
+ * The fix: never let the two copies share a process. This script is
190
+ * written into the TARGET project directory and run as its own child
191
+ * process, so every bare `import("waygraph")` / `import("playwright")` it
192
+ * does resolves relative to ITS OWN location on disk - the target
193
+ * project's own `node_modules`, exclusively. This CLI's own copy never
194
+ * loads in that process at all.
195
+ */
196
+ const CHAIN_RUNNER_SCRIPT = `
197
+ import { readdirSync } from "node:fs";
198
+ import { join } from "node:path";
199
+
200
+ function walkDir(dir, pattern) {
201
+ const results = [];
202
+ let entries;
203
+ try {
204
+ entries = readdirSync(dir, { withFileTypes: true });
205
+ } catch {
206
+ return results;
207
+ }
208
+ for (const entry of entries) {
209
+ const full = join(dir, entry.name);
210
+ if (entry.isDirectory()) {
211
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
212
+ results.push(...walkDir(full, pattern));
213
+ } else if (pattern.test(entry.name)) {
214
+ results.push(full);
215
+ }
216
+ }
217
+ return results;
218
+ }
219
+
220
+ function isBlockLike(val) {
221
+ if (val === null || typeof val !== "object") return false;
222
+ if (typeof val.name !== "string") return false;
223
+ if (val.instruction === null || typeof val.instruction !== "object") return false;
224
+ return typeof val.instruction.act === "function";
225
+ }
226
+
227
+ async function findBlock(projectDir, ref) {
228
+ const files = walkDir(projectDir, /\\.block\\.ts$/);
229
+ const byRuntimeName = [];
230
+ for (const file of files) {
231
+ let mod;
232
+ try {
233
+ mod = await import("file://" + file);
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const [exportName, exported] of Object.entries(mod)) {
238
+ if (!isBlockLike(exported)) continue;
239
+ if (exportName === ref) return { block: exported, exportName, file };
240
+ if (exported.name === ref) byRuntimeName.push({ block: exported, exportName, file });
241
+ }
242
+ }
243
+ if (byRuntimeName.length === 1) return byRuntimeName[0];
244
+ if (byRuntimeName.length > 1) {
245
+ throw new Error(
246
+ "waygraph chain: \\"" + ref + "\\" matches " + byRuntimeName.length + " Blocks by name - " +
247
+ byRuntimeName.map((b) => b.exportName + " (" + b.file + ")").join(", ") +
248
+ ". Use the export name to disambiguate.",
249
+ );
250
+ }
251
+ throw new Error("waygraph chain: no Block named \\"" + ref + "\\" found under " + projectDir);
252
+ }
253
+
254
+ /**
255
+ * Finds an already-defined Flow by its export name (e.g. loginFlow) among
256
+ * the project's *.flow.ts files - "I already have this wired up, just point
257
+ * the stepper at it, no chain spec to hand-write." Flows have no runtime
258
+ * .name of their own (unlike Blocks), so this only matches by export
259
+ * identifier. Returns null (not a throw) when nothing matches - the caller
260
+ * falls back to ordinary block-chain spec parsing.
261
+ */
262
+ async function findFlow(projectDir, flowName) {
263
+ const files = walkDir(projectDir, /\\.flow\\.ts$/);
264
+ for (const file of files) {
265
+ let mod;
266
+ try {
267
+ mod = await import("file://" + file);
268
+ } catch {
269
+ continue;
270
+ }
271
+ const candidate = mod[flowName];
272
+ if (candidate && typeof candidate.run === "function") {
273
+ return candidate;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ function parseChainSpec(spec) {
280
+ return spec
281
+ .split(/\\bthen\\b/)
282
+ .map((s) => s.trim())
283
+ .filter((s) => s.length > 0)
284
+ .map((s) => {
285
+ const m = /^([A-Za-z_$][\\w-]*)\\s*(?:\\(([\\s\\S]*)\\))?$/.exec(s);
286
+ if (!m) {
287
+ throw new Error(
288
+ "waygraph chain: could not parse segment \\"" + s + "\\" - expected \\"blockName\\" or \\"blockName({...json...})\\"",
289
+ );
290
+ }
291
+ const ref = m[1];
292
+ const json = m[2] ? m[2].trim() : undefined;
293
+ return json ? { ref, json } : { ref };
294
+ });
295
+ }
296
+
297
+ function seedMemForBlock(mem, resolved, json) {
298
+ const requires = resolved.block.requires ?? [];
299
+ if (requires.length === 0) {
300
+ if (json !== undefined) {
301
+ throw new Error(
302
+ "waygraph chain: \\"" + resolved.exportName + "\\" takes no input (empty requires) but got a payload: " + json,
303
+ );
304
+ }
305
+ return;
306
+ }
307
+ if (json === undefined) {
308
+ throw new Error(
309
+ "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.map((k) => k.name).join(", ") +
310
+ " - give a JSON payload, e.g. " + resolved.exportName + "({...})",
311
+ );
312
+ }
313
+ let parsed;
314
+ try {
315
+ parsed = JSON.parse(json);
316
+ } catch (err) {
317
+ throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is not valid JSON - " + String(err));
318
+ }
319
+ if (requires.length === 1) {
320
+ mem.set(requires[0], parsed);
321
+ return;
322
+ }
323
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
324
+ throw new Error(
325
+ "waygraph chain: \\"" + resolved.exportName + "\\" requires " + requires.length + " keys (" +
326
+ requires.map((k) => k.name).join(", ") + ") - payload must be an object keyed by each key's name",
327
+ );
328
+ }
329
+ for (const k of requires) {
330
+ if (!(k.name in parsed)) {
331
+ throw new Error("waygraph chain: \\"" + resolved.exportName + "\\" payload is missing required key \\"" + k.name + "\\"");
332
+ }
333
+ mem.set(k, parsed[k.name]);
334
+ }
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // WAYGRAPH_STEP=1 - human-verification overlay: one Block at a time, a
339
+ // browser-injected panel showing/editing that Block's MemKeys, a "Run this
340
+ // step" gate, then a highlight ring over whatever its own verify Traits just
341
+ // confirmed, then a "Next" gate before moving on. Ring CSS borrowed from
342
+ // help-center-clip-engine's video-pipeline overlay (same purple ring +
343
+ // label-under-box language, minus everything camera/narration-specific).
344
+ // Purely CLI-level orchestration - no engine changes, no change to the
345
+ // non-STEP path above.
346
+ const RING_CSS =
347
+ "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
348
+ "border:2.5px solid #7C3AED;border-radius:10px;" +
349
+ "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
350
+ "#wg-ring::after{content:attr(data-label);position:absolute;left:0;top:calc(100% + 8px);" +
351
+ "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
352
+ "font:600 12px/1.2 system-ui,sans-serif;}" +
353
+ "#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
354
+ "max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
355
+ "background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
356
+ "padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
357
+ "opacity:0;transition:opacity .25s ease;}" +
358
+ "#wg-panel.wg-in{opacity:1;}" +
359
+ "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
360
+ "#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
361
+ "letter-spacing:.02em;text-transform:uppercase;}" +
362
+ "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
363
+ "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
364
+ "#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
365
+ "#wg-modules .wg-mod{padding:3px 9px;border-radius:6px;font:600 11px system-ui,sans-serif;}" +
366
+ "#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
367
+ "#wg-modules .wg-mod-current{background:#7C3AED;color:#fff;}" +
368
+ "#wg-modules .wg-mod-upcoming{background:transparent;color:#5a4a80;border:1px solid #3a2a60;}" +
369
+ "#wg-panel .wg-key{margin:8px 0;}" +
370
+ "#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
371
+ "#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
372
+ "border:1px solid #4b2a80;border-radius:8px;padding:6px 8px;font:12px/1.3 monospace;resize:vertical;}" +
373
+ "#wg-panel button{margin-top:10px;background:#7C3AED;color:#fff;border:none;border-radius:8px;" +
374
+ "padding:8px 16px;font:600 13px system-ui,sans-serif;cursor:pointer;}" +
375
+ "#wg-panel button:hover{background:#6b2fd6;}" +
376
+ "#wg-panel .wg-result{font:12px/1.4 monospace;background:#0f0620;border-radius:8px;padding:8px;" +
377
+ "margin:8px 0;white-space:pre-wrap;}" +
378
+ "#wg-panel .wg-result-pretty{font:600 14px/1.4 system-ui,sans-serif;}" +
379
+ "#wg-panel .wg-toggle{display:flex;gap:4px;margin:0 0 4px;}" +
380
+ "#wg-panel .wg-toggle button{margin:0;padding:3px 10px;font:600 11px system-ui,sans-serif;" +
381
+ "background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
382
+ "#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}";
383
+
384
+ async function installOverlay(page) {
385
+ await page.addStyleTag({ content: RING_CSS }).catch(() => {});
386
+ await page
387
+ .evaluate(() => {
388
+ if (!document.getElementById("wg-ring")) {
389
+ const ring = document.createElement("div");
390
+ ring.id = "wg-ring";
391
+ document.documentElement.appendChild(ring);
392
+ }
393
+ })
394
+ .catch(() => {});
395
+ }
396
+
397
+ async function renderBeforeStep(page, info) {
398
+ await installOverlay(page);
399
+ // A ring left highlighting the PREVIOUS step's element (and its live
400
+ // resize/scroll tracker) shouldn't linger once a new step's own panel is
401
+ // up - only relevant when a Block's act() doesn't navigate away, since a
402
+ // real navigation already wipes document.documentElement's children.
403
+ await page
404
+ .evaluate(() => {
405
+ if (window.__wgRingTrack) {
406
+ window.removeEventListener("resize", window.__wgRingTrack);
407
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
408
+ window.__wgRingTrack = null;
409
+ }
410
+ const ring = document.getElementById("wg-ring");
411
+ if (ring) ring.style.opacity = "0";
412
+ })
413
+ .catch(() => {});
414
+ await page
415
+ .evaluate((info) => {
416
+ const old = document.getElementById("wg-panel");
417
+ if (old) old.remove();
418
+ const panel = document.createElement("div");
419
+ panel.id = "wg-panel";
420
+ const pct = Math.round((info.index / info.total) * 100);
421
+ const modulesHtml = info.allNames
422
+ .map((name, idx) => {
423
+ const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
424
+ return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
425
+ })
426
+ .join("");
427
+ let html =
428
+ "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
429
+ "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
430
+ "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>";
431
+ if (info.keys.length === 0) {
432
+ html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
433
+ }
434
+ for (const k of info.keys) {
435
+ html +=
436
+ "<div class=\\"wg-key\\"><label>" + k.name + "</label>" +
437
+ "<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
438
+ k.value.replace(/</g, "&lt;") + "</textarea></div>";
439
+ }
440
+ html += info.autoplay
441
+ ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
442
+ : "<button id=\\"wg-run\\">Run this step \\u25B6</button>";
443
+ panel.innerHTML = html;
444
+ document.documentElement.appendChild(panel);
445
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
446
+ const runBtn = document.getElementById("wg-run");
447
+ if (runBtn) {
448
+ runBtn.addEventListener("click", () => {
449
+ const edits = {};
450
+ panel.querySelectorAll("textarea[data-key]").forEach((el) => {
451
+ edits[el.getAttribute("data-key")] = el.value;
452
+ });
453
+ window.__wgNext(edits);
454
+ });
455
+ }
456
+ }, info)
457
+ .catch(() => {});
458
+ }
459
+
460
+ async function renderAfterStep(page, info) {
461
+ await installOverlay(page);
462
+ // Tracks the highlighted element live (recomputes on resize/scroll)
463
+ // instead of a one-time Node-side boundingBox() snapshot - a resized
464
+ // window used to leave the ring frozen at its stale old position.
465
+ // Only the first recovered selector is drawn (one ring); best-effort - a
466
+ // selector that never resolves just leaves the ring hidden.
467
+ await page
468
+ .evaluate((highlights) => {
469
+ if (window.__wgRingTrack) {
470
+ window.removeEventListener("resize", window.__wgRingTrack);
471
+ window.removeEventListener("scroll", window.__wgRingTrack, true);
472
+ window.__wgRingTrack = null;
473
+ }
474
+ const ring = document.getElementById("wg-ring");
475
+ if (!ring) return;
476
+ const h = highlights[0];
477
+ if (!h) {
478
+ ring.style.opacity = "0";
479
+ return;
480
+ }
481
+ const reposition = () => {
482
+ const el = document.querySelector(h.selector);
483
+ if (!el) {
484
+ ring.style.opacity = "0";
485
+ return;
486
+ }
487
+ const box = el.getBoundingClientRect();
488
+ ring.style.left = box.x - 6 + "px";
489
+ ring.style.top = box.y - 6 + "px";
490
+ ring.style.width = box.width + 12 + "px";
491
+ ring.style.height = box.height + 12 + "px";
492
+ ring.setAttribute("data-label", h.label);
493
+ ring.style.opacity = "1";
494
+ };
495
+ reposition();
496
+ window.__wgRingTrack = reposition;
497
+ window.addEventListener("resize", reposition);
498
+ window.addEventListener("scroll", reposition, true);
499
+ }, info.highlights)
500
+ .catch(() => {});
501
+ await page
502
+ .evaluate((info) => {
503
+ const old = document.getElementById("wg-panel");
504
+ if (old) old.remove();
505
+ const panel = document.createElement("div");
506
+ panel.id = "wg-panel";
507
+ const pct = Math.round(((info.index + 1) / info.total) * 100);
508
+ const heading = info.isLast
509
+ ? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
510
+ : "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " done";
511
+ const buttonLabel = info.isLast ? "Finish" : "Next \\u25B6";
512
+ const modulesHtml = info.allNames
513
+ .map((name, idx) => {
514
+ const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
515
+ return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
516
+ })
517
+ .join("");
518
+ // QA-friendly by default ("LoginSuccess" -> "Login Success") - raw
519
+ // JSON is one click away for whoever actually wants __state.
520
+ const stateTag = info.result && info.result.__state ? info.result.__state : "";
521
+ const prettyText = stateTag
522
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
523
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") || info.resultTag;
524
+ const pretty = window.__wgPretty !== false;
525
+ const resultHtml =
526
+ "<div class=\\"wg-toggle\\">" +
527
+ "<button class=\\"wg-toggle-btn" + (pretty ? " wg-active" : "") + "\\" data-mode=\\"pretty\\">Pretty</button>" +
528
+ "<button class=\\"wg-toggle-btn" + (pretty ? "" : " wg-active") + "\\" data-mode=\\"json\\">JSON</button>" +
529
+ "</div>" +
530
+ "<div class=\\"wg-result wg-result-pretty\\" style=\\"display:" + (pretty ? "block" : "none") + "\\">" +
531
+ prettyText + "</div>" +
532
+ "<div class=\\"wg-result wg-result-json\\" style=\\"display:" + (pretty ? "none" : "block") + "\\">" +
533
+ info.resultTag + "</div>";
534
+ const gateHtml = info.autoplay
535
+ ? "<div class=\\"wg-auto\\">Auto-advancing...</div>"
536
+ : "<button id=\\"wg-run\\">" + buttonLabel + "</button>";
537
+ panel.innerHTML =
538
+ "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
539
+ "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
540
+ "<h3>" + heading + "</h3>" +
541
+ resultHtml +
542
+ gateHtml;
543
+ document.documentElement.appendChild(panel);
544
+ requestAnimationFrame(() => panel.classList.add("wg-in"));
545
+ panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
546
+ btn.addEventListener("click", () => {
547
+ const wantPretty = btn.getAttribute("data-mode") === "pretty";
548
+ window.__wgPretty = wantPretty;
549
+ panel.querySelectorAll(".wg-toggle-btn").forEach((b) => b.classList.remove("wg-active"));
550
+ btn.classList.add("wg-active");
551
+ panel.querySelector(".wg-result-pretty").style.display = wantPretty ? "block" : "none";
552
+ panel.querySelector(".wg-result-json").style.display = wantPretty ? "none" : "block";
553
+ });
554
+ });
555
+ const runBtn = document.getElementById("wg-run");
556
+ if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
557
+ }, info)
558
+ .catch(() => {});
559
+ }
560
+
561
+ /**
562
+ * Recovers a DOM selector from a built-in Trait's own .name string -
563
+ * visible(sel) / text-equals(sel, "...") - since Trait.check itself is
564
+ * an opaque closure with no selector field of its own. Best-effort only: a
565
+ * hand-written bespoke Trait, or url-matches(...) (no DOM target), yields
566
+ * nothing to highlight, which is fine - the panel still shows the result.
567
+ */
568
+ function extractVerifyHighlights(block, resultTag) {
569
+ let verify = block.instruction && block.instruction.verify;
570
+ if (typeof verify === "function") {
571
+ try {
572
+ verify = verify({ __state: resultTag });
573
+ } catch {
574
+ verify = [];
575
+ }
576
+ }
577
+ if (!Array.isArray(verify)) return [];
578
+ const highlights = [];
579
+ for (const t of verify) {
580
+ const name = t && t.name;
581
+ if (typeof name !== "string") continue;
582
+ let m = /^visible\\((.+)\\)$/.exec(name);
583
+ if (m) {
584
+ highlights.push({ selector: m[1], label: name });
585
+ continue;
586
+ }
587
+ m = /^text-equals\\((.+?),\\s*"/.exec(name);
588
+ if (m) {
589
+ highlights.push({ selector: m[1], label: name });
590
+ continue;
591
+ }
592
+ }
593
+ return highlights;
594
+ }
595
+
596
+ /**
597
+ * Patches Locator.prototype.fill (via any real locator's own prototype
598
+ * chain - Playwright doesn't export the class directly) so every fill(),
599
+ * regardless of how the Block built that locator (page.locator, chained
600
+ * .getByLabel off a scoped form locator, etc.), highlights the real target
601
+ * first, then types it out character by character instead of snapping the
602
+ * whole value in - "when something is written from mem, it should
603
+ * highlight then slowly input." One-time patch (idempotent - guarded so a
604
+ * multi-step chain doesn't re-wrap an already-wrapped fill).
605
+ */
606
+ function instrumentFillHighlighting(page) {
607
+ const proto = Object.getPrototypeOf(page.locator("html"));
608
+ if (proto.__wgFillPatched) return;
609
+ proto.__wgFillPatched = true;
610
+ const originalFill = proto.fill;
611
+ proto.fill = async function (value, options) {
612
+ try {
613
+ await installOverlay(page);
614
+ const box = await this.boundingBox();
615
+ if (box) {
616
+ await page.evaluate(
617
+ (box) => {
618
+ const ring = document.getElementById("wg-ring");
619
+ if (!ring) return;
620
+ ring.style.left = box.x - 6 + "px";
621
+ ring.style.top = box.y - 6 + "px";
622
+ ring.style.width = box.width + 12 + "px";
623
+ ring.style.height = box.height + 12 + "px";
624
+ ring.setAttribute("data-label", "writing from mem");
625
+ ring.style.opacity = "1";
626
+ },
627
+ box,
628
+ );
629
+ await new Promise((res) => setTimeout(res, 200));
630
+ }
631
+ } catch {
632
+ // best-effort - element not visible/attached yet is not this
633
+ // instrumentation's problem, the real fill below still runs
634
+ }
635
+ try {
636
+ // NOT this.clear() - Locator.clear() is itself implemented as
637
+ // fill(""), and since fill is patched on the shared prototype, that
638
+ // call would resolve back to THIS same patched function and recurse
639
+ // forever. Call the real original fill directly to clear instead.
640
+ await originalFill.call(this, "", { timeout: options && options.timeout });
641
+ return await this.pressSequentially(String(value), { delay: 45, timeout: options && options.timeout });
642
+ } catch {
643
+ // pressSequentially unsupported on this element (e.g. a
644
+ // contenteditable div, or a locator .fill() genuinely needs to
645
+ // handle specially) - fall back to the real, unpatched fill.
646
+ return await originalFill.call(this, value, options);
647
+ }
648
+ };
649
+ }
650
+
651
+ async function runStepMode(engine, start, end, context, page, mem, resolved) {
652
+ instrumentFillHighlighting(page);
653
+ let resolveNext = null;
654
+ await page.exposeFunction("__wgNext", (edits) => {
655
+ if (resolveNext) {
656
+ const r = resolveNext;
657
+ resolveNext = null;
658
+ r(edits);
659
+ }
660
+ });
661
+ const waitForNext = () => new Promise((res) => (resolveNext = res));
662
+ // WAYGRAPH_AUTOPLAY=1 - hands-off, matching the existing
663
+ // scripts/waygraph-demo.mjs pattern (plain slowMo, no clicks, just watch)
664
+ // but keeping this overlay's progress bar/module breadcrumb/highlight.
665
+ const autoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
666
+ const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
667
+ const gate = () => (autoplay ? new Promise((res) => setTimeout(() => res({}), autoplayMs)) : waitForNext());
668
+ const allNames = resolved.map((r) => r.block.name);
669
+
670
+ let result;
671
+ for (let i = 0; i < resolved.length; i++) {
672
+ const r = resolved[i];
673
+ const requires = r.block.requires ?? [];
674
+ const keys = requires.map((k) => {
675
+ let value = "<not yet set>";
676
+ try {
677
+ value = JSON.stringify(mem.get(k));
678
+ } catch {
679
+ // not written yet - shown as a placeholder, not a crash
680
+ }
681
+ return { name: k.name, value };
682
+ });
683
+ await renderBeforeStep(page, { index: i, total: resolved.length, blockName: r.block.name, keys, allNames, autoplay });
684
+ const edits = await gate();
685
+ for (const k of requires) {
686
+ if (edits[k.name] !== undefined) {
687
+ try {
688
+ mem.set(k, JSON.parse(edits[k.name]));
689
+ } catch {
690
+ // left as-authored if the human's edit isn't valid JSON
691
+ }
692
+ }
693
+ }
694
+ const stepFlow = engine.defineFlow([start, r.block, end]);
695
+ // { closeOnFinish: false } makes Flow.run return { result, page }, not
696
+ // the plain checkpoint - destructure it, don't treat the wrapper as the
697
+ // checkpoint itself (caught via the standalone verify script: this used
698
+ // to serialize the whole { result, page } object into the panel/log).
699
+ const stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
700
+ result = stepOutcome.result;
701
+ const highlights = extractVerifyHighlights(r.block, result.__state);
702
+ await renderAfterStep(page, {
703
+ index: i,
704
+ total: resolved.length,
705
+ blockName: r.block.name,
706
+ result,
707
+ resultTag: JSON.stringify(result),
708
+ highlights,
709
+ isLast: i === resolved.length - 1,
710
+ allNames,
711
+ autoplay,
712
+ });
713
+ await gate();
714
+ }
715
+ return result;
716
+ }
717
+
718
+ async function main() {
719
+ const projectDir = process.argv[2];
720
+ const spec = process.argv[3];
721
+ const { connect, MemPage, Engine, start, end } = await import("waygraph");
722
+ const mem = new MemPage();
723
+ let resolved = [];
724
+ // A bare identifier (no "(", no "then") might name an existing Flow
725
+ // that's already wired up (e.g. loginFlow) - try that FIRST so pointing
726
+ // at real, already-built flows needs no chain-spec typing at all. Falls
727
+ // through to ordinary block-chain parsing if nothing matches.
728
+ const bareRef = /^[A-Za-z_$][\\w]*$/.test(spec.trim()) ? spec.trim() : null;
729
+ if (bareRef) {
730
+ const flow = await findFlow(projectDir, bareRef);
731
+ if (flow && typeof flow.blocks === "function") {
732
+ resolved = flow.blocks().map((bi) => ({ block: bi.block, exportName: bi.name }));
733
+ console.log(
734
+ "waygraph: running existing flow \\"" + bareRef + "\\" - " +
735
+ resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
736
+ (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
737
+ );
738
+ }
739
+ }
740
+ if (resolved.length === 0) {
741
+ const segments = parseChainSpec(spec);
742
+ if (segments.length === 0) {
743
+ throw new Error("waygraph chain: empty spec - give at least one block name");
744
+ }
745
+ for (const seg of segments) {
746
+ const r = await findBlock(projectDir, seg.ref);
747
+ seedMemForBlock(mem, r, seg.json);
748
+ resolved.push(r);
749
+ }
750
+ console.log(
751
+ "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
752
+ " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
753
+ );
754
+ }
755
+ const step = process.env.WAYGRAPH_STEP === "1";
756
+ // Stepping through headless defeats the point - a human can't watch it.
757
+ const headed = process.env.WAYGRAPH_HEADED === "1" || step;
758
+ // Step mode defaults to a visible pace (each fill/click actually shows on
759
+ // screen instead of snapping in) unless the human overrides it - an
760
+ // explicit WAYGRAPH_SLOWMO=0 still means "off".
761
+ const slowMo = process.env.WAYGRAPH_SLOWMO !== undefined
762
+ ? Number(process.env.WAYGRAPH_SLOWMO)
763
+ : step
764
+ ? 350
765
+ : undefined;
766
+ const baseURL = process.env.WAYGRAPH_BASE_URL;
767
+ const engine = new Engine({ headless: !headed, slowMo });
768
+ let result;
769
+ if (step || baseURL) {
770
+ const { chromium } = await import("playwright");
771
+ // --start-maximized (step mode only): viewport: null alone only made
772
+ // the PAGE content track the window - the actual browser WINDOW still
773
+ // launched at Chromium's own default size/position, which stayed put
774
+ // where it was for a previous, larger monitor and looked chopped off on
775
+ // a smaller one. Maximizing fills whatever screen it's actually on.
776
+ const browser = await chromium.launch({
777
+ headless: !headed,
778
+ slowMo,
779
+ args: step ? ["--start-maximized"] : [],
780
+ });
781
+ const context = await browser.newContext(
782
+ step ? { baseURL, viewport: null } : { baseURL, viewport: { width: 1280, height: 720 } },
783
+ );
784
+ try {
785
+ if (step) {
786
+ const page = await context.newPage();
787
+ // Step 1's "before" panel used to sit over a blank about:blank page
788
+ // until the human clicked Run - show the real destination first.
789
+ if (baseURL) {
790
+ await page.goto(baseURL).catch(() => {});
791
+ }
792
+ result = await runStepMode(engine, start, end, context, page, mem, resolved);
793
+ } else {
794
+ const blocks = resolved.map((r) => r.block);
795
+ const chained = blocks.reduce((a, b) => connect(a, b));
796
+ const flow = engine.defineFlow([start, chained, end]);
797
+ result = await flow.run(context, mem);
798
+ }
799
+ } finally {
800
+ await browser.close();
801
+ }
802
+ } else {
803
+ const blocks = resolved.map((r) => r.block);
804
+ const chained = blocks.reduce((a, b) => connect(a, b));
805
+ const flow = engine.defineFlow([start, chained, end]);
806
+ result = await flow.run(mem);
807
+ }
808
+ console.log("waygraph: chain finished -- " + JSON.stringify(result));
809
+ }
810
+
811
+ main().catch((err) => {
812
+ console.error(err && err.stack ? err.stack : String(err));
813
+ process.exitCode = 1;
814
+ });
815
+ `;
816
+ /**
817
+ * Runs the chain runner script as a child process rooted at `projectDir` -
818
+ * see {@link CHAIN_RUNNER_SCRIPT} for why this can't just be imported
819
+ * in-process. `WAYGRAPH_BASE_URL` (relative `page.goto()` targets, like
820
+ * every real zsign-all Block uses), `WAYGRAPH_HEADED=1`, and
821
+ * `WAYGRAPH_SLOWMO=<ms>` pass straight through from this process's own env.
822
+ */
823
+ async function runChain(projectDir, spec) {
824
+ const tsxEsm = import.meta.resolve("tsx/esm");
825
+ const scriptPath = join(projectDir, `.waygraph-chain-${process.pid}.mjs`);
826
+ writeFileSync(scriptPath, CHAIN_RUNNER_SCRIPT);
827
+ try {
828
+ const code = await new Promise((res, rej) => {
829
+ const child = spawn(process.execPath, ["--import", tsxEsm, scriptPath, projectDir, spec], {
830
+ stdio: "inherit",
831
+ env: process.env,
832
+ });
833
+ child.on("error", rej);
834
+ child.on("exit", (code) => res(code ?? 1));
835
+ });
836
+ if (code !== 0) {
837
+ process.exitCode = code;
838
+ }
839
+ }
840
+ finally {
841
+ rmSync(scriptPath, { force: true });
842
+ }
843
+ }
844
+ async function validateFlows(projectDir) {
845
+ const files = discoverFlows(projectDir);
846
+ const results = [];
847
+ for (const file of files) {
848
+ try {
849
+ const mod = await importFlowFile(file);
850
+ for (const [exportName, exported] of Object.entries(mod)) {
851
+ if (isFlowLike(exported)) {
852
+ results.push({ file, name: exportName, valid: true });
853
+ }
854
+ }
855
+ }
856
+ catch (err) {
857
+ const msg = err instanceof Error ? err.message : String(err);
858
+ results.push({ file, name: basename(file, ".ts"), valid: false, error: msg });
859
+ }
860
+ }
861
+ return results;
862
+ }
863
+ // ---------------------------------------------------------------------------
864
+ // `run`
865
+ // ---------------------------------------------------------------------------
866
+ async function runFlow(projectDir, flowName) {
867
+ const files = discoverFlows(projectDir);
868
+ for (const file of files) {
869
+ let mod;
870
+ try {
871
+ mod = await importFlowFile(file);
872
+ }
873
+ catch {
874
+ continue;
875
+ }
876
+ for (const [exportName, exported] of Object.entries(mod)) {
877
+ if (!isFlowLike(exported) || exportName !== flowName)
878
+ continue;
879
+ console.log(`waygraph: running flow "${flowName}" from ${relative(projectDir, file)}`);
880
+ const result = await exported.run(new MemPage());
881
+ console.log(`waygraph: flow finished -- ${JSON.stringify(result)}`);
882
+ return;
883
+ }
884
+ }
885
+ console.error(`waygraph: no flow named "${flowName}" found under ${projectDir}`);
886
+ process.exitCode = 1;
887
+ }
888
+ // ---------------------------------------------------------------------------
889
+ // CLI plumbing
890
+ // ---------------------------------------------------------------------------
891
+ const args = process.argv.slice(2);
892
+ const command = args[0];
893
+ function usage() {
894
+ console.log(`waygraph -- graph project tool + engine CLI
895
+
896
+ Usage:
897
+ waygraph list [project] List discovered flows
898
+ waygraph nav [flow] [project] Print flow chain + navigation steps
899
+ waygraph validate [project] Import and validate all flows
900
+ waygraph run <flow> [project] Run a named flow
901
+ waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
902
+
903
+ "chain" needs no .flow.ts file - reference Blocks (by their own
904
+ runtime .name, or their export identifier) straight from the command
905
+ line, chained with "then", each optionally given a JSON payload for
906
+ whatever it requires:
907
+
908
+ waygraph chain "login({\\"email\\":\\"a@b.com\\",\\"password\\":\\"x\\"}) then overview-metrics"
909
+
910
+ A Block with no requires needs no payload. One with several required
911
+ keys takes one JSON object keyed by each key's own name instead of a
912
+ flat payload.
913
+
914
+ Runs in a child process rooted at "project" (its own waygraph/playwright,
915
+ never this CLI's) so a project's real Blocks resolve against its own
916
+ node_modules. Env vars, all optional:
917
+ WAYGRAPH_BASE_URL base URL for Blocks using relative page.goto()
918
+ WAYGRAPH_HEADED=1 show the browser instead of headless
919
+ WAYGRAPH_SLOWMO=ms slow down each Playwright action, for watching a run
920
+ WAYGRAPH_STEP=1 human-verification mode - one Block at a time, a
921
+ browser overlay shows/lets you edit that Block's
922
+ MemKeys, a "Run this step" button gates it, then a
923
+ highlight ring shows whatever its verify Traits
924
+ just confirmed, then "Next" before moving on.
925
+ Implies headed - stepping through headless defeats
926
+ the point.
927
+
928
+ "project" defaults to the current directory.
929
+ `);
930
+ process.exit(0);
931
+ }
932
+ async function main() {
933
+ if (!command || command === "--help" || command === "-h") {
934
+ usage();
935
+ }
936
+ switch (command) {
937
+ case "list": {
938
+ const proj = resolve(args[1] ?? process.cwd());
939
+ if (!existsSync(proj)) {
940
+ console.error(`waygraph: no such directory: ${proj}`);
941
+ process.exit(1);
942
+ }
943
+ listCommand(proj);
944
+ break;
945
+ }
946
+ case "nav": {
947
+ const flowName = args[1] && args[1] !== "." ? args[1] : undefined;
948
+ const proj = resolve(args[2] ?? process.cwd());
949
+ navCommand(proj, flowName);
950
+ break;
951
+ }
952
+ case "validate": {
953
+ const proj = resolve(args[1] ?? process.cwd());
954
+ const results = await validateFlows(proj);
955
+ let failed = 0;
956
+ for (const r of results) {
957
+ const rel = relative(proj, r.file);
958
+ if (r.valid) {
959
+ console.log(`OK ${rel} ${r.name}`);
960
+ }
961
+ else {
962
+ failed++;
963
+ console.log(`FAIL ${rel} ${r.name} -- ${r.error}`);
964
+ }
965
+ }
966
+ if (failed > 0)
967
+ process.exitCode = 1;
968
+ break;
969
+ }
970
+ case "run": {
971
+ const flowName = args[1];
972
+ if (!flowName) {
973
+ console.error("waygraph run: missing <flow>, e.g. waygraph run LoginFlow");
974
+ process.exit(1);
975
+ }
976
+ const proj = resolve(args[2] ?? process.cwd());
977
+ await runFlow(proj, flowName);
978
+ break;
979
+ }
980
+ case "chain": {
981
+ const spec = args[1];
982
+ if (!spec) {
983
+ console.error('waygraph chain: missing <spec>, e.g. waygraph chain "login({...}) then overview-metrics"');
984
+ process.exit(1);
985
+ }
986
+ const proj = resolve(args[2] ?? process.cwd());
987
+ await runChain(proj, spec);
988
+ break;
989
+ }
990
+ default:
991
+ console.error(`waygraph: unknown command "${command}"`);
992
+ process.exit(1);
993
+ }
994
+ }
995
+ main().catch((err) => {
996
+ console.error(err instanceof Error ? err.message : String(err));
997
+ process.exitCode = 1;
998
+ });