waygraph 0.15.21 → 0.15.23

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.
@@ -0,0 +1,353 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { installOverlay } from "./overlay-install.js";
4
+ import { applyHighlightZoom, captionForLocator, dwellMatchedStub, ensureLocatorInView, hideRing, matchStubIndexForLocator, showRing, wasJustNarrated } from "./rings.js";
5
+ import { syncTodoDockAdvance } from "./todo-dock.js";
6
+ import { clickPulseAt, moveCursorTo } from "./cursor.js";
7
+ import { demoHighlight } from "./demo-log.js";
8
+ import { restoreTheaterAfterNavigation } from "./teardown.js";
9
+
10
+ export function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef, deviceRef) {
11
+ // Playwright's own slowMo ALREADY pauses after every single low-level
12
+ // action it dispatches - and pressSequentially() fires one such action
13
+ // PER CHARACTER. Also giving pressSequentially its own fixed delay
14
+ // double-paces every keystroke (45ms + slowMo's own ~350ms, per
15
+ // character) - an ordinary 22-character email alone stretched past 8
16
+ // seconds. When slowMo is already doing the pacing, add none of our own;
17
+ // only fall back to a small typing delay when slowMo is off entirely.
18
+ //
19
+ // pacing.skipTheater (WAYGRAPH_FAST_BLOCKS **or** FFCompose): near-zero
20
+ // cursor / pop / typing - blitz this step. --fast alone sets gatesFast only
21
+ // (shorter Next/autoplay gates) and must NOT skip theater, or the whole demo
22
+ // looks like plain waygraph run.
23
+ const skipTheater = () => !!pacing.skipTheater;
24
+ const typeDelay = () => (skipTheater() ? 0 : slowMo ? 0 : 30);
25
+ // Click is a single action, not per-character, so it doesn't compound
26
+ // the same way - but slowMo still adds its own pause around the actual
27
+ // click, so trim our own explicit "pop" pauses when it's already active
28
+ // rather than stacking a full 1.2s on top of that.
29
+ const clickPrePop = () => (skipTheater() ? 0 : slowMo ? 300 : 700);
30
+ const clickPostPop = () => (skipTheater() ? 0 : slowMo ? 200 : 500);
31
+ // FF / FAST_BLOCKS: no cursor travel animation (0). Plain theater keeps full.
32
+ const cursorMs = (full) => (skipTheater() ? 0 : full);
33
+ // Locator.fill()/click() only ever see a raw call, no context of where
34
+ // the value came from. Patching mem.get() to remember the most recently
35
+ // read key's name (Blocks read-then-immediately-fill, e.g. const { email
36
+ // } = mem.get(LoginInput.key); ...fill(email)) lets the fill patch below
37
+ // label the ring with the real key, not a generic "writing from mem".
38
+ // Patches this ONE mem instance only, not MemPage's shared prototype -
39
+ // there's exactly one mem per chain run.
40
+ const memTrack = { lastKeyName: null, at: 0 };
41
+ const originalGet = mem.get.bind(mem);
42
+ mem.get = (key) => {
43
+ memTrack.lastKeyName = key && key.name;
44
+ memTrack.at = Date.now();
45
+ return originalGet(key);
46
+ };
47
+
48
+ const proto = Object.getPrototypeOf(page.locator("html"));
49
+
50
+ if (!proto.__wgFillPatched) {
51
+ proto.__wgFillPatched = true;
52
+ const originalFill = proto.fill;
53
+ proto.fill = async function (value, options) {
54
+ try {
55
+ await installOverlay(page);
56
+ await this.waitFor({
57
+ state: "visible",
58
+ timeout: (options && options.timeout) || 30000,
59
+ }).catch(() => {});
60
+ // Smooth scroll before measuring - otherwise off-screen fields
61
+ // teleport when Playwright's fill actionability scrolls.
62
+ await ensureLocatorInView(this, { instant: skipTheater() });
63
+ const box = await this.boundingBox();
64
+ let typeLabel = "input";
65
+ try {
66
+ const meta = await this.evaluate((el) => {
67
+ const id = (el.id || "").toLowerCase();
68
+ const name = (el.getAttribute("name") || "").toLowerCase();
69
+ const ph = (el.getAttribute("placeholder") || "").toLowerCase();
70
+ const ty = (el.getAttribute("type") || "text").toLowerCase();
71
+ if (ty === "password" || /pass/.test(id + name + ph)) return "password";
72
+ if (/user|email|login/.test(id + name + ph)) return "username";
73
+ if (ty && ty !== "text") return ty;
74
+ return id || name || ph || "input";
75
+ });
76
+ if (meta) typeLabel = String(meta);
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ if (box && !(await wasJustNarrated(page))) {
81
+ // Only trust the "last mem.get()" as THIS fill's source if it
82
+ // happened recently - a stale read from several actions ago is
83
+ // more likely unrelated than actually describing this field.
84
+ const fallback = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
85
+ ? "from mem: " + memTrack.lastKeyName
86
+ : "writing from mem";
87
+ const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
88
+ const cap = await captionForLocator(page, this, stubs, fallback);
89
+ const stubIdx = await matchStubIndexForLocator(page, this, stubs);
90
+ const stub = stubIdx >= 0 ? stubs[stubIdx] : null;
91
+ await syncTodoDockAdvance(page, todoDockRef, stubIdx, stub);
92
+ await moveCursorTo(page, box, cursorMs(500));
93
+ if (stub) {
94
+ await applyHighlightZoom(page, stub.selector, stub.zoom, stub.zoomOut);
95
+ }
96
+ demoHighlight(
97
+ {
98
+ selector: stub && stub.selector,
99
+ label: cap.label,
100
+ tone: cap.tone || (stub && stub.tone) || "planned",
101
+ color: stub && stub.color,
102
+ focus: !!(stub && stub.focus),
103
+ zoom: stub && stub.zoom,
104
+ zoomOut: stub && stub.zoomOut,
105
+ gesture: stub && stub.gesture,
106
+ weight: cap.weight,
107
+ size: cap.size,
108
+ },
109
+ "fill",
110
+ );
111
+ await showRing(page, box, cap.label, cap.tone, {
112
+ size: cap.size,
113
+ weight: cap.weight,
114
+ selector: stub && stub.selector ? stub.selector : undefined,
115
+ focus: !!(stub && stub.focus),
116
+ });
117
+ await dwellMatchedStub(page, this, stubs, pacing);
118
+ await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 200));
119
+ if (cap && cap.label) typeLabel = String(cap.label).slice(0, 40);
120
+ } else if (box) {
121
+ // Ring/caption already handled by narrate() - still move the
122
+ // cursor there, just skip re-showing the ring with a generic label.
123
+ await moveCursorTo(page, box, cursorMs(200));
124
+ }
125
+ await page
126
+ .evaluate((label) => {
127
+ if (window.__wgSetTypingBadge) window.__wgSetTypingBadge(true, label);
128
+ }, typeLabel)
129
+ .catch(() => {});
130
+ } catch {
131
+ // best-effort - element not visible/attached yet is not this
132
+ // instrumentation's problem, the real fill below still runs
133
+ }
134
+ let result;
135
+ try {
136
+ // NOT this.clear() - Locator.clear() is itself implemented as
137
+ // fill(""), and since fill is patched on the shared prototype,
138
+ // that call would resolve back to THIS same patched function and
139
+ // recurse forever. Call the real original fill directly instead.
140
+ await originalFill.call(this, "", { timeout: options && options.timeout });
141
+ result = await this.pressSequentially(String(value), { delay: typeDelay(), timeout: options && options.timeout });
142
+ } catch {
143
+ // pressSequentially unsupported on this element (e.g. a
144
+ // contenteditable div, or a locator .fill() genuinely needs to
145
+ // handle specially) - fall back to the real, unpatched fill.
146
+ result = await originalFill.call(this, value, options);
147
+ }
148
+ await page
149
+ .evaluate(() => {
150
+ if (window.__wgSetTypingBadge) window.__wgSetTypingBadge(false);
151
+ })
152
+ .catch(() => {});
153
+ // Fade the ring back out once this field is actually done, instead
154
+ // of leaving it lit until the next Block's own before-panel clears
155
+ // it - it was sticking around through the whole rest of the step.
156
+ await hideRing(page);
157
+ await restoreTheaterAfterNavigation(page, todoDockRef, deviceRef, stubBeforeRef);
158
+ return result;
159
+ };
160
+ }
161
+
162
+ if (!proto.__wgClickPatched) {
163
+ proto.__wgClickPatched = true;
164
+ const originalClick = proto.click;
165
+ proto.click = async function (options) {
166
+ let clickPoint = null;
167
+ let clickTone = "auto";
168
+ try {
169
+ await installOverlay(page);
170
+ // Wait until the target is visible BEFORE measuring - otherwise
171
+ // defineNavBlock({ click }) (and any slow-to-appear control) skips
172
+ // the cursor entirely: boundingBox was null, then originalClick
173
+ // waited and clicked with no demo animation.
174
+ await this.waitFor({
175
+ state: "visible",
176
+ timeout: (options && options.timeout) || 30000,
177
+ }).catch(() => {});
178
+ // Smooth-scroll off-screen targets before cursor/ring. Playwright's
179
+ // own click scroll is instant and reads as a teleport under --fast.
180
+ await ensureLocatorInView(this, { instant: skipTheater() });
181
+ const box = await this.boundingBox();
182
+ const narrated = box ? await wasJustNarrated(page) : false;
183
+ if (box && !narrated) {
184
+ let fallback = "click";
185
+ try {
186
+ const text = (await this.textContent())?.trim();
187
+ if (text && text.length > 0 && text.length <= 30) fallback = text;
188
+ } catch {
189
+ // element has no simple text (an icon button, say) - generic label is fine
190
+ }
191
+ // Prefer "nav: …" when this locator is a NavBlock click target
192
+ // (set on page by runStepMode just before act).
193
+ try {
194
+ const navLabel = await page.evaluate(() => window.__wgPendingNavClickLabel || null);
195
+ if (navLabel) fallback = String(navLabel);
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ const stubs = (stubBeforeRef && stubBeforeRef.current) || [];
200
+ const cap = await captionForLocator(page, this, stubs, fallback);
201
+ const stubIdx = await matchStubIndexForLocator(page, this, stubs);
202
+ const stub = stubIdx >= 0 ? stubs[stubIdx] : null;
203
+ await syncTodoDockAdvance(page, todoDockRef, stubIdx, stub);
204
+ clickTone = cap.tone;
205
+ clickPoint = await moveCursorTo(page, box, cursorMs(600));
206
+ if (stub) {
207
+ await applyHighlightZoom(page, stub.selector, stub.zoom, stub.zoomOut);
208
+ }
209
+ demoHighlight(
210
+ {
211
+ selector: stub && stub.selector,
212
+ label: cap.label,
213
+ tone: cap.tone || (stub && stub.tone) || "planned",
214
+ color: stub && stub.color,
215
+ focus: !!(stub && stub.focus),
216
+ zoom: stub && stub.zoom,
217
+ zoomOut: stub && stub.zoomOut,
218
+ gesture: stub && stub.gesture,
219
+ weight: cap.weight,
220
+ size: cap.size,
221
+ },
222
+ "click",
223
+ );
224
+ await showRing(page, box, cap.label, cap.tone, {
225
+ size: cap.size,
226
+ weight: cap.weight,
227
+ selector: stub && stub.selector ? stub.selector : undefined,
228
+ focus: !!(stub && stub.focus),
229
+ });
230
+ await dwellMatchedStub(page, this, stubs, pacing);
231
+ // "pop for a few seconds" - Dan's own phrase, matching the
232
+ // zsign demo-engine's ring-before-click pattern in
233
+ // services/help-center-clip-engine's video-pipeline.
234
+ await new Promise((res) => setTimeout(res, clickPrePop()));
235
+ } else if (box) {
236
+ // Ring/caption already handled by narrate() - still move the
237
+ // cursor + pulse the click point, just skip re-showing the ring.
238
+ clickPoint = await moveCursorTo(page, box, cursorMs(200));
239
+ clickTone = "planned";
240
+ }
241
+ } catch {
242
+ // best-effort - the real click below still runs either way
243
+ }
244
+ if (clickPoint) {
245
+ await clickPulseAt(page, clickPoint.x, clickPoint.y, clickTone);
246
+ // clickPulseAt only triggers the CSS animation class - it doesn't
247
+ // wait for it. Without a pause here, the real click (and any
248
+ // resulting navigation/DOM change) fires while the ripple is still
249
+ // mid-animation, sometimes cutting it off before it's even visible.
250
+ // Wait out the same .5s the "@keyframes wg-pulse" rule uses, so the
251
+ // mock click visually completes before the real one fires.
252
+ await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 500));
253
+ }
254
+ // Touch theater (0.13+): prefer Locator.tap / long-press when device
255
+ // touchMode is on (context launched with hasTouch). gesture on stub
256
+ // wins: tap | hold | click.
257
+ let result;
258
+ const stubsForGesture = (stubBeforeRef && stubBeforeRef.current) || [];
259
+ let gesture = "click";
260
+ try {
261
+ const idx = await matchStubIndexForLocator(page, this, stubsForGesture);
262
+ const stub = idx >= 0 ? stubsForGesture[idx] : null;
263
+ if (stub && stub.gesture) gesture = stub.gesture;
264
+ else if (deviceRef && deviceRef.current && deviceRef.current.touchMode) gesture = "tap";
265
+ } catch {
266
+ if (deviceRef && deviceRef.current && deviceRef.current.touchMode) gesture = "tap";
267
+ }
268
+ if (gesture === "tap" || gesture === "hold") {
269
+ try {
270
+ if (gesture === "hold") {
271
+ result = await this.tap({
272
+ ...(options || {}),
273
+ delay: skipTheater() ? 0 : 600,
274
+ });
275
+ } else {
276
+ result = await this.tap(options);
277
+ }
278
+ } catch {
279
+ result = await originalClick.call(this, options);
280
+ }
281
+ } else {
282
+ result = await originalClick.call(this, options);
283
+ }
284
+ await new Promise((res) => setTimeout(res, clickPostPop()));
285
+ await hideRing(page);
286
+ // Full document navigation drops #wg-todo-dock + rings; restore carry.
287
+ await restoreTheaterAfterNavigation(page, todoDockRef, deviceRef, stubBeforeRef);
288
+ return result;
289
+ };
290
+ }
291
+
292
+ // Caps any Locator.waitFor()/page.waitForTimeout() timeout during step
293
+ // mode - a Block polling for something that will NEVER happen (e.g. "is
294
+ // there an unverified-account banner" on an account that IS verified)
295
+ // has no choice but to wait out its own hardcoded timeout in full before
296
+ // concluding "no". Only the WAIT gets capped, never the real outcome -
297
+ // if the thing genuinely appears at 800ms into a 5000ms wait, waitFor
298
+ // still resolves at 800ms same as always; this only shortens the "it's
299
+ // just never going to happen" case. Plain setTimeout()-based sleep()
300
+ // helpers some Blocks use for their own pacing are NOT Playwright calls
301
+ // at all and can't be touched this way - a real, disclosed limit, not
302
+ // silently ignored.
303
+ // Pushed down to 700ms earlier this session based on login.block.ts
304
+ // alone - every waitFor() there is a "give up gracefully" pattern
305
+ // (wrapped in .catch()/Promise.race, a timeout IS the expected negative
306
+ // result). That's a different kind of wait than a REQUIRED
307
+ // synchronization point with no catch - e.g. overview-metrics.block.ts
308
+ // waits (uncaught) for a "Loading pending actions..." placeholder to
309
+ // actually appear before it's safe to check for an error, a real
310
+ // hydration signal the block's own comment calls "deterministic,"
311
+ // not a probe. A flat 700ms cap broke that block outright on this
312
+ // shared, loaded box (a real 3-block chain run threw instead of
313
+ // catching). Bumping the SAME flat cap to 4000ms to cover it just
314
+ // reintroduced most of the original torture on login.block.ts's own
315
+ // 5000ms unverified-email check - 4000ms is barely better than the real
316
+ // 5000ms it's capping.
317
+ //
318
+ // A single flat number can't serve both: it has no way to tell "safe to
319
+ // cut short" from "must actually happen" apart from the outside. But
320
+ // each call site's OWN declared timeout is already a real signal of
321
+ // which kind it is - a Block author who wrote timeout: 5000 for a quick
322
+ // conditional check and one who wrote timeout: 15000 for a real
323
+ // hydration wait weren't picking the same number by accident. Scale the
324
+ // cap off that instead of a single constant: 30% of whatever was
325
+ // declared, never below a 700ms floor (the value already proven safe
326
+ // for the fast/common case), and never above what was declared in the
327
+ // first place. login's 5000ms check -> 1500ms (still 3.5s faster than
328
+ // uncapped). overview-metrics' 15000ms wait -> 4500ms (more headroom
329
+ // than the flat 4000ms fix, not less).
330
+ const WAIT_CAP_FLOOR_MS = 700;
331
+ const WAIT_CAP_FRACTION = 0.3;
332
+ const scaledWaitCap = (declared) => Math.max(WAIT_CAP_FLOOR_MS, declared * WAIT_CAP_FRACTION);
333
+ if (!proto.__wgWaitForPatched) {
334
+ proto.__wgWaitForPatched = true;
335
+ const originalWaitFor = proto.waitFor;
336
+ proto.waitFor = function (options) {
337
+ const declared = (options && options.timeout) || 30000;
338
+ const capped = { ...(options || {}), timeout: Math.min(declared, scaledWaitCap(declared)) };
339
+ return originalWaitFor.call(this, capped);
340
+ };
341
+ }
342
+ const pageProto = Object.getPrototypeOf(page);
343
+ if (!pageProto.__wgWaitForTimeoutPatched) {
344
+ pageProto.__wgWaitForTimeoutPatched = true;
345
+ const originalWaitForTimeout = pageProto.waitForTimeout;
346
+ pageProto.waitForTimeout = function (ms) {
347
+ // A plain sleep (not a "wait for condition X") never has a hidden
348
+ // "must actually happen" requirement attached - always safe to cut
349
+ // to the floor, no scaling needed.
350
+ return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_FLOOR_MS));
351
+ };
352
+ }
353
+ }