@bubstack/moe-glass 0.1.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.
Files changed (62) hide show
  1. package/README.md +29 -0
  2. package/agents/browser-user.md +105 -0
  3. package/dist/LICENSE +25 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22517 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/payload.d.ts +214 -0
  9. package/dist/payload.d.ts.map +1 -0
  10. package/dist/payload.js +325 -0
  11. package/dist/payload.js.map +1 -0
  12. package/package.json +59 -0
  13. package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
  14. package/skills/browsing/EXAMPLES.md +717 -0
  15. package/skills/browsing/README.md +55 -0
  16. package/skills/browsing/SKILL.md +478 -0
  17. package/skills/browsing/chrome-ws +1021 -0
  18. package/skills/browsing/chrome-ws-lib.js +461 -0
  19. package/skills/browsing/host-override.js +98 -0
  20. package/skills/browsing/lib/browser-bridge.js +175 -0
  21. package/skills/browsing/lib/browser-session.js +137 -0
  22. package/skills/browsing/lib/capture.js +499 -0
  23. package/skills/browsing/lib/cdp-router.js +72 -0
  24. package/skills/browsing/lib/cdp-utils.js +18 -0
  25. package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
  26. package/skills/browsing/lib/chrome-process.js +464 -0
  27. package/skills/browsing/lib/console-logging.js +70 -0
  28. package/skills/browsing/lib/cookies.js +17 -0
  29. package/skills/browsing/lib/dialogs-render.js +154 -0
  30. package/skills/browsing/lib/dialogs-router.js +117 -0
  31. package/skills/browsing/lib/dialogs.js +254 -0
  32. package/skills/browsing/lib/element-selector.js +91 -0
  33. package/skills/browsing/lib/evaluation.js +85 -0
  34. package/skills/browsing/lib/extraction.js +55 -0
  35. package/skills/browsing/lib/file-upload.js +56 -0
  36. package/skills/browsing/lib/html-diff.js +122 -0
  37. package/skills/browsing/lib/key-definitions.js +149 -0
  38. package/skills/browsing/lib/keyboard-input.js +288 -0
  39. package/skills/browsing/lib/mouse.js +423 -0
  40. package/skills/browsing/lib/navigation.js +272 -0
  41. package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
  42. package/skills/browsing/lib/page-scripts/markdown.js +85 -0
  43. package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
  44. package/skills/browsing/lib/page-session.js +106 -0
  45. package/skills/browsing/lib/profile-lock.js +179 -0
  46. package/skills/browsing/lib/screenshot.js +171 -0
  47. package/skills/browsing/lib/select-option.js +99 -0
  48. package/skills/browsing/lib/session-state.js +66 -0
  49. package/skills/browsing/lib/tabs.js +144 -0
  50. package/skills/browsing/lib/viewport.js +103 -0
  51. package/skills/browsing/lib/websocket-client.js +162 -0
  52. package/skills/browsing/package.json +11 -0
  53. package/skills/browsing/test-chrome-args.js +81 -0
  54. package/skills/browsing/test-cookies.js +21 -0
  55. package/skills/browsing/test-e2e.sh +51 -0
  56. package/skills/browsing/test-extract.sh +17 -0
  57. package/skills/browsing/test-interact.sh +11 -0
  58. package/skills/browsing/test-navigate.sh +9 -0
  59. package/skills/browsing/test-raw.sh +8 -0
  60. package/skills/browsing/test-tabs.sh +15 -0
  61. package/skills/browsing/test-viewport.js +27 -0
  62. package/skills/browsing/test-wait.sh +9 -0
