waygraph 0.2.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +181 -44
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -603,53 +603,190 @@ function extractVerifyHighlights(block, resultTag) {
603
603
  * highlight then slowly input." One-time patch (idempotent - guarded so a
604
604
  * multi-step chain doesn't re-wrap an already-wrapped fill).
605
605
  */
606
- function instrumentFillHighlighting(page) {
606
+ async function showRing(page, box, label) {
607
+ await page
608
+ .evaluate(
609
+ ({ box, label }) => {
610
+ const ring = document.getElementById("wg-ring");
611
+ if (!ring) return;
612
+ ring.style.left = box.x - 6 + "px";
613
+ ring.style.top = box.y - 6 + "px";
614
+ ring.style.width = box.width + 12 + "px";
615
+ ring.style.height = box.height + 12 + "px";
616
+ ring.setAttribute("data-label", label);
617
+ ring.style.opacity = "1";
618
+ },
619
+ { box, label },
620
+ )
621
+ .catch(() => {});
622
+ }
623
+
624
+ async function hideRing(page) {
625
+ await page
626
+ .evaluate(() => {
627
+ const ring = document.getElementById("wg-ring");
628
+ if (ring) ring.style.opacity = "0";
629
+ })
630
+ .catch(() => {});
631
+ }
632
+
633
+ function instrumentInteractionHighlighting(page, mem, slowMo) {
634
+ // Playwright's own slowMo ALREADY pauses after every single low-level
635
+ // action it dispatches - and pressSequentially() fires one such action
636
+ // PER CHARACTER. Also giving pressSequentially its own fixed delay
637
+ // double-paces every keystroke (45ms + slowMo's own ~350ms, per
638
+ // character) - an ordinary 22-character email alone stretched past 8
639
+ // seconds. When slowMo is already doing the pacing, add none of our own;
640
+ // only fall back to a small typing delay when slowMo is off entirely.
641
+ const typeDelay = slowMo ? 0 : 30;
642
+ // Click is a single action, not per-character, so it doesn't compound
643
+ // the same way - but slowMo still adds its own pause around the actual
644
+ // click, so trim our own explicit "pop" pauses when it's already active
645
+ // rather than stacking a full 1.2s on top of that.
646
+ const clickPrePop = slowMo ? 300 : 700;
647
+ const clickPostPop = slowMo ? 200 : 500;
648
+ // Locator.fill()/click() only ever see a raw call, no context of where
649
+ // the value came from. Patching mem.get() to remember the most recently
650
+ // read key's name (Blocks read-then-immediately-fill, e.g. const { email
651
+ // } = mem.get(LoginInput.key); ...fill(email)) lets the fill patch below
652
+ // label the ring with the real key, not a generic "writing from mem".
653
+ // Patches this ONE mem instance only, not MemPage's shared prototype -
654
+ // there's exactly one mem per chain run.
655
+ const memTrack = { lastKeyName: null, at: 0 };
656
+ const originalGet = mem.get.bind(mem);
657
+ mem.get = (key) => {
658
+ memTrack.lastKeyName = key && key.name;
659
+ memTrack.at = Date.now();
660
+ return originalGet(key);
661
+ };
662
+
607
663
  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));
664
+
665
+ if (!proto.__wgFillPatched) {
666
+ proto.__wgFillPatched = true;
667
+ const originalFill = proto.fill;
668
+ proto.fill = async function (value, options) {
669
+ try {
670
+ await installOverlay(page);
671
+ const box = await this.boundingBox();
672
+ if (box) {
673
+ // Only trust the "last mem.get()" as THIS fill's source if it
674
+ // happened recently - a stale read from several actions ago is
675
+ // more likely unrelated than actually describing this field.
676
+ const label = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
677
+ ? "from mem: " + memTrack.lastKeyName
678
+ : "writing from mem";
679
+ await showRing(page, box, label);
680
+ await new Promise((res) => setTimeout(res, 200));
681
+ }
682
+ } catch {
683
+ // best-effort - element not visible/attached yet is not this
684
+ // instrumentation's problem, the real fill below still runs
630
685
  }
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
- };
686
+ let result;
687
+ try {
688
+ // NOT this.clear() - Locator.clear() is itself implemented as
689
+ // fill(""), and since fill is patched on the shared prototype,
690
+ // that call would resolve back to THIS same patched function and
691
+ // recurse forever. Call the real original fill directly instead.
692
+ await originalFill.call(this, "", { timeout: options && options.timeout });
693
+ result = await this.pressSequentially(String(value), { delay: typeDelay, timeout: options && options.timeout });
694
+ } catch {
695
+ // pressSequentially unsupported on this element (e.g. a
696
+ // contenteditable div, or a locator .fill() genuinely needs to
697
+ // handle specially) - fall back to the real, unpatched fill.
698
+ result = await originalFill.call(this, value, options);
699
+ }
700
+ // Fade the ring back out once this field is actually done, instead
701
+ // of leaving it lit until the next Block's own before-panel clears
702
+ // it - it was sticking around through the whole rest of the step.
703
+ await hideRing(page);
704
+ return result;
705
+ };
706
+ }
707
+
708
+ if (!proto.__wgClickPatched) {
709
+ proto.__wgClickPatched = true;
710
+ const originalClick = proto.click;
711
+ proto.click = async function (options) {
712
+ try {
713
+ await installOverlay(page);
714
+ const box = await this.boundingBox();
715
+ if (box) {
716
+ let label = "click";
717
+ try {
718
+ const text = (await this.textContent())?.trim();
719
+ if (text && text.length > 0 && text.length <= 30) label = text;
720
+ } catch {
721
+ // element has no simple text (an icon button, say) - generic label is fine
722
+ }
723
+ await showRing(page, box, label);
724
+ // "pop for a few seconds" - Dan's own phrase, matching the
725
+ // zsign demo-engine's ring-before-click pattern in
726
+ // services/help-center-clip-engine's video-pipeline.
727
+ await new Promise((res) => setTimeout(res, clickPrePop));
728
+ }
729
+ } catch {
730
+ // best-effort - the real click below still runs either way
731
+ }
732
+ const result = await originalClick.call(this, options);
733
+ await new Promise((res) => setTimeout(res, clickPostPop));
734
+ await hideRing(page);
735
+ return result;
736
+ };
737
+ }
738
+
739
+ // Caps any Locator.waitFor()/page.waitForTimeout() timeout during step
740
+ // mode - a Block polling for something that will NEVER happen (e.g. "is
741
+ // there an unverified-account banner" on an account that IS verified)
742
+ // has no choice but to wait out its own hardcoded timeout in full before
743
+ // concluding "no". Only the WAIT gets capped, never the real outcome -
744
+ // if the thing genuinely appears at 800ms into a 5000ms wait, waitFor
745
+ // still resolves at 800ms same as always; this only shortens the "it's
746
+ // just never going to happen" case. Plain setTimeout()-based sleep()
747
+ // helpers some Blocks use for their own pacing are NOT Playwright calls
748
+ // at all and can't be touched this way - a real, disclosed limit, not
749
+ // silently ignored.
750
+ // Conservative on purpose: this exact codebase has documented real
751
+ // flakiness around slow-but-legitimate hydration waits (see
752
+ // login.block.ts's own waitForActionable comment). A too-aggressive cap
753
+ // would trade "one annoying 5s dead wait" for "logins that sometimes
754
+ // fail outright" - worse. 3000ms still meaningfully shortens the
755
+ // "waiting for something that will never happen" case without giving a
756
+ // real, slow-but-genuine wait much less room than before.
757
+ const WAIT_CAP_MS = 3000;
758
+ if (!proto.__wgWaitForPatched) {
759
+ proto.__wgWaitForPatched = true;
760
+ const originalWaitFor = proto.waitFor;
761
+ proto.waitFor = function (options) {
762
+ const capped = { ...(options || {}), timeout: Math.min((options && options.timeout) || 30000, WAIT_CAP_MS) };
763
+ return originalWaitFor.call(this, capped);
764
+ };
765
+ }
766
+ const pageProto = Object.getPrototypeOf(page);
767
+ if (!pageProto.__wgWaitForTimeoutPatched) {
768
+ pageProto.__wgWaitForTimeoutPatched = true;
769
+ const originalWaitForTimeout = pageProto.waitForTimeout;
770
+ pageProto.waitForTimeout = function (ms) {
771
+ return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_MS));
772
+ };
773
+ }
649
774
  }
