waygraph 0.1.0 → 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/README.md +5 -1
- package/dist/cli.js +486 -22
- package/dist/engine.d.ts +34 -0
- package/dist/engine.js +9 -0
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,9 +101,13 @@ so the caller can keep driving it - hands a run tab back instead of losing it. S
|
|
|
101
101
|
"Recipes" for capture-on-popup, which stays a documented pattern until a second real use
|
|
102
102
|
case promotes it to an engine API.
|
|
103
103
|
|
|
104
|
+
```bash
|
|
105
|
+
npx create-waygraph my-project && cd my-project && npm install && npm test
|
|
106
|
+
```
|
|
107
|
+
|
|
104
108
|
[`create-waygraph`](https://github.com/deviate-dv8/create-waygraph) scaffolds a new
|
|
105
109
|
project (package.json, tsconfig, playwright.config, one working example flow) in one
|
|
106
|
-
command -
|
|
110
|
+
command, published on npm - the above runs a real green test with no manual edits.
|
|
107
111
|
|
|
108
112
|
Deliberately **not yet implemented** (tracked on the project board):
|
|
109
113
|
- Interstitials/Watchers - background overlay handling (cookie banners, popups).
|
package/dist/cli.js
CHANGED
|
@@ -251,6 +251,31 @@ async function findBlock(projectDir, ref) {
|
|
|
251
251
|
throw new Error("waygraph chain: no Block named \\"" + ref + "\\" found under " + projectDir);
|
|
252
252
|
}
|
|
253
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
|
+
|
|
254
279
|
function parseChainSpec(spec) {
|
|
255
280
|
return spec
|
|
256
281
|
.split(/\\bthen\\b/)
|
|
@@ -309,43 +334,475 @@ function seedMemForBlock(mem, resolved, json) {
|
|
|
309
334
|
}
|
|
310
335
|
}
|
|
311
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, "<") + "</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
|
+
|
|
312
718
|
async function main() {
|
|
313
719
|
const projectDir = process.argv[2];
|
|
314
720
|
const spec = process.argv[3];
|
|
315
|
-
const segments = parseChainSpec(spec);
|
|
316
|
-
if (segments.length === 0) {
|
|
317
|
-
throw new Error("waygraph chain: empty spec - give at least one block name");
|
|
318
|
-
}
|
|
319
721
|
const { connect, MemPage, Engine, start, end } = await import("waygraph");
|
|
320
722
|
const mem = new MemPage();
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
);
|
|
326
754
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
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;
|
|
335
766
|
const baseURL = process.env.WAYGRAPH_BASE_URL;
|
|
336
767
|
const engine = new Engine({ headless: !headed, slowMo });
|
|
337
|
-
const flow = engine.defineFlow([start, chained, end]);
|
|
338
768
|
let result;
|
|
339
|
-
if (baseURL) {
|
|
769
|
+
if (step || baseURL) {
|
|
340
770
|
const { chromium } = await import("playwright");
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
);
|
|
343
784
|
try {
|
|
344
|
-
|
|
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
|
+
}
|
|
345
799
|
} finally {
|
|
346
800
|
await browser.close();
|
|
347
801
|
}
|
|
348
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]);
|
|
349
806
|
result = await flow.run(mem);
|
|
350
807
|
}
|
|
351
808
|
console.log("waygraph: chain finished -- " + JSON.stringify(result));
|
|
@@ -460,6 +917,13 @@ Usage:
|
|
|
460
917
|
WAYGRAPH_BASE_URL base URL for Blocks using relative page.goto()
|
|
461
918
|
WAYGRAPH_HEADED=1 show the browser instead of headless
|
|
462
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.
|
|
463
927
|
|
|
464
928
|
"project" defaults to the current directory.
|
|
465
929
|
`);
|
package/dist/engine.d.ts
CHANGED
|
@@ -105,7 +105,34 @@ type EndMarker = typeof end;
|
|
|
105
105
|
* each call, so it's still correct after a patch too. Returns a new Flow -
|
|
106
106
|
* the original is untouched, same as every other decorator in this module.
|
|
107
107
|
*/
|
|
108
|
+
/**
|
|
109
|
+
* One Block's identity as exposed by graph introspection - {@link Flow.blocks},
|
|
110
|
+
* {@link ComposedBlock.steps} - without needing to execute anything. Does not
|
|
111
|
+
* include the Block's literal Checkpoint `In`/`Out` tags: those are TypeScript
|
|
112
|
+
* literal types, erased at runtime, and can only be recovered by a build-time
|
|
113
|
+
* static-analysis tool reading the source's own type annotations - see the
|
|
114
|
+
* `graph-visualization` openspec change for that piece.
|
|
115
|
+
*/
|
|
116
|
+
export interface BlockInfo {
|
|
117
|
+
name: string;
|
|
118
|
+
/** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
|
|
119
|
+
routes?: Readonly<Record<string, string | null>>;
|
|
120
|
+
/**
|
|
121
|
+
* The actual Block, re-runnable on its own via
|
|
122
|
+
* `engine.defineFlow([start, info.block, end])` - e.g. step-through
|
|
123
|
+
* tooling that runs a Flow's Blocks one at a time. Introspection that only
|
|
124
|
+
* wants to read the shape (a visualizer) can ignore this and use `name`/
|
|
125
|
+
* `routes` alone.
|
|
126
|
+
*/
|
|
127
|
+
block: Block<any, any>;
|
|
128
|
+
}
|
|
108
129
|
export interface Flow<Out extends Checkpoint<string>> {
|
|
130
|
+
/**
|
|
131
|
+
* This Flow's constituent Blocks, in order between `start`/`end`, as plain
|
|
132
|
+
* data - for introspection/visualization tools, without running anything.
|
|
133
|
+
* @example loginFlow.blocks() // [{ name: "login" }, { name: "add-to-cart", routes: {...} }]
|
|
134
|
+
*/
|
|
135
|
+
blocks(): readonly BlockInfo[];
|
|
109
136
|
run(context: BrowserContext, mem: MemPage): Promise<Out>;
|
|
110
137
|
/**
|
|
111
138
|
* `options.closeOnFinish: false` hands the driven page back instead of
|
|
@@ -150,6 +177,12 @@ export interface Flow<Out extends Checkpoint<string>> {
|
|
|
150
177
|
* casing needed anywhere that only expects a `Block`.
|
|
151
178
|
*/
|
|
152
179
|
export interface ComposedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> extends Block<In, Out> {
|
|
180
|
+
/**
|
|
181
|
+
* This composed Block's constituent steps, in order, as plain data - same
|
|
182
|
+
* shape and purpose as {@link Flow.blocks}.
|
|
183
|
+
* @example GovFormBlock.steps() // [{ name: "step-1-name" }, { name: "step-2-address" }, ...]
|
|
184
|
+
*/
|
|
185
|
+
steps(): readonly BlockInfo[];
|
|
153
186
|
/**
|
|
154
187
|
* Replaces one step's whole verify list, wherever it sits in this composed
|
|
155
188
|
* Block - same rules as {@link withVerify}. Throws if no step matches.
|
|
@@ -269,6 +302,7 @@ export declare function defineBlock<In extends Checkpoint<string>, Out extends C
|
|
|
269
302
|
instruction: Instruction<In, Out, any>;
|
|
270
303
|
next?: Block<In, Out>["next"];
|
|
271
304
|
requires?: Block<In, Out>["requires"];
|
|
305
|
+
routes?: Block<In, Out>["routes"];
|
|
272
306
|
}): DefinedBlock<In, Out>;
|
|
273
307
|
/**
|
|
274
308
|
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
package/dist/engine.js
CHANGED
|
@@ -180,6 +180,7 @@ function buildFlow(middle, engineConfig) {
|
|
|
180
180
|
return result;
|
|
181
181
|
};
|
|
182
182
|
return {
|
|
183
|
+
blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
|
|
183
184
|
run: run,
|
|
184
185
|
withBlockVerify(block, verify) {
|
|
185
186
|
const index = findBlockIndex(middle, block);
|
|
@@ -203,6 +204,7 @@ export function composeBlock(name, steps) {
|
|
|
203
204
|
return {
|
|
204
205
|
...chain,
|
|
205
206
|
name,
|
|
207
|
+
steps: () => steps.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
|
|
206
208
|
withStepVerify(step, verify) {
|
|
207
209
|
const index = findBlockIndex(steps, step);
|
|
208
210
|
const patched = [...steps];
|
|
@@ -249,6 +251,10 @@ export function branch(block, routes) {
|
|
|
249
251
|
const base = stripMethods(block);
|
|
250
252
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
251
253
|
let routed;
|
|
254
|
+
const routesAsData = {};
|
|
255
|
+
for (const [tag, target] of Object.entries(routes)) {
|
|
256
|
+
routesAsData[tag] = target === null ? null : target === block ? base.name : target.name;
|
|
257
|
+
}
|
|
252
258
|
routed = defineBlock({
|
|
253
259
|
...base,
|
|
254
260
|
next: (checkpoint) => {
|
|
@@ -260,6 +266,7 @@ export function branch(block, routes) {
|
|
|
260
266
|
// the routed Block, not the original with no `next` of its own.
|
|
261
267
|
return target === block ? routed : target;
|
|
262
268
|
},
|
|
269
|
+
routes: routesAsData,
|
|
263
270
|
});
|
|
264
271
|
return routed;
|
|
265
272
|
}
|
|
@@ -279,6 +286,7 @@ function stripMethods(block) {
|
|
|
279
286
|
instruction: block.instruction,
|
|
280
287
|
...(block.next ? { next: block.next } : {}),
|
|
281
288
|
...(block.requires ? { requires: block.requires } : {}),
|
|
289
|
+
...(block.routes ? { routes: block.routes } : {}),
|
|
282
290
|
};
|
|
283
291
|
}
|
|
284
292
|
/**
|
|
@@ -296,6 +304,7 @@ export function defineBlock(base) {
|
|
|
296
304
|
instruction: base.instruction,
|
|
297
305
|
...(base.next ? { next: base.next } : {}),
|
|
298
306
|
...(base.requires ? { requires: base.requires } : {}),
|
|
307
|
+
...(base.routes ? { routes: base.routes } : {}),
|
|
299
308
|
};
|
|
300
309
|
return {
|
|
301
310
|
...plain,
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ 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
5
|
export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
|
|
6
|
-
export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions } from "./engine.js";
|
|
6
|
+
export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } from "./engine.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -79,6 +79,18 @@ export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<str
|
|
|
79
79
|
* self-loop - see `runGraph`'s `maxSteps` for the safety net that needs.
|
|
80
80
|
*/
|
|
81
81
|
next?(checkpoint: Out): Block<any, any> | null | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* `branch()`'s routing table as plain, readable data - Checkpoint tag ->
|
|
84
|
+
* target Block's `.name` (`null` for a terminal tag) - alongside {@link next}
|
|
85
|
+
* itself, which stays the actual runtime dispatch. `next` is an opaque
|
|
86
|
+
* closure a graph-introspection tool can't read; this is `next`'s own
|
|
87
|
+
* routing decisions exposed as data instead, purely additive and never
|
|
88
|
+
* consulted by `runGraph`/`connect` - it exists only for tools like
|
|
89
|
+
* `Flow`'s inspection method (and, eventually, `toMermaid`) to walk without
|
|
90
|
+
* executing anything. A route back to the Block itself (a self-loop) maps
|
|
91
|
+
* to that same Block's own name, not `null`.
|
|
92
|
+
*/
|
|
93
|
+
routes?: Readonly<Record<string, string | null>>;
|
|
82
94
|
/**
|
|
83
95
|
* MemKeys this Block's `act` reads from `mem` and needs already set.
|
|
84
96
|
* Declaring it lets {@link preflight} (built into `runGraph`) catch a missing
|