waygraph 0.2.0 → 0.3.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 +344 -90
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +2 -0
- package/dist/types.d.ts +28 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -359,6 +359,7 @@ const RING_CSS =
|
|
|
359
359
|
"#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
|
|
360
360
|
"#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
|
|
361
361
|
"letter-spacing:.02em;text-transform:uppercase;}" +
|
|
362
|
+
"#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
|
|
362
363
|
"#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
|
|
363
364
|
"#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
|
|
364
365
|
"#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
|
|
@@ -366,6 +367,12 @@ const RING_CSS =
|
|
|
366
367
|
"#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
|
|
367
368
|
"#wg-modules .wg-mod-current{background:#7C3AED;color:#fff;}" +
|
|
368
369
|
"#wg-modules .wg-mod-upcoming{background:transparent;color:#5a4a80;border:1px solid #3a2a60;}" +
|
|
370
|
+
"#wg-banner{position:fixed;z-index:2147483647;top:14px;right:14px;max-width:320px;" +
|
|
371
|
+
"background:rgba(20,10,40,.94);color:#fff;border-radius:12px;padding:10px 16px;" +
|
|
372
|
+
"font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 20px rgba(0,0,0,.3);" +
|
|
373
|
+
"border:1px solid rgba(124,58,237,.4);}" +
|
|
374
|
+
"#wg-banner .wg-banner-tag{display:block;font-size:10px;font-weight:700;color:#c9a6ff;" +
|
|
375
|
+
"letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;}" +
|
|
369
376
|
"#wg-panel .wg-key{margin:8px 0;}" +
|
|
370
377
|
"#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
|
|
371
378
|
"#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
|
|
@@ -381,21 +388,59 @@ const RING_CSS =
|
|
|
381
388
|
"background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
|
|
382
389
|
"#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}";
|
|
383
390
|
|
|
384
|
-
|
|
391
|
+
// Purple dot favicon (matches the overlay's own theme color) - the tab-bar
|
|
392
|
+
// signal that "this Chromium window is a waygraph run," even at a glance
|
|
393
|
+
// across a taskbar/alt-tab, not just something visible inside the page.
|
|
394
|
+
const WAYGRAPH_FAVICON =
|
|
395
|
+
"data:image/svg+xml," +
|
|
396
|
+
encodeURIComponent(
|
|
397
|
+
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
|
|
398
|
+
"<circle cx='16' cy='16' r='14' fill='#7C3AED'/></svg>",
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
async function installOverlay(page, title) {
|
|
385
402
|
await page.addStyleTag({ content: RING_CSS }).catch(() => {});
|
|
386
403
|
await page
|
|
387
|
-
.evaluate(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
404
|
+
.evaluate(
|
|
405
|
+
({ title, favicon }) => {
|
|
406
|
+
if (!document.getElementById("wg-ring")) {
|
|
407
|
+
const ring = document.createElement("div");
|
|
408
|
+
ring.id = "wg-ring";
|
|
409
|
+
document.documentElement.appendChild(ring);
|
|
410
|
+
}
|
|
411
|
+
if (title && !document.getElementById("wg-banner")) {
|
|
412
|
+
const banner = document.createElement("div");
|
|
413
|
+
banner.id = "wg-banner";
|
|
414
|
+
const tag = document.createElement("span");
|
|
415
|
+
tag.className = "wg-banner-tag";
|
|
416
|
+
tag.textContent = "waygraph demo";
|
|
417
|
+
const text = document.createElement("span");
|
|
418
|
+
text.textContent = title;
|
|
419
|
+
banner.appendChild(tag);
|
|
420
|
+
banner.appendChild(text);
|
|
421
|
+
document.documentElement.appendChild(banner);
|
|
422
|
+
}
|
|
423
|
+
// Tab title/favicon: a real navigation resets document.title and any
|
|
424
|
+
// <link rel="icon"> the new document brings, so re-check (not
|
|
425
|
+
// re-append) on every call instead of a one-time flag.
|
|
426
|
+
if (!document.title.startsWith("[waygraph] ")) {
|
|
427
|
+
document.title = "[waygraph] " + document.title;
|
|
428
|
+
}
|
|
429
|
+
let iconLink = document.querySelector("link[rel~='icon']");
|
|
430
|
+
if (!iconLink) {
|
|
431
|
+
iconLink = document.createElement("link");
|
|
432
|
+
iconLink.rel = "icon";
|
|
433
|
+
document.head.appendChild(iconLink);
|
|
434
|
+
}
|
|
435
|
+
if (iconLink.href !== favicon) iconLink.href = favicon;
|
|
436
|
+
},
|
|
437
|
+
{ title, favicon: WAYGRAPH_FAVICON },
|
|
438
|
+
)
|
|
394
439
|
.catch(() => {});
|
|
395
440
|
}
|
|
396
441
|
|
|
397
442
|
async function renderBeforeStep(page, info) {
|
|
398
|
-
await installOverlay(page);
|
|
443
|
+
await installOverlay(page, info.title);
|
|
399
444
|
// A ring left highlighting the PREVIOUS step's element (and its live
|
|
400
445
|
// resize/scroll tracker) shouldn't linger once a new step's own panel is
|
|
401
446
|
// up - only relevant when a Block's act() doesn't navigate away, since a
|
|
@@ -418,16 +463,23 @@ async function renderBeforeStep(page, info) {
|
|
|
418
463
|
const panel = document.createElement("div");
|
|
419
464
|
panel.id = "wg-panel";
|
|
420
465
|
const pct = Math.round((info.index / info.total) * 100);
|
|
466
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
421
467
|
const modulesHtml = info.allNames
|
|
422
468
|
.map((name, idx) => {
|
|
423
469
|
const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
|
|
424
|
-
|
|
470
|
+
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
471
|
+
const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
|
|
472
|
+
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
425
473
|
})
|
|
426
474
|
.join("");
|
|
475
|
+
const narrationHtml = info.description
|
|
476
|
+
? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
|
|
477
|
+
: "";
|
|
427
478
|
let html =
|
|
428
479
|
"<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
|
|
429
480
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
430
|
-
"<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>"
|
|
481
|
+
"<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
|
|
482
|
+
narrationHtml;
|
|
431
483
|
if (info.keys.length === 0) {
|
|
432
484
|
html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
|
|
433
485
|
}
|
|
@@ -458,46 +510,66 @@ async function renderBeforeStep(page, info) {
|
|
|
458
510
|
}
|
|
459
511
|
|
|
460
512
|
async function renderAfterStep(page, info) {
|
|
461
|
-
await installOverlay(page);
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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.
|
|
513
|
+
await installOverlay(page, info.title);
|
|
514
|
+
// Clear any tracker from a previous highlight before cycling through this
|
|
515
|
+
// step's own.
|
|
467
516
|
await page
|
|
468
|
-
.evaluate((
|
|
517
|
+
.evaluate(() => {
|
|
469
518
|
if (window.__wgRingTrack) {
|
|
470
519
|
window.removeEventListener("resize", window.__wgRingTrack);
|
|
471
520
|
window.removeEventListener("scroll", window.__wgRingTrack, true);
|
|
472
521
|
window.__wgRingTrack = null;
|
|
473
522
|
}
|
|
474
|
-
|
|
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)
|
|
523
|
+
})
|
|
500
524
|
.catch(() => {});
|
|
525
|
+
const highlights = info.highlights || [];
|
|
526
|
+
// Cycle through EVERY declared/recovered highlight in order, each shown
|
|
527
|
+
// long enough to actually register - "it highlights something [...] then
|
|
528
|
+
// it highlights something [else] and next," not just the first one.
|
|
529
|
+
for (let i = 0; i < highlights.length; i++) {
|
|
530
|
+
const h = highlights[i];
|
|
531
|
+
try {
|
|
532
|
+
const box = await page.locator(h.selector).first().boundingBox();
|
|
533
|
+
if (box) {
|
|
534
|
+
await showRing(page, box, h.label);
|
|
535
|
+
await new Promise((res) => setTimeout(res, i === highlights.length - 1 ? 200 : 900));
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
// best-effort - a selector that doesn't resolve just gets skipped
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (highlights.length > 0) {
|
|
542
|
+
// The LAST highlight is the one that stays lit while the human reads
|
|
543
|
+
// the after-step panel - give THAT one live resize/scroll tracking,
|
|
544
|
+
// same as before.
|
|
545
|
+
const last = highlights[highlights.length - 1];
|
|
546
|
+
await page
|
|
547
|
+
.evaluate((h) => {
|
|
548
|
+
const ring = document.getElementById("wg-ring");
|
|
549
|
+
if (!ring) return;
|
|
550
|
+
const reposition = () => {
|
|
551
|
+
const el = document.querySelector(h.selector);
|
|
552
|
+
if (!el) {
|
|
553
|
+
ring.style.opacity = "0";
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const box = el.getBoundingClientRect();
|
|
557
|
+
ring.style.left = box.x - 6 + "px";
|
|
558
|
+
ring.style.top = box.y - 6 + "px";
|
|
559
|
+
ring.style.width = box.width + 12 + "px";
|
|
560
|
+
ring.style.height = box.height + 12 + "px";
|
|
561
|
+
ring.setAttribute("data-label", h.label);
|
|
562
|
+
ring.style.opacity = "1";
|
|
563
|
+
};
|
|
564
|
+
reposition();
|
|
565
|
+
window.__wgRingTrack = reposition;
|
|
566
|
+
window.addEventListener("resize", reposition);
|
|
567
|
+
window.addEventListener("scroll", reposition, true);
|
|
568
|
+
}, last)
|
|
569
|
+
.catch(() => {});
|
|
570
|
+
} else {
|
|
571
|
+
await hideRing(page);
|
|
572
|
+
}
|
|
501
573
|
await page
|
|
502
574
|
.evaluate((info) => {
|
|
503
575
|
const old = document.getElementById("wg-panel");
|
|
@@ -509,10 +581,13 @@ async function renderAfterStep(page, info) {
|
|
|
509
581
|
? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
|
|
510
582
|
: "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " done";
|
|
511
583
|
const buttonLabel = info.isLast ? "Finish" : "Next \\u25B6";
|
|
584
|
+
const escA = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
512
585
|
const modulesHtml = info.allNames
|
|
513
586
|
.map((name, idx) => {
|
|
514
587
|
const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
|
|
515
|
-
|
|
588
|
+
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
589
|
+
const titleAttr = desc ? " title=\\"" + escA(desc) + "\\"" : "";
|
|
590
|
+
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
516
591
|
})
|
|
517
592
|
.join("");
|
|
518
593
|
// QA-friendly by default ("LoginSuccess" -> "Login Success") - raw
|
|
@@ -558,14 +633,37 @@ async function renderAfterStep(page, info) {
|
|
|
558
633
|
.catch(() => {});
|
|
559
634
|
}
|
|
560
635
|
|
|
636
|
+
/**
|
|
637
|
+
* A Block's own explicit instruction.highlights, if it declared any -
|
|
638
|
+
* arbitrary, author-controlled highlight points, independent of verify
|
|
639
|
+
* ("remember this ID" isn't a pass/fail check). Takes priority over
|
|
640
|
+
* anything recovered from verify traits below.
|
|
641
|
+
*/
|
|
642
|
+
function resolveDeclaredHighlights(block, resultTag) {
|
|
643
|
+
let highlights = block.instruction && block.instruction.highlights;
|
|
644
|
+
if (typeof highlights === "function") {
|
|
645
|
+
try {
|
|
646
|
+
highlights = highlights({ __state: resultTag });
|
|
647
|
+
} catch {
|
|
648
|
+
highlights = [];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!Array.isArray(highlights)) return [];
|
|
652
|
+
return highlights.filter((h) => h && typeof h.selector === "string" && typeof h.label === "string");
|
|
653
|
+
}
|
|
654
|
+
|
|
561
655
|
/**
|
|
562
656
|
* Recovers a DOM selector from a built-in Trait's own .name string -
|
|
563
657
|
* visible(sel) / text-equals(sel, "...") - since Trait.check itself is
|
|
564
658
|
* an opaque closure with no selector field of its own. Best-effort only: a
|
|
565
659
|
* hand-written bespoke Trait, or url-matches(...) (no DOM target), yields
|
|
566
660
|
* nothing to highlight, which is fine - the panel still shows the result.
|
|
661
|
+
* Only used as a FALLBACK when the Block declared no explicit
|
|
662
|
+
* instruction.highlights of its own - see resolveDeclaredHighlights.
|
|
567
663
|
*/
|
|
568
664
|
function extractVerifyHighlights(block, resultTag) {
|
|
665
|
+
const declared = resolveDeclaredHighlights(block, resultTag);
|
|
666
|
+
if (declared.length > 0) return declared;
|
|
569
667
|
let verify = block.instruction && block.instruction.verify;
|
|
570
668
|
if (typeof verify === "function") {
|
|
571
669
|
try {
|
|
@@ -603,53 +701,190 @@ function extractVerifyHighlights(block, resultTag) {
|
|
|
603
701
|
* highlight then slowly input." One-time patch (idempotent - guarded so a
|
|
604
702
|
* multi-step chain doesn't re-wrap an already-wrapped fill).
|
|
605
703
|
*/
|
|
606
|
-
function
|
|
704
|
+
async function showRing(page, box, label) {
|
|
705
|
+
await page
|
|
706
|
+
.evaluate(
|
|
707
|
+
({ box, label }) => {
|
|
708
|
+
const ring = document.getElementById("wg-ring");
|
|
709
|
+
if (!ring) return;
|
|
710
|
+
ring.style.left = box.x - 6 + "px";
|
|
711
|
+
ring.style.top = box.y - 6 + "px";
|
|
712
|
+
ring.style.width = box.width + 12 + "px";
|
|
713
|
+
ring.style.height = box.height + 12 + "px";
|
|
714
|
+
ring.setAttribute("data-label", label);
|
|
715
|
+
ring.style.opacity = "1";
|
|
716
|
+
},
|
|
717
|
+
{ box, label },
|
|
718
|
+
)
|
|
719
|
+
.catch(() => {});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function hideRing(page) {
|
|
723
|
+
await page
|
|
724
|
+
.evaluate(() => {
|
|
725
|
+
const ring = document.getElementById("wg-ring");
|
|
726
|
+
if (ring) ring.style.opacity = "0";
|
|
727
|
+
})
|
|
728
|
+
.catch(() => {});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
732
|
+
// Playwright's own slowMo ALREADY pauses after every single low-level
|
|
733
|
+
// action it dispatches - and pressSequentially() fires one such action
|
|
734
|
+
// PER CHARACTER. Also giving pressSequentially its own fixed delay
|
|
735
|
+
// double-paces every keystroke (45ms + slowMo's own ~350ms, per
|
|
736
|
+
// character) - an ordinary 22-character email alone stretched past 8
|
|
737
|
+
// seconds. When slowMo is already doing the pacing, add none of our own;
|
|
738
|
+
// only fall back to a small typing delay when slowMo is off entirely.
|
|
739
|
+
const typeDelay = slowMo ? 0 : 30;
|
|
740
|
+
// Click is a single action, not per-character, so it doesn't compound
|
|
741
|
+
// the same way - but slowMo still adds its own pause around the actual
|
|
742
|
+
// click, so trim our own explicit "pop" pauses when it's already active
|
|
743
|
+
// rather than stacking a full 1.2s on top of that.
|
|
744
|
+
const clickPrePop = slowMo ? 300 : 700;
|
|
745
|
+
const clickPostPop = slowMo ? 200 : 500;
|
|
746
|
+
// Locator.fill()/click() only ever see a raw call, no context of where
|
|
747
|
+
// the value came from. Patching mem.get() to remember the most recently
|
|
748
|
+
// read key's name (Blocks read-then-immediately-fill, e.g. const { email
|
|
749
|
+
// } = mem.get(LoginInput.key); ...fill(email)) lets the fill patch below
|
|
750
|
+
// label the ring with the real key, not a generic "writing from mem".
|
|
751
|
+
// Patches this ONE mem instance only, not MemPage's shared prototype -
|
|
752
|
+
// there's exactly one mem per chain run.
|
|
753
|
+
const memTrack = { lastKeyName: null, at: 0 };
|
|
754
|
+
const originalGet = mem.get.bind(mem);
|
|
755
|
+
mem.get = (key) => {
|
|
756
|
+
memTrack.lastKeyName = key && key.name;
|
|
757
|
+
memTrack.at = Date.now();
|
|
758
|
+
return originalGet(key);
|
|
759
|
+
};
|
|
760
|
+
|
|
607
761
|
const proto = Object.getPrototypeOf(page.locator("html"));
|
|
608
|
-
|
|
609
|
-
proto.__wgFillPatched
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
await new Promise((res) => setTimeout(res, 200));
|
|
762
|
+
|
|
763
|
+
if (!proto.__wgFillPatched) {
|
|
764
|
+
proto.__wgFillPatched = true;
|
|
765
|
+
const originalFill = proto.fill;
|
|
766
|
+
proto.fill = async function (value, options) {
|
|
767
|
+
try {
|
|
768
|
+
await installOverlay(page);
|
|
769
|
+
const box = await this.boundingBox();
|
|
770
|
+
if (box) {
|
|
771
|
+
// Only trust the "last mem.get()" as THIS fill's source if it
|
|
772
|
+
// happened recently - a stale read from several actions ago is
|
|
773
|
+
// more likely unrelated than actually describing this field.
|
|
774
|
+
const label = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
|
|
775
|
+
? "from mem: " + memTrack.lastKeyName
|
|
776
|
+
: "writing from mem";
|
|
777
|
+
await showRing(page, box, label);
|
|
778
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
// best-effort - element not visible/attached yet is not this
|
|
782
|
+
// instrumentation's problem, the real fill below still runs
|
|
630
783
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
784
|
+
let result;
|
|
785
|
+
try {
|
|
786
|
+
// NOT this.clear() - Locator.clear() is itself implemented as
|
|
787
|
+
// fill(""), and since fill is patched on the shared prototype,
|
|
788
|
+
// that call would resolve back to THIS same patched function and
|
|
789
|
+
// recurse forever. Call the real original fill directly instead.
|
|
790
|
+
await originalFill.call(this, "", { timeout: options && options.timeout });
|
|
791
|
+
result = await this.pressSequentially(String(value), { delay: typeDelay, timeout: options && options.timeout });
|
|
792
|
+
} catch {
|
|
793
|
+
// pressSequentially unsupported on this element (e.g. a
|
|
794
|
+
// contenteditable div, or a locator .fill() genuinely needs to
|
|
795
|
+
// handle specially) - fall back to the real, unpatched fill.
|
|
796
|
+
result = await originalFill.call(this, value, options);
|
|
797
|
+
}
|
|
798
|
+
// Fade the ring back out once this field is actually done, instead
|
|
799
|
+
// of leaving it lit until the next Block's own before-panel clears
|
|
800
|
+
// it - it was sticking around through the whole rest of the step.
|
|
801
|
+
await hideRing(page);
|
|
802
|
+
return result;
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (!proto.__wgClickPatched) {
|
|
807
|
+
proto.__wgClickPatched = true;
|
|
808
|
+
const originalClick = proto.click;
|
|
809
|
+
proto.click = async function (options) {
|
|
810
|
+
try {
|
|
811
|
+
await installOverlay(page);
|
|
812
|
+
const box = await this.boundingBox();
|
|
813
|
+
if (box) {
|
|
814
|
+
let label = "click";
|
|
815
|
+
try {
|
|
816
|
+
const text = (await this.textContent())?.trim();
|
|
817
|
+
if (text && text.length > 0 && text.length <= 30) label = text;
|
|
818
|
+
} catch {
|
|
819
|
+
// element has no simple text (an icon button, say) - generic label is fine
|
|
820
|
+
}
|
|
821
|
+
await showRing(page, box, label);
|
|
822
|
+
// "pop for a few seconds" - Dan's own phrase, matching the
|
|
823
|
+
// zsign demo-engine's ring-before-click pattern in
|
|
824
|
+
// services/help-center-clip-engine's video-pipeline.
|
|
825
|
+
await new Promise((res) => setTimeout(res, clickPrePop));
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
// best-effort - the real click below still runs either way
|
|
829
|
+
}
|
|
830
|
+
const result = await originalClick.call(this, options);
|
|
831
|
+
await new Promise((res) => setTimeout(res, clickPostPop));
|
|
832
|
+
await hideRing(page);
|
|
833
|
+
return result;
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Caps any Locator.waitFor()/page.waitForTimeout() timeout during step
|
|
838
|
+
// mode - a Block polling for something that will NEVER happen (e.g. "is
|
|
839
|
+
// there an unverified-account banner" on an account that IS verified)
|
|
840
|
+
// has no choice but to wait out its own hardcoded timeout in full before
|
|
841
|
+
// concluding "no". Only the WAIT gets capped, never the real outcome -
|
|
842
|
+
// if the thing genuinely appears at 800ms into a 5000ms wait, waitFor
|
|
843
|
+
// still resolves at 800ms same as always; this only shortens the "it's
|
|
844
|
+
// just never going to happen" case. Plain setTimeout()-based sleep()
|
|
845
|
+
// helpers some Blocks use for their own pacing are NOT Playwright calls
|
|
846
|
+
// at all and can't be touched this way - a real, disclosed limit, not
|
|
847
|
+
// silently ignored.
|
|
848
|
+
// 3000ms was the first, deliberately conservative pass (this codebase
|
|
849
|
+
// has documented real flakiness around slow-but-legitimate hydration
|
|
850
|
+
// waits, so a too-aggressive cap risks trading "one annoying dead wait"
|
|
851
|
+
// for "logins that sometimes fail outright"). Verified empirically
|
|
852
|
+
// (3 consecutive real login runs, no failures) that those legitimate
|
|
853
|
+
// waits actually resolve in well under a second in practice - the cap
|
|
854
|
+
// was never close to touching them - so it's safe to tighten further.
|
|
855
|
+
const WAIT_CAP_MS = 1500;
|
|
856
|
+
if (!proto.__wgWaitForPatched) {
|
|
857
|
+
proto.__wgWaitForPatched = true;
|
|
858
|
+
const originalWaitFor = proto.waitFor;
|
|
859
|
+
proto.waitFor = function (options) {
|
|
860
|
+
const capped = { ...(options || {}), timeout: Math.min((options && options.timeout) || 30000, WAIT_CAP_MS) };
|
|
861
|
+
return originalWaitFor.call(this, capped);
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const pageProto = Object.getPrototypeOf(page);
|
|
865
|
+
if (!pageProto.__wgWaitForTimeoutPatched) {
|
|
866
|
+
pageProto.__wgWaitForTimeoutPatched = true;
|
|
867
|
+
const originalWaitForTimeout = pageProto.waitForTimeout;
|
|
868
|
+
pageProto.waitForTimeout = function (ms) {
|
|
869
|
+
return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_MS));
|
|
870
|
+
};
|
|
871
|
+
}
|
|
649
872
|
}
|
|
650
873
|
|
|
651
|
-
async function runStepMode(engine, start, end, context, page, mem, resolved) {
|
|
652
|
-
|
|
874
|
+
async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title) {
|
|
875
|
+
instrumentInteractionHighlighting(page, mem, slowMo);
|
|
876
|
+
// Some real Blocks (e.g. zsign-all's login.block.ts) call
|
|
877
|
+
// page.setViewportSize({ width: 1280, height: 720 }) inside their own
|
|
878
|
+
// act() - a hardcoded override for THEIR OWN testing consistency, with no
|
|
879
|
+
// idea an interactive human session is watching. Restoring the size
|
|
880
|
+
// AFTER each step (the first attempt at this) was still just repairing
|
|
881
|
+
// damage after the fact, on a delay, per-step - not the real fix. The
|
|
882
|
+
// real fix is to stop the override from ever landing at all: no-op
|
|
883
|
+
// setViewportSize entirely for the duration of step mode. Blocks that
|
|
884
|
+
// call it don't need it to actually do anything here - their own
|
|
885
|
+
// selectors/layouts still work fine at whatever size the real window
|
|
886
|
+
// already is.
|
|
887
|
+
page.setViewportSize = async () => {};
|
|
653
888
|
let resolveNext = null;
|
|
654
889
|
await page.exposeFunction("__wgNext", (edits) => {
|
|
655
890
|
if (resolveNext) {
|
|
@@ -666,6 +901,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved) {
|
|
|
666
901
|
const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
|
|
667
902
|
const gate = () => (autoplay ? new Promise((res) => setTimeout(() => res({}), autoplayMs)) : waitForNext());
|
|
668
903
|
const allNames = resolved.map((r) => r.block.name);
|
|
904
|
+
const allDescriptions = resolved.map((r) => r.block.description || "");
|
|
669
905
|
|
|
670
906
|
let result;
|
|
671
907
|
for (let i = 0; i < resolved.length; i++) {
|
|
@@ -680,7 +916,17 @@ async function runStepMode(engine, start, end, context, page, mem, resolved) {
|
|
|
680
916
|
}
|
|
681
917
|
return { name: k.name, value };
|
|
682
918
|
});
|
|
683
|
-
await renderBeforeStep(page, {
|
|
919
|
+
await renderBeforeStep(page, {
|
|
920
|
+
index: i,
|
|
921
|
+
total: resolved.length,
|
|
922
|
+
blockName: r.block.name,
|
|
923
|
+
description: r.block.description || "",
|
|
924
|
+
keys,
|
|
925
|
+
allNames,
|
|
926
|
+
allDescriptions,
|
|
927
|
+
autoplay,
|
|
928
|
+
title,
|
|
929
|
+
});
|
|
684
930
|
const edits = await gate();
|
|
685
931
|
for (const k of requires) {
|
|
686
932
|
if (edits[k.name] !== undefined) {
|
|
@@ -708,7 +954,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved) {
|
|
|
708
954
|
highlights,
|
|
709
955
|
isLast: i === resolved.length - 1,
|
|
710
956
|
allNames,
|
|
957
|
+
allDescriptions,
|
|
711
958
|
autoplay,
|
|
959
|
+
title,
|
|
712
960
|
});
|
|
713
961
|
await gate();
|
|
714
962
|
}
|
|
@@ -764,6 +1012,7 @@ async function main() {
|
|
|
764
1012
|
? 350
|
|
765
1013
|
: undefined;
|
|
766
1014
|
const baseURL = process.env.WAYGRAPH_BASE_URL;
|
|
1015
|
+
const title = process.env.WAYGRAPH_TITLE;
|
|
767
1016
|
const engine = new Engine({ headless: !headed, slowMo });
|
|
768
1017
|
let result;
|
|
769
1018
|
if (step || baseURL) {
|
|
@@ -789,7 +1038,7 @@ async function main() {
|
|
|
789
1038
|
if (baseURL) {
|
|
790
1039
|
await page.goto(baseURL).catch(() => {});
|
|
791
1040
|
}
|
|
792
|
-
result = await runStepMode(engine, start, end, context, page, mem, resolved);
|
|
1041
|
+
result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title);
|
|
793
1042
|
} else {
|
|
794
1043
|
const blocks = resolved.map((r) => r.block);
|
|
795
1044
|
const chained = blocks.reduce((a, b) => connect(a, b));
|
|
@@ -924,6 +1173,11 @@ Usage:
|
|
|
924
1173
|
just confirmed, then "Next" before moving on.
|
|
925
1174
|
Implies headed - stepping through headless defeats
|
|
926
1175
|
the point.
|
|
1176
|
+
WAYGRAPH_AUTOPLAY=1 step mode only - hands-off, advances on a
|
|
1177
|
+
timer instead of waiting for clicks
|
|
1178
|
+
WAYGRAPH_AUTOPLAY_MS=ms delay between auto-advances (default 1800)
|
|
1179
|
+
WAYGRAPH_TITLE="..." step mode only - a persistent top-right
|
|
1180
|
+
banner naming what this whole run is about
|
|
927
1181
|
|
|
928
1182
|
"project" defaults to the current directory.
|
|
929
1183
|
`);
|
package/dist/engine.d.ts
CHANGED
|
@@ -299,6 +299,7 @@ export declare function branch<In extends Checkpoint<string>, Out extends Checkp
|
|
|
299
299
|
*/
|
|
300
300
|
export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
|
|
301
301
|
name: string;
|
|
302
|
+
description?: Block<In, Out>["description"];
|
|
302
303
|
instruction: Instruction<In, Out, any>;
|
|
303
304
|
next?: Block<In, Out>["next"];
|
|
304
305
|
requires?: Block<In, Out>["requires"];
|
package/dist/engine.js
CHANGED
|
@@ -284,6 +284,7 @@ function stripMethods(block) {
|
|
|
284
284
|
return {
|
|
285
285
|
name: block.name,
|
|
286
286
|
instruction: block.instruction,
|
|
287
|
+
...(block.description ? { description: block.description } : {}),
|
|
287
288
|
...(block.next ? { next: block.next } : {}),
|
|
288
289
|
...(block.requires ? { requires: block.requires } : {}),
|
|
289
290
|
...(block.routes ? { routes: block.routes } : {}),
|
|
@@ -302,6 +303,7 @@ export function defineBlock(base) {
|
|
|
302
303
|
const plain = {
|
|
303
304
|
name: base.name,
|
|
304
305
|
instruction: base.instruction,
|
|
306
|
+
...(base.description ? { description: base.description } : {}),
|
|
305
307
|
...(base.next ? { next: base.next } : {}),
|
|
306
308
|
...(base.requires ? { requires: base.requires } : {}),
|
|
307
309
|
...(base.routes ? { routes: base.routes } : {}),
|
package/dist/types.d.ts
CHANGED
|
@@ -51,6 +51,24 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
|
|
|
51
51
|
* {@link Block.modVerify} for changing this from outside the Block's own file.
|
|
52
52
|
*/
|
|
53
53
|
verify?: Trait[] | ((out: Out) => Trait[]);
|
|
54
|
+
/**
|
|
55
|
+
* Optional. Arbitrary highlight points for step-mode tooling to show, in
|
|
56
|
+
* order, after this Block resolves - independent of `verify`, for
|
|
57
|
+
* calling out something worth a human's attention regardless of whether
|
|
58
|
+
* it's also being asserted ("remember this signature ID" isn't a
|
|
59
|
+
* pass/fail check). A function form picks different highlights per
|
|
60
|
+
* resolved `Out`, same as `verify`. Purely additive UI narration - never
|
|
61
|
+
* consulted by `runGraph`/`connect`/anything that decides behavior.
|
|
62
|
+
* @example highlights: (out) => [{ selector: "#signature-id", label: "remember this ID" }]
|
|
63
|
+
*/
|
|
64
|
+
highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
|
|
65
|
+
}
|
|
66
|
+
/** One arbitrary highlight point - see {@link Instruction.highlights}. */
|
|
67
|
+
export interface WaygraphHighlight {
|
|
68
|
+
/** CSS selector for the element to highlight. */
|
|
69
|
+
selector: string;
|
|
70
|
+
/** Caption shown next to the highlight ring. */
|
|
71
|
+
label: string;
|
|
54
72
|
}
|
|
55
73
|
/**
|
|
56
74
|
* A Block wraps one instruction. Its Observed type is an internal detail, erased here.
|
|
@@ -71,6 +89,16 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
|
|
|
71
89
|
export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<string>> {
|
|
72
90
|
/** A short, stable label - shows up in every error this Block can produce ("trait X failed after Y"). */
|
|
73
91
|
name: string;
|
|
92
|
+
/**
|
|
93
|
+
* A human-readable sentence describing what this Block actually tests or
|
|
94
|
+
* does - "the outsider case: a user outside a request's audience is
|
|
95
|
+
* denied its detail page," not a restatement of the code. Purely
|
|
96
|
+
* additive, optional narration for a human watching a run (step-mode
|
|
97
|
+
* tooling shows it before the Block runs) - never read by
|
|
98
|
+
* `runGraph`/`connect`/anything that decides behavior. A Block with none
|
|
99
|
+
* just shows its `name` instead.
|
|
100
|
+
*/
|
|
101
|
+
description?: string;
|
|
74
102
|
/** The act/observe/resolve/verify pipeline itself. See {@link Instruction}. */
|
|
75
103
|
instruction: Instruction<In, Out, any>;
|
|
76
104
|
/**
|