650
775
 
651
- async function runStepMode(engine, start, end, context, page, mem, resolved) {
652
- instrumentFillHighlighting(page);
776
+ async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo) {
777
+ instrumentInteractionHighlighting(page, mem, slowMo);
778
+ // Some real Blocks (e.g. zsign-all's login.block.ts) call
779
+ // page.setViewportSize({ width: 1280, height: 720 }) inside their own
780
+ // act() - a hardcoded override for THEIR OWN testing consistency, with no
781
+ // idea an interactive human session is watching. Restoring the size
782
+ // AFTER each step (the first attempt at this) was still just repairing
783
+ // damage after the fact, on a delay, per-step - not the real fix. The
784
+ // real fix is to stop the override from ever landing at all: no-op
785
+ // setViewportSize entirely for the duration of step mode. Blocks that
786
+ // call it don't need it to actually do anything here - their own
787
+ // selectors/layouts still work fine at whatever size the real window
788
+ // already is.
789
+ page.setViewportSize = async () => {};
653
790
  let resolveNext = null;
654
791
  await page.exposeFunction("__wgNext", (edits) => {
655
792
  if (resolveNext) {
@@ -789,7 +926,7 @@ async function main() {
789
926
  if (baseURL) {
790
927
  await page.goto(baseURL).catch(() => {});
791
928
  }
792
- result = await runStepMode(engine, start, end, context, page, mem, resolved);
929
+ result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo);
793
930
  } else {
794
931
  const blocks = resolved.map((r) => r.block);
795
932
  const chained = blocks.reduce((a, b) => connect(a, b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",