@@ -0,0 +1,423 @@
1
+ const { getElementSelector } = require('./element-selector');
2
+ const { throwIfExceptionDetails } = require('./cdp-utils');
3
+
4
+ // Brief pause between the last mouseMoved step and mouseReleased so apps
5
+ // that process drag events asynchronously have time to commit.
6
+ const DRAG_SETTLE_MS = 50;
7
+
8
+ // Default RNG (uses Math.random). Injectable via _rng for deterministic tests.
9
+ function defaultRng() {
10
+ return Math.random();
11
+ }
12
+
13
+ /**
14
+ * Compute N evenly-spaced points along a quadratic Bezier curve from
15
+ * (x0,y0) to (x1,y1) with a perpendicular-offset control point.
16
+ * The offset is a random fraction of the chord length so paths curve
17
+ * naturally but don't overshoot wildly.
18
+ *
19
+ * Returns an array of {x, y} integer coordinates, NOT including the
20
+ * start point but INCLUDING the end point.
21
+ */
22
+ function bezierPoints(x0, y0, x1, y1, n, rng) {
23
+ const dx = x1 - x0;
24
+ const dy = y1 - y0;
25
+ const dist = Math.sqrt(dx * dx + dy * dy);
26
+
27
+ // Control point: midpoint ± perpendicular offset (5%–25% of chord).
28
+ const mx = (x0 + x1) / 2;
29
+ const my = (y0 + y1) / 2;
30
+ const perpScale = (0.05 + rng() * 0.20) * dist;
31
+ // Perpendicular unit vector (rotate 90°): (-dy/dist, dx/dist)
32
+ const perpX = dist > 0 ? (-dy / dist) * perpScale : 0;
33
+ const perpY = dist > 0 ? (dx / dist) * perpScale : 0;
34
+ const cx = mx + perpX;
35
+ const cy = my + perpY;
36
+
37
+ const points = [];
38
+ for (let i = 1; i <= n; i++) {
39
+ const t = i / n;
40
+ const u = 1 - t;
41
+ // Quadratic Bezier: B(t) = u²·P0 + 2u·t·Pc + t²·P1
42
+ points.push({
43
+ x: Math.round(u * u * x0 + 2 * u * t * cx + t * t * x1),
44
+ y: Math.round(u * u * y0 + 2 * u * t * cy + t * t * y1),
45
+ });
46
+ }
47
+ return points;
48
+ }
49
+
50
+ /**
51
+ * Ease-in/ease-out (sinusoidal) weight for step i of n total steps.
52
+ * Returns a value 0–1 that is small at start and end, large in middle.
53
+ */
54
+ function easeWeight(i, n) {
55
+ return Math.sin((i / n) * Math.PI);
56
+ }
57
+
58
+ /**
59
+ * CDP mouse actions — click, hover, drag, mouse-move, scroll, double-click,
60
+ * right-click. Every entry resolves to real `Input.dispatchMouseEvent`
61
+ * calls so React (and other framework) synthetic-event handlers see
62
+ * genuine input. JRV-124 and friends established this as the default click
63
+ * path; the older `el.click()` route survives only as a fallback for
64
+ * hidden-element edge cases inside `click`.
65
+ *
66
+ * `attachMouse({ getPageSession, dialogs, _rng })` returns the bound action
67
+ * methods. The pre-action element-coordinate lookup uses the shared
68
+ * `getElementSelector` from lib/element-selector — same visibility-aware
69
+ * picker the rest of the library uses.
70
+ *
71
+ * `_rng` is injectable for deterministic tests; defaults to Math.random.
72
+ */
73
+ function attachMouse({ getPageSession, dialogs, _rng }) {
74
+ const { tryHandleDialogSelectorForSession } = require('./dialogs-router.js');
75
+
76
+ // Per-session last-known cursor position. Chains consecutive moves so the
77
+ // path starts where the cursor actually is rather than (0,0).
78
+ const lastMousePos = { x: 0, y: 0 };
79
+
80
+ // Resolve the random number generator.
81
+ const rng = typeof _rng === 'function' ? _rng : defaultRng;
82
+
83
+ /**
84
+ * Send a humanised sequence of mouseMoved events from (fromX, fromY) to
85
+ * (toX, toY) using a quadratic Bezier path with ease-in/ease-out timing.
86
+ *
87
+ * The number of intermediate steps scales with distance so short moves are
88
+ * still smooth and long moves don't fire an unreasonable number of events.
89
+ * Total duration scales with distance: ~80ms per 200px, capped 30–400ms.
90
+ * Inter-event delay follows ease-in/ease-out (slow at start/end) with
91
+ * ±10% random jitter per step.
92
+ *
93
+ * The final event is ALWAYS dispatched at the exact integer target coords.
94
+ * Updates lastMousePos after completion.
95
+ */
96
+ async function humanMouseMove(ps, fromX, fromY, toX, toY, extraParams = {}) {
97
+ const dx = toX - fromX;
98
+ const dy = toY - fromY;
99
+ const dist = Math.sqrt(dx * dx + dy * dy);
100
+
101
+ // Steps: at least 10, one per 15px, no more than 120.
102
+ const steps = Math.min(120, Math.max(10, Math.round(dist / 15)));
103
+
104
+ // Total duration: 80ms per 200px, clamped 30–400ms.
105
+ const totalMs = Math.min(400, Math.max(30, (dist / 200) * 80));
106
+
107
+ // Compute weighted step durations (ease-in/ease-out).
108
+ const weights = Array.from({ length: steps }, (_, i) => easeWeight(i + 1, steps));
109
+ const weightSum = weights.reduce((a, b) => a + b, 0);
110
+
111
+ // Bezier path (includes end point).
112
+ const points = bezierPoints(fromX, fromY, toX, toY, steps, rng);
113
+
114
+ for (let i = 0; i < steps; i++) {
115
+ const { x, y } = i === steps - 1
116
+ ? { x: Math.round(toX), y: Math.round(toY) }
117
+ : points[i];
118
+
119
+ await ps.send('Input.dispatchMouseEvent', {
120
+ type: 'mouseMoved',
121
+ x,
122
+ y,
123
+ ...extraParams,
124
+ });
125
+
126
+ // Compute this step's delay with ±10% jitter.
127
+ const baseDuration = (weights[i] / weightSum) * totalMs;
128
+ const jitter = 1 + (rng() * 0.2 - 0.1);
129
+ const delayMs = Math.round(baseDuration * jitter);
130
+ if (delayMs > 0) {
131
+ await new Promise(resolve => setTimeout(resolve, delayMs));
132
+ }
133
+ }
134
+
135
+ lastMousePos.x = Math.round(toX);
136
+ lastMousePos.y = Math.round(toY);
137
+ }
138
+
139
+ // Common helper: resolve a CSS/XPath selector to centered viewport coords
140
+ // after scrolling the element into view. Returns { x, y } or throws.
141
+ async function resolveCenter(ps, selector, label = 'Element') {
142
+ const js = `
143
+ (() => {
144
+ const el = ${getElementSelector(selector)};
145
+ if (!el) return { found: false };
146
+ el.scrollIntoView({ block: 'center', inline: 'center' });
147
+ const rect = el.getBoundingClientRect();
148
+ return {
149
+ x: rect.left + rect.width / 2,
150
+ y: rect.top + rect.height / 2,
151
+ found: true
152
+ };
153
+ })()
154
+ `;
155
+ const result = await ps.send('Runtime.evaluate', {
156
+ expression: js,
157
+ returnByValue: true
158
+ });
159
+ throwIfExceptionDetails(result);
160
+ if (!result.result.value || !result.result.value.found) {
161
+ throw new Error(`${label} not found: ${selector}`);
162
+ }
163
+ return { x: result.result.value.x, y: result.result.value.y };
164
+ }
165
+
166
+ /**
167
+ * Click element using CDP mouse events (works with React and all frameworks).
168
+ * Moves the cursor to the element center via a humanised Bezier path before
169
+ * pressing. Falls back to `el.click()` if CDP coordinate resolution throws
170
+ * but the element exists. Throws if the element cannot be found at all —
171
+ * never report a fake-success click on a missing selector.
172
+ */
173
+ async function click(tabIndexOrWsUrl, selector) {
174
+ const ps = await getPageSession(tabIndexOrWsUrl);
175
+
176
+ if (selector && selector.startsWith('dialog::') && dialogs) {
177
+ const state = dialogs.getOpen(ps.sessionId);
178
+ const routed = await tryHandleDialogSelectorForSession({ selector, op: 'click', state, pageSession: ps });
179
+ if (routed.handled) {
180
+ if (routed.error) throw new Error(routed.error);
181
+ if (routed.clearDialog) dialogs.clear(ps.sessionId);
182
+ return routed.result;
183
+ }
184
+ }
185
+
186
+ try {
187
+ const { x, y } = await resolveCenter(ps, selector);
188
+
189
+ await humanMouseMove(ps, lastMousePos.x, lastMousePos.y, x, y);
190
+
191
+ await ps.send('Input.dispatchMouseEvent', {
192
+ type: 'mousePressed', x, y, button: 'left', clickCount: 1
193
+ });
194
+ await ps.send('Input.dispatchMouseEvent', {
195
+ type: 'mouseReleased', x, y, button: 'left', clickCount: 1
196
+ });
197
+
198
+ return { clicked: true, x, y };
199
+ } catch (_e) {
200
+ // Skip the fallback when a dialog is already open for this session:
201
+ // the press/release timed out because the click opened a dialog and the
202
+ // page is paused. Running Element.click() now queues a SECOND click
203
+ // event behind the dialog, so dismissing later spawns another confirm
204
+ // (scenario 03 step 6 regression). Propagate the original timeout so
205
+ // the caller learns the click landed on a dialog.
206
+ if (dialogs && dialogs.getOpen && dialogs.getOpen(ps.sessionId)) {
207
+ throw _e;
208
+ }
209
+ // Fallback for cases where CDP coordinate resolution failed but the
210
+ // element actually exists (e.g., hidden / zero bounding rect). Resolve
211
+ // the element first, click via JS only if it's really there, and
212
+ // propagate a not-found error otherwise — never silently succeed.
213
+ const js = `(() => {
214
+ const _el = ${getElementSelector(selector)};
215
+ if (!_el) return { found: false };
216
+ _el.click();
217
+ return { found: true };
218
+ })()`;
219
+ const fallbackResult = await ps.send('Runtime.evaluate', {
220
+ expression: js,
221
+ returnByValue: true,
222
+ });
223
+ throwIfExceptionDetails(fallbackResult);
224
+ if (!fallbackResult.result.value || !fallbackResult.result.value.found) {
225
+ throw new Error(`Element not found: ${selector}`);
226
+ }
227
+ return { clicked: true, fallback: true };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Hover over an element using CDP mouseMoved.
233
+ * Triggers CSS :hover, mouseenter/mouseover events, tooltips, dropdown menus.
234
+ * Uses a humanised Bezier path to reach the element.
235
+ */
236
+ async function hover(tabIndexOrWsUrl, selector) {
237
+ const ps = await getPageSession(tabIndexOrWsUrl);
238
+ const { x, y } = await resolveCenter(ps, selector);
239
+
240
+ await humanMouseMove(ps, lastMousePos.x, lastMousePos.y, x, y);
241
+
242
+ return { hovered: true, x, y };
243
+ }
244
+
245
+ /**
246
+ * Drag from source element to target element or coordinates.
247
+ * Uses Input.dispatchMouseEvent to trigger native drag-and-drop, bypassing
248
+ * the DataTransfer restriction on synthetic JS DragEvents.
249
+ *
250
+ * @param {number|string} tabIndexOrWsUrl - Tab index or WebSocket URL
251
+ * @param {string} sourceSelector - CSS/XPath selector for the drag source
252
+ * @param {string|{x:number,y:number}} target - Target selector string or {x,y} coordinates
253
+ * @param {object} options
254
+ * @param {number} [options.steps=8] - Intermediate mouseMoved steps (must exceed
255
+ * the browser's ~4px drag-detection threshold)
256
+ */
257
+ async function drag(tabIndexOrWsUrl, sourceSelector, target, options = {}) {
258
+ const ps = await getPageSession(tabIndexOrWsUrl);
259
+ const steps = options.steps || 8;
260
+
261
+ const src = await resolveCenter(ps, sourceSelector, 'Source element');
262
+
263
+ let dst;
264
+ if (typeof target === 'object' && target.x !== undefined && target.y !== undefined) {
265
+ dst = { x: target.x, y: target.y };
266
+ } else {
267
+ dst = await resolveCenter(ps, target, 'Target element');
268
+ }
269
+
270
+ await ps.send('Input.dispatchMouseEvent', {
271
+ type: 'mousePressed', x: src.x, y: src.y, button: 'left', clickCount: 1
272
+ });
273
+
274
+ for (let i = 1; i <= steps; i++) {
275
+ const ratio = i / steps;
276
+ await ps.send('Input.dispatchMouseEvent', {
277
+ type: 'mouseMoved',
278
+ x: Math.round(src.x + (dst.x - src.x) * ratio),
279
+ y: Math.round(src.y + (dst.y - src.y) * ratio),
280
+ button: 'left'
281
+ });
282
+ }
283
+
284
+ // Brief pause for apps that process drag events asynchronously.
285
+ await new Promise(resolve => setTimeout(resolve, DRAG_SETTLE_MS));
286
+
287
+ await ps.send('Input.dispatchMouseEvent', {
288
+ type: 'mouseReleased',
289
+ x: Math.round(dst.x),
290
+ y: Math.round(dst.y),
291
+ button: 'left',
292
+ clickCount: 1
293
+ });
294
+
295
+ return { dragged: true, from: { x: src.x, y: src.y }, to: { x: dst.x, y: dst.y }, steps };
296
+ }
297
+
298
+ /**
299
+ * Move mouse to specific coordinates using a humanised Bezier path.
300
+ * Useful for: pre-click mouse patterns (bot detection), captcha puzzles,
301
+ * hover effects on coordinate-based targets.
302
+ *
303
+ * `options.fromX`/`fromY` set an explicit start; otherwise the last-known
304
+ * cursor position (maintained across consecutive moves) is used as the
305
+ * start so chains of moves flow naturally.
306
+ */
307
+ async function mouseMove(tabIndexOrWsUrl, x, y, options = {}) {
308
+ const ps = await getPageSession(tabIndexOrWsUrl);
309
+
310
+ const fromX = options.fromX !== undefined ? options.fromX : lastMousePos.x;
311
+ const fromY = options.fromY !== undefined ? options.fromY : lastMousePos.y;
312
+
313
+ await humanMouseMove(ps, fromX, fromY, x, y);
314
+
315
+ return { moved: true, x, y };
316
+ }
317
+
318
+ /**
319
+ * Scroll using CDP mouse-wheel events.
320
+ * Simulates real wheel input — bot detectors flag JavaScript `scrollTo`.
321
+ *
322
+ * @param {object} options
323
+ * @param {string} [options.selector] - Element to anchor the wheel event on
324
+ * @param {number} [options.deltaX=0] - Horizontal scroll (positive = right)
325
+ * @param {number} [options.deltaY=0] - Vertical scroll (positive = down)
326
+ */
327
+ async function scroll(tabIndexOrWsUrl, options = {}) {
328
+ const ps = await getPageSession(tabIndexOrWsUrl);
329
+
330
+ let x = options.x || 100;
331
+ let y = options.y || 100;
332
+
333
+ if (options.selector) {
334
+ // Inline the selector lookup (rather than using the throwing resolveCenter)
335
+ // so a missing element falls back to default coordinates instead of throwing —
336
+ // matches the pre-extraction scroll() behaviour. CDP errors still propagate.
337
+ const js = `
338
+ (() => {
339
+ const el = ${getElementSelector(options.selector)};
340
+ if (!el) return { found: false };
341
+ const rect = el.getBoundingClientRect();
342
+ return {
343
+ x: rect.left + rect.width / 2,
344
+ y: rect.top + rect.height / 2,
345
+ found: true
346
+ };
347
+ })()
348
+ `;
349
+ const result = await ps.send('Runtime.evaluate', {
350
+ expression: js,
351
+ returnByValue: true
352
+ });
353
+ throwIfExceptionDetails(result);
354
+ if (result.result.value && result.result.value.found) {
355
+ x = result.result.value.x;
356
+ y = result.result.value.y;
357
+ }
358
+ }
359
+
360
+ await ps.send('Input.dispatchMouseEvent', {
361
+ type: 'mouseWheel',
362
+ x: Math.round(x),
363
+ y: Math.round(y),
364
+ deltaX: options.deltaX || 0,
365
+ deltaY: options.deltaY || 0
366
+ });
367
+
368
+ return { scrolled: true, x, y, deltaX: options.deltaX || 0, deltaY: options.deltaY || 0 };
369
+ }
370
+
371
+ /**
372
+ * Double-click an element using CDP mouse events.
373
+ * Moves to element via humanised Bezier path, then fires
374
+ * mousedown, mouseup, click, mousedown, mouseup, click, dblclick.
375
+ */
376
+ async function doubleClick(tabIndexOrWsUrl, selector) {
377
+ const ps = await getPageSession(tabIndexOrWsUrl);
378
+ const { x, y } = await resolveCenter(ps, selector);
379
+
380
+ await humanMouseMove(ps, lastMousePos.x, lastMousePos.y, x, y);
381
+
382
+ await ps.send('Input.dispatchMouseEvent', {
383
+ type: 'mousePressed', x, y, button: 'left', clickCount: 1
384
+ });
385
+ await ps.send('Input.dispatchMouseEvent', {
386
+ type: 'mouseReleased', x, y, button: 'left', clickCount: 1
387
+ });
388
+ // Second click with clickCount: 2 triggers dblclick.
389
+ await ps.send('Input.dispatchMouseEvent', {
390
+ type: 'mousePressed', x, y, button: 'left', clickCount: 2
391
+ });
392
+ await ps.send('Input.dispatchMouseEvent', {
393
+ type: 'mouseReleased', x, y, button: 'left', clickCount: 2
394
+ });
395
+
396
+ return { doubleClicked: true, x, y };
397
+ }
398
+
399
+ /**
400
+ * Right-click an element using CDP mouse events.
401
+ * Moves to element via humanised Bezier path, then fires
402
+ * mousedown (button 2), mouseup (button 2), contextmenu.
403
+ */
404
+ async function rightClick(tabIndexOrWsUrl, selector) {
405
+ const ps = await getPageSession(tabIndexOrWsUrl);
406
+ const { x, y } = await resolveCenter(ps, selector);
407
+
408
+ await humanMouseMove(ps, lastMousePos.x, lastMousePos.y, x, y);
409
+
410
+ await ps.send('Input.dispatchMouseEvent', {
411
+ type: 'mousePressed', x, y, button: 'right', clickCount: 1
412
+ });
413
+ await ps.send('Input.dispatchMouseEvent', {
414
+ type: 'mouseReleased', x, y, button: 'right', clickCount: 1
415
+ });
416
+
417
+ return { rightClicked: true, x, y };
418
+ }
419
+
420
+ return { click, hover, drag, mouseMove, scroll, doubleClick, rightClick };
421
+ }
422
+
423
+ module.exports = { attachMouse };
@@ -0,0 +1,272 @@
1
+ const { getElementSelector } = require('./element-selector');
2
+ const { DialogRefusedError } = require('./dialogs');
3
+ const { renderSyntheticArtifacts } = require('./dialogs-render');
4
+
5
+ // Hard cap on the navigate() wait — covers slow servers and pages that
6
+ // never fire Page.loadEventFired.
7
+ const NAVIGATE_TIMEOUT_MS = 30000;
8
+
9
+ // After Page.loadEventFired, keep the console capture subscription open
10
+ // this long so console messages emitted in the load handler get captured.
11
+ const CONSOLE_LINGER_MS = 1000;
12
+
13
+ /**
14
+ * Navigation: page-level navigation, SPA pushState navigation, and the
15
+ * "wait for" predicates.
16
+ *
17
+ * The full-page `navigate` flow opens a pageSession (via the bridge) and
18
+ * subscribes to events on the shared browser-WS instead of opening a second
19
+ * WebSocket connection. consoleMessages are keyed by sessionId (not wsUrl) so
20
+ * that getConsoleMessages (console-logging.js) can read them after the fact.
21
+ *
22
+ * Listener-ordering invariant: ps.waitForEvent('Page.loadEventFired') registers
23
+ * the listener synchronously before `await ps.send('Page.navigate')` fires —
24
+ * preserving the guarantee that even a fast-loading (data: URL) page won't
25
+ * lose the event.
26
+ *
27
+ * `attachNavigation({ state, getPageSession, capturePageArtifacts, evaluate })`
28
+ * returns the bound methods.
29
+ */
30
+ function attachNavigation({ state, getPageSession, capturePageArtifacts, evaluate }) {
31
+ async function navigate(tabIndexOrWsUrl, url, autoCapture = false) {
32
+ const ps = await getPageSession(tabIndexOrWsUrl);
33
+ const sid = ps.sessionId;
34
+
35
+ // Reset console buffer for this session (keyed by sessionId, not wsUrl).
36
+ // console-logging.js (enableConsoleLogging / attachConsoleLogging) is the
37
+ // single writer for state.consoleMessages. Navigation must NOT also write
38
+ // here — two writers for the same Runtime.consoleAPICalled event is the
39
+ // root cause of the double-entry bug (Bug 1 / fix G follow-up).
40
+ state.consoleMessages.set(sid, []);
41
+
42
+ await ps.enableDomain('Page');
43
+ if (autoCapture) {
44
+ await ps.enableDomain('Runtime');
45
+ }
46
+
47
+ const unsubConsole = () => {};
48
+
49
+ // Chrome broadcasts Page.loadEventFired to all clients that have Page.enable
50
+ // active when ANY other client first enables Page on an already-loaded tab.
51
+ // Guard: only accept Page.loadEventFired after Page.frameNavigated — which
52
+ // only fires for real navigation events, not for the synthetic broadcast.
53
+ let frameNavigated = false;
54
+ const unsubFrameNav = ps.onEvent((msg) => {
55
+ if (msg.method === 'Page.frameNavigated') {
56
+ const frame = msg.params && msg.params.frame;
57
+ if (frame && !frame.parentId) {
58
+ frameNavigated = true;
59
+ }
60
+ }
61
+ });
62
+
63
+ // Listener-ordering invariant: register the Page.loadEventFired listener
64
+ // BEFORE sending Page.navigate so a fast-loading page (data: URL) cannot
65
+ // fire the event before we're ready.
66
+ let loadTimeout, unsubLoad;
67
+ const loadPromise = new Promise((resolve, reject) => {
68
+ loadTimeout = setTimeout(() => {
69
+ unsubLoad();
70
+ reject(new Error(`navigate timeout: ${url} did not fire Page.loadEventFired within ${NAVIGATE_TIMEOUT_MS}ms`));
71
+ }, NAVIGATE_TIMEOUT_MS);
72
+ unsubLoad = ps.onEvent((msg) => {
73
+ if (msg.method === 'Page.loadEventFired' && frameNavigated) {
74
+ clearTimeout(loadTimeout);
75
+ unsubLoad();
76
+ resolve(msg);
77
+ }
78
+ });
79
+ });
80
+
81
+ // Guard against an orphaned loadPromise rejection: if ps.send('Page.navigate')
82
+ // times out (or fails) before loadPromise settles, loadPromise's own 30-second
83
+ // timer will fire later with no awaiter → unhandled rejection → process exit.
84
+ // Attaching .catch here makes that eventual rejection handled, without
85
+ // interfering with the `await loadPromise` path below (Promises can have
86
+ // multiple handlers).
87
+ loadPromise.catch(() => {});
88
+
89
+ // Dialog detection: a basic-auth challenge, permission prompt, or other
90
+ // dialog that fires *during* navigation will (a) pause Chrome so
91
+ // Page.loadEventFired never arrives and often (b) leave the Page.navigate
92
+ // request itself pending too. Without this race the navigate hangs until
93
+ // the 30-second timeout fires, and the caller never learns there's a
94
+ // dialog they need to handle. Resolve the dialogPromise as soon as
95
+ // dialogs.js sets state.dialogs[sid] in response to a CDP event.
96
+ const sawDialogBefore = state.dialogs && state.dialogs.has(sid);
97
+ let unsubDialog;
98
+ const dialogPromise = new Promise((resolve) => {
99
+ unsubDialog = ps.onEvent(() => {
100
+ const open = state.dialogs && state.dialogs.get(sid);
101
+ if (open && !sawDialogBefore) {
102
+ unsubDialog();
103
+ resolve(open);
104
+ }
105
+ });
106
+ });
107
+
108
+ let navigateResult;
109
+ let dialogWon = null;
110
+ try {
111
+ // Fire the navigate without awaiting — we race its completion against
112
+ // loadPromise and dialogPromise below. Any rejection propagates via
113
+ // the .catch attached on the race outcome.
114
+ const navigatePromise = ps.send('Page.navigate', { url });
115
+ // Suppress unhandled-rejection if the dialog race wins.
116
+ navigatePromise.catch(() => {});
117
+
118
+ const outcome = await Promise.race([
119
+ navigatePromise.then((r) => ({ kind: 'send-resolved', r })),
120
+ navigatePromise.catch((e) => ({ kind: 'send-rejected', e })),
121
+ loadPromise.then(() => ({ kind: 'load' })),
122
+ dialogPromise.then((d) => ({ kind: 'dialog', d })),
123
+ ]);
124
+
125
+ if (outcome.kind === 'send-rejected') {
126
+ clearTimeout(loadTimeout);
127
+ if (unsubLoad) unsubLoad();
128
+ if (unsubDialog) unsubDialog();
129
+ unsubConsole();
130
+ unsubFrameNav();
131
+ throw outcome.e;
132
+ }
133
+
134
+ if (outcome.kind === 'dialog') {
135
+ dialogWon = outcome.d;
136
+ } else {
137
+ // 'send-resolved' or 'load' — make sure we have the navigate result.
138
+ navigateResult = (outcome.kind === 'send-resolved') ? outcome.r : await navigatePromise;
139
+ }
140
+ } catch (err) {
141
+ clearTimeout(loadTimeout);
142
+ if (unsubLoad) unsubLoad();
143
+ if (unsubDialog) unsubDialog();
144
+ unsubConsole();
145
+ unsubFrameNav();
146
+ throw err;
147
+ }
148
+
149
+ if (dialogWon) {
150
+ clearTimeout(loadTimeout);
151
+ if (unsubLoad) unsubLoad();
152
+ if (unsubDialog) unsubDialog();
153
+ unsubConsole();
154
+ unsubFrameNav();
155
+ throw new DialogRefusedError({
156
+ dialog: dialogWon,
157
+ artifacts: renderSyntheticArtifacts(dialogWon),
158
+ });
159
+ }
160
+
161
+ if (unsubDialog) unsubDialog();
162
+
163
+ // CDP Page.navigate returns errorText when the host is unreachable (e.g. DNS
164
+ // failure, refused connection). The navigation "succeeded" at the protocol
165
+ // level but the page load failed — treat this as a hard error so the caller
166
+ // doesn't silently believe the page loaded.
167
+ if (navigateResult && navigateResult.errorText) {
168
+ clearTimeout(loadTimeout);
169
+ if (unsubLoad) unsubLoad();
170
+ unsubConsole();
171
+ unsubFrameNav();
172
+ throw new Error(`Navigate failed: ${navigateResult.errorText} (${url})`);
173
+ }
174
+
175
+ try {
176
+ await loadPromise;
177
+ } catch (err) {
178
+ unsubConsole();
179
+ unsubFrameNav();
180
+ throw err;
181
+ }
182
+
183
+ // Linger to catch trailing console output emitted during load event handlers.
184
+ if (autoCapture) {
185
+ await new Promise(r => setTimeout(r, CONSOLE_LINGER_MS));
186
+ }
187
+
188
+ unsubConsole();
189
+ unsubFrameNav();
190
+
191
+ if (autoCapture) {
192
+ try {
193
+ const artifacts = await capturePageArtifacts(tabIndexOrWsUrl, 'navigate');
194
+ // TODO: console logging is captured into state.consoleMessages above
195
+ // but the return value still placeholder-empty — the *WithCapture
196
+ // wrappers in capture.js have the same TODO.
197
+ const consoleLog = [];
198
+
199
+ return {
200
+ frameId: navigateResult?.frameId,
201
+ url,
202
+ pageSize: artifacts.pageSize,
203
+ capturePrefix: artifacts.capturePrefix,
204
+ sessionDir: artifacts.sessionDir,
205
+ files: artifacts.files,
206
+ domSummary: artifacts.domSummary,
207
+ consoleLog
208
+ };
209
+ } catch (error) {
210
+ // Auto-capture failed (e.g. screenshot failed) — return success
211
+ // with an error note so the navigation itself isn't reported as failed.
212
+ return {
213
+ frameId: navigateResult?.frameId,
214
+ url,
215
+ error: `Auto-capture failed: ${error.message}`
216
+ };
217
+ }
218
+ }
219
+
220
+ return navigateResult?.frameId;
221
+ }
222
+
223
+ async function waitForElement(tabIndexOrWsUrl, selector, timeout = 5000) {
224
+ const js = `
225
+ new Promise((resolve, reject) => {
226
+ const t = setTimeout(() => reject(new Error('waitForElement timeout: ' + ${JSON.stringify(selector)})), ${timeout});
227
+ const check = () => {
228
+ if (${getElementSelector(selector)}) {
229
+ clearTimeout(t);
230
+ resolve(true);
231
+ } else {
232
+ setTimeout(check, 100);
233
+ }
234
+ };
235
+ check();
236
+ })
237
+ `;
238
+ await evaluate(tabIndexOrWsUrl, js);
239
+ }
240
+
241
+ async function waitForText(tabIndexOrWsUrl, text, timeout = 5000) {
242
+ const js = `
243
+ new Promise((resolve, reject) => {
244
+ const t = setTimeout(() => reject(new Error('waitForText timeout: ' + ${JSON.stringify(text)})), ${timeout});
245
+ const check = () => {
246
+ if (document.body.textContent.includes(${JSON.stringify(text)})) {
247
+ clearTimeout(t);
248
+ resolve(true);
249
+ } else {
250
+ setTimeout(check, 100);
251
+ }
252
+ };
253
+ check();
254
+ })
255
+ `;
256
+ await evaluate(tabIndexOrWsUrl, js);
257
+ }
258
+
259
+ async function back(tabIndexOrWsUrl) {
260
+ const ps = await getPageSession(tabIndexOrWsUrl);
261
+ await ps.send('Runtime.evaluate', { expression: 'history.back()' });
262
+ }
263
+
264
+ async function forward(tabIndexOrWsUrl) {
265
+ const ps = await getPageSession(tabIndexOrWsUrl);
266
+ await ps.send('Runtime.evaluate', { expression: 'history.forward()' });
267
+ }
268
+
269
+ return { navigate, waitForElement, waitForText, back, forward };
270
+ }
271
+
272
+ module.exports = { attachNavigation };