browser-commander 0.17.1 → 0.18.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 (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. package/tests/unit/traces/viewer.test.js +289 -0
@@ -5,87 +5,185 @@ import { waitForLocatorOrElement } from '../elements/locators.js';
5
5
  import { scrollIntoViewIfNeeded } from './scroll.js';
6
6
  import { logElementInfo } from '../elements/content.js';
7
7
  import { createEngineAdapter } from '../core/engine-adapter.js';
8
+ import { runWithinDeadline } from '../core/readiness.js';
9
+ import {
10
+ CLICK_EFFECT,
11
+ CLICK_STATUS,
12
+ evidence,
13
+ makeClickResult,
14
+ nextActionId,
15
+ } from './click-result.js';
16
+ import {
17
+ CLICK_SCROLL,
18
+ ScrollConstraintError,
19
+ beginClickAction,
20
+ dispatchClick,
21
+ } from './click-activation.js';
8
22
 
9
23
  /**
10
- * Default verification function for click operations.
11
- * Verifies that the click had an effect by checking for common patterns:
12
- * - Element state changes (disabled, aria-pressed, etc.)
13
- * - Element class changes
14
- * - Element visibility changes
24
+ * Whether an error means the page moved out from under the action rather than
25
+ * the action itself failing.
15
26
  *
16
- * Note: Navigation-triggering clicks are considered "verified" if navigation starts.
27
+ * @param {Error} error - Error thrown while acting on the page
28
+ * @returns {boolean} True when the action was interrupted, not broken
29
+ */
30
+ function isInterrupted(error) {
31
+ return isNavigationError(error) || isActionStoppedError(error);
32
+ }
33
+
34
+ /**
35
+ * Read the target element's observable state under the click's own budget.
36
+ *
37
+ * The engine's locator timeout is far longer than a click's - Playwright waits
38
+ * 30 seconds for an element a navigation already took away - so the budget the
39
+ * caller asked for has to bound the probe.
40
+ *
41
+ * @param {Object} options - Probe options
42
+ * @param {Object} options.page - Browser page object
43
+ * @param {string} options.engine - Engine type
44
+ * @param {Object} options.locatorOrElement - Element to read
45
+ * @param {Object} [options.adapter] - Engine adapter
46
+ * @param {Object} [options.deadline] - Deadline the whole click shares
47
+ * @returns {Promise<{timedOut: boolean, value: Object}>} Element state, or expiry
48
+ */
49
+ function probeElementState(options) {
50
+ const { page, engine, locatorOrElement, adapter, deadline } = options;
51
+ const resolved = adapter || createEngineAdapter(page, engine);
52
+
53
+ return runWithinDeadline(deadline, () =>
54
+ resolved.evaluateOnElement(locatorOrElement, ELEMENT_STATE_PROBE)
55
+ );
56
+ }
57
+
58
+ /**
59
+ * A verdict for verification that ran out of budget before it saw anything.
60
+ *
61
+ * Nothing was observed, so nothing is claimed - only that time, rather than
62
+ * the click, is what ended the attempt.
63
+ *
64
+ * @param {Object} [deadline] - Deadline the click shared
65
+ * @param {string} what - What the budget expired before, e.g. 'the element could be read'
66
+ * @returns {Object} Verification verdict
67
+ */
68
+ function timedOutVerdict(deadline, what) {
69
+ return {
70
+ verified: false,
71
+ timedOut: true,
72
+ effect: CLICK_EFFECT.NOT_OBSERVED,
73
+ reason: `verification budget expired before ${what}`,
74
+ evidence: [
75
+ evidence('verification-timeout', {
76
+ timeoutMs: deadline?.timeoutMs,
77
+ elapsedMs: deadline?.elapsedMs(),
78
+ }),
79
+ ],
80
+ };
81
+ }
82
+
83
+ const ELEMENT_STATE_PROBE = (el) => ({
84
+ disabled: el.disabled,
85
+ ariaPressed: el.getAttribute('aria-pressed'),
86
+ ariaExpanded: el.getAttribute('aria-expanded'),
87
+ ariaSelected: el.getAttribute('aria-selected'),
88
+ checked: el.checked,
89
+ className: el.className,
90
+ isConnected: el.isConnected,
91
+ });
92
+
93
+ const OBSERVED_STATE_KEYS = [
94
+ ['ariaPressed', 'aria-pressed changed'],
95
+ ['ariaExpanded', 'aria-expanded changed'],
96
+ ['ariaSelected', 'aria-selected changed'],
97
+ ['checked', 'checked state changed'],
98
+ ['className', 'className changed'],
99
+ ['disabled', 'disabled state changed'],
100
+ ];
101
+
102
+ /**
103
+ * Default verification for click operations.
104
+ *
105
+ * This reports what it observed and nothing more. In particular, an element
106
+ * that is still present and unchanged is *not* evidence that the click did
107
+ * anything - that is precisely the case of a button whose handler is missing
108
+ * or threw - so it is reported as `not-observed` rather than as success.
17
109
  *
18
110
  * @param {Object} options - Verification options
19
111
  * @param {Object} options.page - Browser page object
20
112
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
21
113
  * @param {Object} options.locatorOrElement - Element that was clicked
22
- * @param {Object} options.preClickState - State captured before click (optional)
23
- * @returns {Promise<{verified: boolean, reason: string}>}
114
+ * @param {Object} [options.preClickState] - State captured before the click
115
+ * @param {Object} [options.adapter] - Engine adapter
116
+ * @param {Object} [options.deadline] - Deadline the whole click shares
117
+ * @returns {Promise<{verified: boolean, effect: string, reason: string, evidence: Array, navigationError: (boolean|undefined), timedOut: (boolean|undefined)}>} Verification outcome
24
118
  */
25
119
  export async function defaultClickVerification(options = {}) {
26
- const {
27
- page,
28
- engine,
29
- locatorOrElement,
30
- preClickState = {},
31
- adapter: providedAdapter,
32
- } = options;
120
+ const { preClickState = {}, deadline } = options;
33
121
 
34
122
  try {
35
- const adapter = providedAdapter || createEngineAdapter(page, engine);
123
+ const probe = await probeElementState(options);
36
124
 
37
- // Get current element state
38
- const getElementState = async () =>
39
- await adapter.evaluateOnElement(locatorOrElement, (el) => ({
40
- disabled: el.disabled,
41
- ariaPressed: el.getAttribute('aria-pressed'),
42
- ariaExpanded: el.getAttribute('aria-expanded'),
43
- ariaSelected: el.getAttribute('aria-selected'),
44
- checked: el.checked,
45
- className: el.className,
46
- isConnected: el.isConnected,
47
- }));
48
-
49
- const postClickState = await getElementState();
50
-
51
- // If we have pre-click state, check for changes
52
- if (preClickState && Object.keys(preClickState).length > 0) {
53
- // Check for state changes that indicate click was processed
54
- if (preClickState.ariaPressed !== postClickState.ariaPressed) {
55
- return { verified: true, reason: 'aria-pressed changed' };
56
- }
57
- if (preClickState.ariaExpanded !== postClickState.ariaExpanded) {
58
- return { verified: true, reason: 'aria-expanded changed' };
59
- }
60
- if (preClickState.ariaSelected !== postClickState.ariaSelected) {
61
- return { verified: true, reason: 'aria-selected changed' };
62
- }
63
- if (preClickState.checked !== postClickState.checked) {
64
- return { verified: true, reason: 'checked state changed' };
65
- }
66
- if (preClickState.className !== postClickState.className) {
67
- return { verified: true, reason: 'className changed' };
125
+ if (probe.timedOut) {
126
+ return timedOutVerdict(deadline, 'the element could be read');
127
+ }
128
+
129
+ const postClickState = probe.value;
130
+
131
+ const hasPreState = preClickState && Object.keys(preClickState).length > 0;
132
+
133
+ if (hasPreState) {
134
+ for (const [key, reason] of OBSERVED_STATE_KEYS) {
135
+ if (preClickState[key] !== postClickState[key]) {
136
+ return {
137
+ verified: true,
138
+ effect: CLICK_EFFECT.CONFIRMED,
139
+ reason,
140
+ evidence: [
141
+ evidence('element-state', {
142
+ key,
143
+ before: preClickState[key],
144
+ after: postClickState[key],
145
+ }),
146
+ ],
147
+ };
148
+ }
68
149
  }
69
150
  }
70
151
 
71
- // If element is still connected and not disabled, assume click worked
72
- // (many clicks don't change element state - they trigger actions)
73
- if (postClickState.isConnected) {
152
+ if (!postClickState.isConnected) {
153
+ // Detachment is a real, observable change in the document.
74
154
  return {
75
155
  verified: true,
76
- reason: 'element still connected (assumed success)',
156
+ effect: CLICK_EFFECT.CONFIRMED,
157
+ reason: 'element removed from DOM (UI updated)',
158
+ evidence: [
159
+ evidence('element-state', { key: 'isConnected', after: false }),
160
+ ],
77
161
  };
78
162
  }
79
163
 
80
- // Element was removed from DOM - likely click triggered UI change
81
- return { verified: true, reason: 'element removed from DOM (UI updated)' };
164
+ return {
165
+ verified: false,
166
+ effect: CLICK_EFFECT.NOT_OBSERVED,
167
+ reason: hasPreState
168
+ ? 'no observable change to the target element after the click'
169
+ : 'no pre-click state captured, so no change could be observed',
170
+ evidence: [evidence('element-state', { unchanged: true, hasPreState })],
171
+ };
82
172
  } catch (error) {
83
- if (isNavigationError(error) || isActionStoppedError(error)) {
84
- // Navigation/stop during verification - click likely triggered navigation
173
+ if (isInterrupted(error)) {
174
+ // The execution context went away. That is consistent with the click
175
+ // having navigated the page, but it is equally consistent with an
176
+ // unrelated navigation already in flight. Verification is simply
177
+ // unavailable; correlation is the caller's job.
85
178
  return {
86
- verified: true,
87
- reason: 'navigation detected (expected for navigation clicks)',
179
+ verified: false,
180
+ effect: CLICK_EFFECT.NOT_OBSERVED,
181
+ reason:
182
+ 'verification unavailable: execution context was destroyed during verification',
88
183
  navigationError: true,
184
+ evidence: [
185
+ evidence('verification-unavailable', { message: error.message }),
186
+ ],
89
187
  };
90
188
  }
91
189
  throw error;
@@ -99,24 +197,17 @@ export async function defaultClickVerification(options = {}) {
99
197
  * @param {string} options.engine - Engine type
100
198
  * @param {Object} options.locatorOrElement - Element to capture state from
101
199
  * @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
102
- * @returns {Promise<Object>} - Pre-click state object
200
+ * @param {Object} [options.deadline] - Deadline the whole click shares
201
+ * @returns {Promise<Object>} - Pre-click state object, empty when it could not be read in time
103
202
  */
104
203
  export async function capturePreClickState(options = {}) {
105
- const { page, engine, locatorOrElement, adapter: providedAdapter } = options;
106
-
107
204
  try {
108
- const adapter = providedAdapter || createEngineAdapter(page, engine);
109
- return await adapter.evaluateOnElement(locatorOrElement, (el) => ({
110
- disabled: el.disabled,
111
- ariaPressed: el.getAttribute('aria-pressed'),
112
- ariaExpanded: el.getAttribute('aria-expanded'),
113
- ariaSelected: el.getAttribute('aria-selected'),
114
- checked: el.checked,
115
- className: el.className,
116
- isConnected: el.isConnected,
117
- }));
205
+ const probe = await probeElementState(options);
206
+ // A state that could not be read in time is no state at all; the click
207
+ // then has nothing to compare against and says so rather than guessing.
208
+ return probe.timedOut ? {} : probe.value;
118
209
  } catch (error) {
119
- if (isNavigationError(error) || isActionStoppedError(error)) {
210
+ if (isInterrupted(error)) {
120
211
  return {};
121
212
  }
122
213
  throw error;
@@ -131,8 +222,10 @@ export async function capturePreClickState(options = {}) {
131
222
  * @param {Object} options.locatorOrElement - Element that was clicked
132
223
  * @param {Object} options.preClickState - State captured before click
133
224
  * @param {Function} options.verifyFn - Custom verification function (optional)
225
+ * @param {Object} options.adapter - Engine adapter (optional)
226
+ * @param {Object} [options.deadline] - Deadline the whole click shares
134
227
  * @param {Function} options.log - Logger instance
135
- * @returns {Promise<{verified: boolean, reason: string}>}
228
+ * @returns {Promise<Object>} Verification outcome with `effect` and `evidence`
136
229
  */
137
230
  export async function verifyClick(options = {}) {
138
231
  const {
@@ -141,60 +234,105 @@ export async function verifyClick(options = {}) {
141
234
  locatorOrElement,
142
235
  preClickState = {},
143
236
  verifyFn = defaultClickVerification,
237
+ adapter,
238
+ deadline,
144
239
  log = { debug: () => {} },
145
240
  } = options;
146
241
 
147
- const result = await verifyFn({
148
- page,
149
- engine,
150
- locatorOrElement,
151
- preClickState,
152
- });
242
+ // Custom verifiers are free to ignore the deadline, so bound them too -
243
+ // otherwise one verifier that never returns outlasts the whole budget.
244
+ const outcome = await runWithinDeadline(deadline, () =>
245
+ verifyFn({
246
+ page,
247
+ engine,
248
+ locatorOrElement,
249
+ preClickState,
250
+ adapter,
251
+ deadline,
252
+ })
253
+ );
254
+
255
+ if (outcome.timedOut) {
256
+ log.debug(() => '⚠️ Click verification ran out of time');
257
+ return timedOutVerdict(deadline, 'an effect could be observed');
258
+ }
153
259
 
154
- if (result.verified) {
155
- log.debug(() => `✅ Click verification passed: ${result.reason}`);
260
+ const result = outcome.value;
261
+
262
+ // Custom verifiers predate the effect vocabulary, so map their boolean.
263
+ const normalized = {
264
+ ...result,
265
+ effect:
266
+ result.effect ??
267
+ (result.verified ? CLICK_EFFECT.CONFIRMED : CLICK_EFFECT.NOT_OBSERVED),
268
+ evidence: result.evidence ?? [],
269
+ };
270
+
271
+ if (normalized.effect === CLICK_EFFECT.CONFIRMED) {
272
+ log.debug(() => `✅ Click effect confirmed: ${normalized.reason}`);
156
273
  } else {
157
274
  log.debug(
158
- () => `⚠️ Click verification uncertain: ${result.reason || 'unknown'}`
275
+ () =>
276
+ `⚠️ Click effect ${normalized.effect}: ${normalized.reason || 'unknown'}`
159
277
  );
160
278
  }
161
279
 
162
- return result;
280
+ return normalized;
163
281
  }
164
282
 
165
283
  /**
166
- * Click an element (low-level)
284
+ * Click an element (low-level).
285
+ *
167
286
  * @param {Object} options - Configuration options
168
287
  * @param {Object} options.page - Browser page object (required for verification)
169
288
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
170
289
  * @param {Function} options.log - Logger instance
171
290
  * @param {Object} options.locatorOrElement - Element or locator to click
172
- * @param {boolean} options.noAutoScroll - Prevent Playwright's automatic scrolling (default: false)
173
- * @param {boolean} options.verify - Whether to verify the click operation (default: true)
174
- * @param {Function} options.verifyFn - Custom verification function (optional)
175
- * @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
176
- * @returns {Promise<Object>} Click result.
291
+ * @param {string} [options.activation='pointer'] - 'pointer' (real input) or 'dom' (untrusted `el.click()`)
292
+ * @param {string} [options.scroll='auto'] - 'auto', 'preserve' (restore position) or 'none' (never scroll)
293
+ * @param {string} [options.actionability='normal'] - 'normal' or 'force' (skip engine pre-checks)
294
+ * @param {boolean} [options.noAutoScroll] - Deprecated alias for `scroll: 'none'`
295
+ * @param {boolean} [options.verify=true] - Whether to verify the click effect
296
+ * @param {Function} [options.verifyFn] - Custom verification function
297
+ * @param {Object} [options.adapter] - Engine adapter
298
+ * @param {string} [options.actionId] - Correlation ID for this click
299
+ * @param {number} [options.timeout] - Budget for the whole click, verification included
300
+ * @returns {Promise<Object>} Click result from {@link makeClickResult}
177
301
  */
178
302
  export async function clickElement(options = {}) {
179
303
  const {
180
304
  page,
181
305
  engine,
182
- log,
306
+ log = { debug: () => {} },
183
307
  locatorOrElement,
184
- noAutoScroll = false,
308
+ activation,
309
+ scroll,
310
+ actionability,
311
+ noAutoScroll,
185
312
  verify = true,
186
313
  verifyFn,
187
314
  adapter: providedAdapter,
315
+ actionId = nextActionId(),
316
+ timeout,
188
317
  } = options;
189
318
 
190
319
  if (!locatorOrElement) {
191
320
  throw new Error('locatorOrElement is required in options');
192
321
  }
193
322
 
323
+ const { deadline, elapsed, activationOptions } = beginClickAction({
324
+ activation,
325
+ scroll,
326
+ actionability,
327
+ noAutoScroll,
328
+ timeout,
329
+ log,
330
+ });
331
+
194
332
  try {
195
333
  const adapter = providedAdapter || createEngineAdapter(page, engine);
334
+ const urlBeforeDispatch = readPageUrl(page);
196
335
 
197
- // Capture pre-click state for verification
198
336
  let preClickState = {};
199
337
  if (verify && page) {
200
338
  preClickState = await capturePreClickState({
@@ -202,78 +340,350 @@ export async function clickElement(options = {}) {
202
340
  engine,
203
341
  locatorOrElement,
204
342
  adapter,
343
+ deadline,
205
344
  });
206
345
  }
207
346
 
208
- // Click with appropriate options
209
- const clickOptions =
210
- engine === 'playwright' && noAutoScroll ? { force: true } : {};
211
- if (engine === 'playwright' && noAutoScroll) {
212
- log.debug(() => `🔍 [VERBOSE] Clicking with noAutoScroll (force: true)`);
347
+ /**
348
+ * Describe a click the page never received.
349
+ *
350
+ * @param {string} status - Status to report
351
+ * @param {string} reason - Why the click did not happen
352
+ * @param {Object} record - Evidence for that reason
353
+ * @returns {Object} Click result
354
+ */
355
+ const neverDispatched = (status, reason, record) =>
356
+ makeClickResult({
357
+ status,
358
+ dispatched: false,
359
+ effect: CLICK_EFFECT.NOT_OBSERVED,
360
+ reason,
361
+ evidence: [record],
362
+ elapsedMs: elapsed(),
363
+ actionId,
364
+ });
365
+
366
+ let dispatch;
367
+ try {
368
+ // Dispatch is bounded too: locating a click point and reading the scroll
369
+ // position are engine round-trips, and an unbounded one spends the budget
370
+ // the caller reserved for the whole click.
371
+ const dispatching = await runWithinDeadline(deadline, () =>
372
+ dispatchClick({
373
+ page,
374
+ adapter,
375
+ locatorOrElement,
376
+ activationOptions,
377
+ log,
378
+ })
379
+ );
380
+
381
+ if (dispatching.timedOut) {
382
+ return neverDispatched(
383
+ CLICK_STATUS.TIMED_OUT,
384
+ 'click budget expired before the click could be dispatched',
385
+ evidence('dispatch-timeout', {
386
+ timeoutMs: deadline?.timeoutMs,
387
+ elapsedMs: deadline?.elapsedMs(),
388
+ })
389
+ );
390
+ }
391
+
392
+ dispatch = dispatching.value;
393
+ } catch (error) {
394
+ if (error instanceof ScrollConstraintError) {
395
+ // The caller asked for no scrolling and we cannot deliver the click
396
+ // without it. Say so instead of scrolling behind their back.
397
+ return neverDispatched(
398
+ CLICK_STATUS.FAILED,
399
+ error.message,
400
+ evidence('scroll-constraint', error.detail)
401
+ );
402
+ }
403
+ throw error;
213
404
  }
214
- await adapter.click(locatorOrElement, clickOptions);
215
405
 
216
- // Verify click if requested
217
- if (verify && page) {
218
- const verificationResult = await verifyClick({
219
- page,
220
- engine,
221
- locatorOrElement,
222
- preClickState,
223
- verifyFn,
224
- log,
406
+ const dispatchEvidence = [
407
+ evidence('dispatch', {
408
+ mode: dispatch.mode,
409
+ activation: activationOptions.activation,
410
+ scroll: activationOptions.scroll,
411
+ actionability: activationOptions.actionability,
412
+ scrollBefore: dispatch.scrollBefore,
413
+ scrollAfter: dispatch.scrollAfter,
414
+ scrollChanged: scrollChanged(dispatch),
415
+ point: dispatch.point,
416
+ }),
417
+ ];
418
+
419
+ if (
420
+ activationOptions.scroll !== CLICK_SCROLL.AUTO &&
421
+ scrollChanged(dispatch)
422
+ ) {
423
+ // We did not scroll, but the page reacted by scrolling itself. Record it
424
+ // so callers asserting on viewport stability can see what moved.
425
+ dispatchEvidence.push(
426
+ evidence('page-scrolled-itself', {
427
+ before: dispatch.scrollBefore,
428
+ after: dispatch.scrollAfter,
429
+ })
430
+ );
431
+ }
432
+
433
+ if (!verify || !page) {
434
+ return makeClickResult({
435
+ status: CLICK_STATUS.UNVERIFIED,
436
+ dispatched: true,
437
+ effect: CLICK_EFFECT.NOT_OBSERVED,
438
+ reason: 'click dispatched; verification not requested',
439
+ evidence: dispatchEvidence,
440
+ elapsedMs: elapsed(),
441
+ actionId,
225
442
  });
443
+ }
226
444
 
227
- return {
228
- clicked: true,
229
- verified: verificationResult.verified,
230
- reason: verificationResult.reason,
231
- };
445
+ // A navigation replaces the document the element lived in, and engines
446
+ // answer an element probe on the *new* document instead of failing - which
447
+ // is how a navigating click used to spend the whole budget waiting for an
448
+ // element that no longer exists. Watching the URL ends the wait at once.
449
+ const watch = watchForNavigation(page, urlBeforeDispatch);
450
+ const verifying = verifyClick({
451
+ page,
452
+ engine,
453
+ locatorOrElement,
454
+ preClickState,
455
+ verifyFn,
456
+ adapter,
457
+ deadline,
458
+ log,
459
+ });
460
+ // Verification that loses the race still settles on its own schedule, so
461
+ // its failure is absorbed here rather than surfacing later as an unhandled
462
+ // rejection in a process that has long since moved on.
463
+ verifying.catch(() => {});
464
+
465
+ let verification;
466
+ try {
467
+ verification = await Promise.race([verifying, watch.navigated]);
468
+ } finally {
469
+ watch.stop();
470
+ }
471
+
472
+ const confirmed = verification.effect === CLICK_EFFECT.CONFIRMED;
473
+ const navigatedAway =
474
+ verification.navigatedTo ?? pageNavigatedSince(page, urlBeforeDispatch);
475
+
476
+ if (navigatedAway && !confirmed) {
477
+ // The document the element belonged to is gone, so there is nothing left
478
+ // to observe. The navigation is recorded as what it is - a correlated
479
+ // event - and *not* as proof that this click caused it.
480
+ return makeClickResult({
481
+ status: CLICK_STATUS.UNVERIFIED,
482
+ dispatched: true,
483
+ effect: CLICK_EFFECT.NOT_OBSERVED,
484
+ reason:
485
+ 'the page navigated after the click, so the click effect could not be observed on the original document',
486
+ evidence: [
487
+ ...dispatchEvidence,
488
+ ...verification.evidence,
489
+ evidence('navigation', {
490
+ actionId,
491
+ from: urlBeforeDispatch,
492
+ to: navigatedAway,
493
+ correlated: true,
494
+ provesClickEffect: false,
495
+ }),
496
+ ],
497
+ elapsedMs: elapsed(),
498
+ actionId,
499
+ navigationError: true,
500
+ });
232
501
  }
233
502
 
234
- return { clicked: true, verified: true };
503
+ return makeClickResult({
504
+ // The click was delivered either way; only the observation ran out of
505
+ // time, and `timed_out` says exactly that rather than blaming the click.
506
+ status: verificationStatus(confirmed, verification.timedOut),
507
+ dispatched: true,
508
+ effect: verification.effect,
509
+ reason: verification.reason,
510
+ evidence: [...dispatchEvidence, ...verification.evidence],
511
+ elapsedMs: elapsed(),
512
+ actionId,
513
+ ...(verification.navigationError ? { navigationError: true } : {}),
514
+ });
235
515
  } catch (error) {
236
- if (isNavigationError(error) || isActionStoppedError(error)) {
237
- console.log(
238
- '⚠️ Navigation/stop detected during click, recovering gracefully'
516
+ if (isInterrupted(error)) {
517
+ log.debug(
518
+ () => '⚠️ Navigation/stop interrupted the click, recovering gracefully'
239
519
  );
240
- // Navigation during click is considered verified (click triggered navigation)
241
- return {
242
- clicked: false,
243
- verified: true,
244
- reason: 'navigation during click',
245
- };
520
+ return makeClickResult({
521
+ status: CLICK_STATUS.INTERRUPTED,
522
+ dispatched: false,
523
+ effect: CLICK_EFFECT.NOT_OBSERVED,
524
+ reason:
525
+ 'navigation or stop interrupted the click before it could be observed',
526
+ evidence: [evidence('interrupted', { message: error.message })],
527
+ elapsedMs: elapsed(),
528
+ actionId,
529
+ });
246
530
  }
247
531
  throw error;
248
532
  }
249
533
  }
250
534
 
535
+ /** How often the navigation watch samples the page URL, in milliseconds. */
536
+ const NAVIGATION_POLL_INTERVAL = 50;
537
+
538
+ /**
539
+ * Watch for the page leaving the document a click was dispatched into.
540
+ *
541
+ * The returned promise settles only on navigation, so it can lose a race
542
+ * against verification without ever forcing a wait of its own.
543
+ *
544
+ * @param {Object} page - Browser page object
545
+ * @param {string|null} urlBefore - URL read immediately before dispatch
546
+ * @returns {{navigated: Promise<Object>, stop: Function}} Watch handle
547
+ */
548
+ function watchForNavigation(page, urlBefore) {
549
+ let timer;
550
+ const stop = () => clearInterval(timer);
551
+
552
+ const navigated = new Promise((resolve) => {
553
+ if (!urlBefore) {
554
+ return;
555
+ }
556
+ timer = setInterval(() => {
557
+ const to = pageNavigatedSince(page, urlBefore);
558
+ if (to) {
559
+ stop();
560
+ resolve({
561
+ effect: CLICK_EFFECT.NOT_OBSERVED,
562
+ evidence: [],
563
+ navigatedTo: to,
564
+ });
565
+ }
566
+ }, NAVIGATION_POLL_INTERVAL);
567
+ // The watch must never keep the process alive on its own.
568
+ timer.unref?.();
569
+ });
570
+
571
+ return { navigated, stop };
572
+ }
573
+
574
+ /**
575
+ * Read the page's current URL, tolerating pages that cannot answer.
576
+ *
577
+ * @param {Object} page - Browser page object, possibly a mock without `url`
578
+ * @returns {string|null} Current URL, or null when it is unavailable
579
+ */
580
+ function readPageUrl(page) {
581
+ if (typeof page?.url !== 'function') {
582
+ return null;
583
+ }
584
+ try {
585
+ return page.url();
586
+ } catch {
587
+ // A closed or crashed page cannot report a URL; that is not a click error.
588
+ return null;
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Whether the page left the document the click was dispatched into.
594
+ *
595
+ * @param {Object} page - Browser page object
596
+ * @param {string|null} urlBefore - URL read immediately before dispatch
597
+ * @returns {string|null} The new URL when it changed, otherwise null
598
+ */
599
+ function pageNavigatedSince(page, urlBefore) {
600
+ if (!urlBefore) {
601
+ return null;
602
+ }
603
+ const now = readPageUrl(page);
604
+ return now && now !== urlBefore ? now : null;
605
+ }
606
+
607
+ /**
608
+ * Status for a dispatched click, given what verification managed to observe.
609
+ *
610
+ * @param {boolean} confirmed - Whether an effect was actually observed
611
+ * @param {boolean} [timedOut] - Whether verification ran out of budget
612
+ * @returns {string} One of {@link CLICK_STATUS}
613
+ */
614
+ function verificationStatus(confirmed, timedOut) {
615
+ if (confirmed) {
616
+ return CLICK_STATUS.SUCCEEDED;
617
+ }
618
+ return timedOut ? CLICK_STATUS.TIMED_OUT : CLICK_STATUS.UNVERIFIED;
619
+ }
620
+
621
+ function scrollChanged(dispatch) {
622
+ const { scrollBefore, scrollAfter } = dispatch;
623
+ if (!scrollBefore || !scrollAfter) {
624
+ return null;
625
+ }
626
+ return scrollBefore.x !== scrollAfter.x || scrollBefore.y !== scrollAfter.y;
627
+ }
628
+
251
629
  /**
252
- * Detect if a click caused navigation by checking URL change or navigation state
630
+ * Detect whether navigation can be attributed to a specific click.
631
+ *
632
+ * A navigation that was already in flight before the click is not evidence
633
+ * about the click, so the navigation session recorded before dispatch is
634
+ * compared against the current one instead of merely asking "are we navigating".
635
+ *
253
636
  * @param {Object} options - Configuration options
254
637
  * @param {Object} options.page - Browser page object
255
- * @param {Object} options.navigationManager - NavigationManager instance (optional)
256
- * @param {string} options.startUrl - URL before click
638
+ * @param {Object} [options.navigationManager] - NavigationManager instance
639
+ * @param {string} options.startUrl - URL before the click
640
+ * @param {number} [options.startSessionId] - Navigation session ID before the click
641
+ * @param {string} [options.actionId] - Correlation ID for the click
257
642
  * @param {Function} options.log - Logger instance
258
- * @returns {Promise<{navigated: boolean, newUrl: string}>}
643
+ * @returns {{navigated: boolean, correlated: boolean, newUrl: string, evidence: Array}} Detection outcome
259
644
  */
260
645
  function detectNavigation(options = {}) {
261
- const { page, navigationManager, startUrl, log } = options;
646
+ const { page, navigationManager, startUrl, startSessionId, actionId, log } =
647
+ options;
262
648
 
263
- const currentUrl = page.url();
264
- const urlChanged = currentUrl !== startUrl;
649
+ const newUrl = page.url();
650
+ const urlChanged = newUrl !== startUrl;
651
+ const sessionId = navigationManager?.getSessionId?.();
652
+ const sessionAdvanced =
653
+ startSessionId !== undefined &&
654
+ sessionId !== undefined &&
655
+ sessionId > startSessionId;
656
+ const navigating = Boolean(navigationManager?.isNavigating?.());
265
657
 
266
- if (navigationManager && navigationManager.isNavigating()) {
267
- log.debug(() => '🔄 Navigation detected via NavigationManager');
268
- return { navigated: true, newUrl: currentUrl };
269
- }
658
+ const correlated = urlChanged || sessionAdvanced;
659
+ const navigated = correlated || navigating;
270
660
 
271
- if (urlChanged) {
272
- log.debug(() => `🔄 URL changed: ${startUrl} → ${currentUrl}`);
273
- return { navigated: true, newUrl: currentUrl };
661
+ if (navigated) {
662
+ log.debug(
663
+ () =>
664
+ `🔄 Navigation ${correlated ? 'correlated with' : 'in flight but NOT correlated with'} ` +
665
+ `click ${actionId}: ${startUrl} → ${newUrl}`
666
+ );
274
667
  }
275
668
 
276
- return { navigated: false, newUrl: currentUrl };
669
+ return {
670
+ navigated,
671
+ correlated,
672
+ newUrl,
673
+ evidence: [
674
+ evidence('navigation', {
675
+ actionId,
676
+ startUrl,
677
+ newUrl,
678
+ urlChanged,
679
+ startSessionId,
680
+ sessionId,
681
+ sessionAdvanced,
682
+ navigating,
683
+ correlated,
684
+ }),
685
+ ],
686
+ };
277
687
  }
278
688
 
279
689
  /**
@@ -289,7 +699,7 @@ function detectNavigation(options = {}) {
289
699
  * @param {number} options.waitAfterScroll - Wait time after scroll in ms
290
700
  * @param {boolean} options.smoothScroll - Use smooth scroll animation
291
701
  * @param {number} options.timeout - Timeout in ms
292
- * @returns {Promise<{locatorOrElement: Object, scrolled: boolean, navigated: boolean}>}
702
+ * @returns {Promise<{locatorOrElement: Object, scrolled: boolean, navigated: boolean}>} Preparation outcome
293
703
  */
294
704
  async function prepareElement(options = {}) {
295
705
  const {
@@ -305,7 +715,6 @@ async function prepareElement(options = {}) {
305
715
  timeout,
306
716
  } = options;
307
717
 
308
- // Get locator/element and wait for it to be visible (unified for both engines)
309
718
  const locatorOrElement = await waitForLocatorOrElement({
310
719
  page,
311
720
  engine,
@@ -313,103 +722,50 @@ async function prepareElement(options = {}) {
313
722
  timeout,
314
723
  });
315
724
 
316
- // Log element info if verbose
317
725
  if (verbose) {
318
726
  await logElementInfo({ page, engine, log, locatorOrElement });
319
727
  }
320
728
 
321
- // Scroll into view (if requested and needed)
322
- if (shouldScroll) {
323
- const behavior = smoothScroll ? 'smooth' : 'instant';
324
- const scrollResult = await scrollIntoViewIfNeeded({
325
- page,
326
- engine,
327
- wait,
328
- log,
329
- locatorOrElement,
330
- behavior,
331
- waitAfterScroll,
332
- verify: false, // Don't verify scroll here, we verify the overall click
333
- });
334
- // Check if scroll was aborted due to navigation/stop
335
- if (!scrollResult.skipped && !scrollResult.scrolled) {
336
- return { locatorOrElement: null, scrolled: false, navigated: true };
337
- }
338
- return { locatorOrElement, scrolled: true, navigated: false };
339
- } else {
340
- log.debug(() => `🔍 [VERBOSE] Skipping scroll (scrollIntoView: false)`);
729
+ if (!shouldScroll) {
730
+ log.debug(() => '🔍 [VERBOSE] Skipping scroll (scroll policy forbids it)');
341
731
  return { locatorOrElement, scrolled: false, navigated: false };
342
732
  }
343
- }
344
733
 
345
- /**
346
- * Execute the click operation with verification
347
- * @param {Object} options - Configuration options
348
- * @param {Object} options.page - Browser page object
349
- * @param {string} options.engine - Engine type
350
- * @param {Function} options.log - Logger instance
351
- * @param {Object} options.locatorOrElement - Element or locator to click
352
- * @param {boolean} options.noAutoScroll - Prevent Playwright's automatic scrolling
353
- * @param {boolean} options.verify - Whether to verify the click operation
354
- * @param {Function} options.verifyFn - Custom verification function (optional)
355
- * @returns {Promise<Object>} Click result.
356
- */
357
- async function executeClick(options = {}) {
358
- const {
359
- page,
360
- engine,
361
- log,
362
- locatorOrElement,
363
- noAutoScroll = false,
364
- verify = true,
365
- verifyFn,
366
- } = options;
367
-
368
- log.debug(() => `🔍 [VERBOSE] About to click element`);
369
-
370
- const clickResult = await clickElement({
734
+ const behavior = smoothScroll ? 'smooth' : 'instant';
735
+ const scrollResult = await scrollIntoViewIfNeeded({
371
736
  page,
372
737
  engine,
738
+ wait,
373
739
  log,
374
740
  locatorOrElement,
375
- noAutoScroll,
376
- verify,
377
- verifyFn,
741
+ behavior,
742
+ waitAfterScroll,
743
+ verify: false, // The overall click result carries the verification.
378
744
  });
379
745
 
380
- if (!clickResult.clicked) {
381
- // Navigation/stop occurred during click itself
382
- return {
383
- clicked: false,
384
- verified: true,
385
- navigated: true,
386
- reason: 'navigation during click',
387
- };
746
+ if (!scrollResult.skipped && !scrollResult.scrolled) {
747
+ return { locatorOrElement: null, scrolled: false, navigated: true };
388
748
  }
389
-
390
- log.debug(() => `🔍 [VERBOSE] Click completed`);
391
-
392
- return {
393
- clicked: true,
394
- verified: clickResult.verified,
395
- navigated: false,
396
- reason: clickResult.reason,
397
- };
749
+ return { locatorOrElement, scrolled: true, navigated: false };
398
750
  }
399
751
 
400
752
  /**
401
- * Handle navigation detection and waiting after a click
753
+ * Wait for the page to settle after a click and report navigation evidence.
754
+ *
402
755
  * @param {Object} options - Configuration options
403
756
  * @param {Object} options.page - Browser page object
404
757
  * @param {Function} options.wait - Wait function
405
758
  * @param {Function} options.log - Logger instance
406
- * @param {Object} options.navigationManager - NavigationManager instance (optional)
407
- * @param {Object} options.networkTracker - NetworkTracker instance (optional)
408
- * @param {string} options.startUrl - URL before click
409
- * @param {boolean} options.waitForNavigation - Wait for navigation to complete
410
- * @param {number} options.navigationCheckDelay - Time to check if navigation started
411
- * @param {number} options.waitAfterClick - Wait time after click in ms
412
- * @returns {Promise<{navigated: boolean, verified: boolean, reason: string}>}
759
+ * @param {Object} [options.navigationManager] - NavigationManager instance
760
+ * @param {Object} [options.networkTracker] - NetworkTracker instance
761
+ * @param {string} options.startUrl - URL before the click
762
+ * @param {number} [options.startSessionId] - Navigation session ID before the click
763
+ * @param {string} [options.actionId] - Correlation ID for the click
764
+ * @param {boolean} [options.waitForNavigation=true] - Whether to look for navigation
765
+ * @param {number} [options.navigationCheckDelay=500] - Grace period for navigation to start
766
+ * @param {number} [options.waitAfterClick=1000] - Settling time when nothing navigated
767
+ * @param {number} [options.navigationReadyTimeout] - Budget for the post-navigation readiness wait
768
+ * @returns {Promise<{navigated: boolean, correlated: boolean, ready: boolean|null, reason: string, evidence: Array}>} Navigation outcome
413
769
  */
414
770
  async function handleNavigationAfterClick(options = {}) {
415
771
  const {
@@ -419,171 +775,176 @@ async function handleNavigationAfterClick(options = {}) {
419
775
  navigationManager,
420
776
  networkTracker,
421
777
  startUrl,
778
+ startSessionId,
779
+ actionId,
422
780
  waitForNavigation = true,
423
781
  navigationCheckDelay = 500,
424
782
  waitAfterClick = 1000,
783
+ navigationReadyTimeout = TIMING.NAVIGATION_TIMEOUT,
425
784
  } = options;
426
785
 
427
- // Check if click caused navigation
428
- if (waitForNavigation) {
429
- // Wait briefly for navigation to potentially start
430
- await wait({
431
- ms: navigationCheckDelay,
432
- reason: 'checking for navigation after click',
433
- });
434
-
435
- // Detect if navigation occurred
436
- const { navigated, newUrl } = await detectNavigation({
786
+ const detect = () =>
787
+ detectNavigation({
437
788
  page,
438
789
  navigationManager,
439
790
  startUrl,
791
+ startSessionId,
792
+ actionId,
440
793
  log,
441
794
  });
442
795
 
443
- if (navigated) {
444
- log.debug(() => `🔄 Click triggered navigation to: ${newUrl}`);
445
-
446
- // Wait for page to be fully ready (network idle + no more redirects)
447
- // Note: If navigationManager detected external navigation, it's already waiting
448
- // We still call waitForPageReady here to ensure we don't return until page is ready
449
- if (navigationManager) {
450
- // Use longer timeout (120s) for full page loads after click-triggered navigation
451
- await navigationManager.waitForPageReady({
452
- timeout: 120000,
453
- reason: 'after click navigation',
454
- });
455
- } else if (networkTracker) {
456
- // Without navigation manager, use network tracker directly with 30s idle time
457
- await networkTracker.waitForNetworkIdle({
458
- timeout: 120000,
459
- // idleTime defaults to 30000ms from tracker config
460
- });
461
- } else {
462
- // Fallback: wait a bit for page to settle
463
- await wait({ ms: 2000, reason: 'page settle after navigation' });
464
- }
796
+ const settle = async (reason) => {
797
+ if (navigationManager) {
798
+ return navigationManager.waitForPageReady({
799
+ timeout: navigationReadyTimeout,
800
+ reason,
801
+ });
802
+ }
803
+ if (networkTracker) {
804
+ return networkTracker.waitForNetworkIdle({
805
+ timeout: navigationReadyTimeout,
806
+ });
807
+ }
808
+ await wait({ ms: 2000, reason: 'page settle after navigation' });
809
+ return null;
810
+ };
465
811
 
466
- // Navigation is considered successful verification
812
+ if (waitForNavigation) {
813
+ await wait({
814
+ ms: navigationCheckDelay,
815
+ reason: 'checking for navigation after click',
816
+ });
817
+
818
+ const detection = detect();
819
+ if (detection.navigated) {
820
+ const ready = await settle('after click navigation');
467
821
  return {
468
822
  navigated: true,
469
- verified: true,
470
- reason: 'click triggered navigation',
823
+ correlated: detection.correlated,
824
+ ready,
825
+ reason: detection.correlated
826
+ ? 'click triggered navigation'
827
+ : 'navigation observed but not attributable to this click',
828
+ evidence: [...detection.evidence, evidence('readiness', { ready })],
471
829
  };
472
830
  }
473
831
  }
474
832
 
475
- // No navigation - wait after click if specified (useful for modals)
476
833
  if (waitAfterClick > 0) {
477
834
  const waitResult = await wait({
478
835
  ms: waitAfterClick,
479
836
  reason: 'post-click settling time for modal scroll capture',
480
837
  });
481
838
 
482
- // Check if wait was aborted due to navigation that happened during the wait
483
839
  if (waitResult && waitResult.aborted) {
484
840
  log.debug(
485
841
  () => '🔄 Navigation detected during post-click wait (wait was aborted)'
486
842
  );
487
-
488
- // Re-check for navigation since it happened during the wait
489
- const { navigated: lateNavigated, newUrl: lateUrl } =
490
- await detectNavigation({
491
- page,
492
- navigationManager,
493
- startUrl,
494
- log,
495
- });
496
-
497
- if (lateNavigated) {
498
- log.debug(() => `🔄 Confirmed late navigation to: ${lateUrl}`);
499
-
500
- // Wait for page to be fully ready
501
- if (navigationManager) {
502
- await navigationManager.waitForPageReady({
503
- timeout: 120000,
504
- reason: 'after late-detected click navigation',
505
- });
506
- }
507
-
843
+ const lateDetection = detect();
844
+ if (lateDetection.navigated) {
845
+ const ready = await settle('after late-detected click navigation');
508
846
  return {
509
847
  navigated: true,
510
- verified: true,
511
- reason: 'late-detected navigation',
848
+ correlated: lateDetection.correlated,
849
+ ready,
850
+ reason: lateDetection.correlated
851
+ ? 'late-detected navigation'
852
+ : 'late navigation observed but not attributable to this click',
853
+ evidence: [
854
+ ...lateDetection.evidence,
855
+ evidence('readiness', { ready }),
856
+ ],
512
857
  };
513
858
  }
514
859
  }
515
860
  }
516
861
 
517
- // Final check: did navigation happen while we were processing?
518
- // This catches cases where navigation started but wasn't detected earlier
519
862
  if (navigationManager && navigationManager.shouldAbort()) {
520
- log.debug(
521
- () => '🔄 Navigation detected via abort signal at end of click processing'
522
- );
523
-
524
- await navigationManager.waitForPageReady({
525
- timeout: 120000,
526
- reason: 'after abort-detected click navigation',
527
- });
528
-
863
+ const abortDetection = detect();
864
+ const ready = await settle('after abort-detected click navigation');
529
865
  return {
530
866
  navigated: true,
531
- verified: true,
532
- reason: 'abort-signal navigation',
867
+ correlated: abortDetection.correlated,
868
+ ready,
869
+ reason: abortDetection.correlated
870
+ ? 'abort-signal navigation'
871
+ : 'abort signal raised by a navigation not attributable to this click',
872
+ evidence: [...abortDetection.evidence, evidence('readiness', { ready })],
533
873
  };
534
874
  }
535
875
 
536
- // If we have network tracking, wait for any XHR/fetch to complete
537
- // Use shorter idle time for non-navigation clicks (just waiting for XHR, not full page load)
538
876
  if (networkTracker) {
539
- await networkTracker.waitForNetworkIdle({
540
- timeout: 10000, // Maximum wait time
541
- idleTime: 2000, // Only 2 seconds of idle needed for XHR completion
542
- });
877
+ await networkTracker.waitForNetworkIdle({ timeout: 10000, idleTime: 2000 });
543
878
  }
544
879
 
545
- return { navigated: false, verified: true, reason: 'no navigation detected' };
880
+ return {
881
+ navigated: false,
882
+ correlated: false,
883
+ ready: null,
884
+ reason: 'no navigation detected',
885
+ evidence: detect().evidence,
886
+ };
887
+ }
888
+
889
+ function combineEffects(clickResult, navResult) {
890
+ if (navResult.correlated) {
891
+ return CLICK_EFFECT.CONFIRMED;
892
+ }
893
+ return clickResult.effect;
894
+ }
895
+
896
+ function combineStatus(clickResult, navResult) {
897
+ if (navResult.correlated) {
898
+ // Navigation attributable to this click also answers "did it do anything".
899
+ return navResult.ready === false
900
+ ? CLICK_STATUS.TIMED_OUT
901
+ : CLICK_STATUS.SUCCEEDED;
902
+ }
903
+ return clickResult.status;
546
904
  }
547
905
 
548
906
  /**
549
- * Click a button or element (high-level with scrolling and waits)
550
- * Now navigation-aware - automatically waits for page ready after navigation-causing clicks.
907
+ * Click a button or element (high-level with scrolling, waits and navigation
908
+ * correlation).
551
909
  *
552
910
  * @param {Object} options - Configuration options
553
911
  * @param {Object} options.page - Browser page object
554
912
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
555
913
  * @param {Function} options.wait - Wait function
556
914
  * @param {Function} options.log - Logger instance
557
- * @param {boolean} options.verbose - Enable verbose logging
558
- * @param {Object} options.navigationManager - NavigationManager instance (optional)
559
- * @param {Object} options.networkTracker - NetworkTracker instance (optional)
915
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
916
+ * @param {Object} [options.navigationManager] - NavigationManager instance
917
+ * @param {Object} [options.networkTracker] - NetworkTracker instance
560
918
  * @param {string|Object} options.selector - CSS selector, ElementHandle, or Playwright Locator
561
- * @param {boolean} options.scrollIntoView - Scroll into view (default: true)
562
- * @param {number} options.waitAfterScroll - Wait time after scroll in ms (default: TIMING.DEFAULT_WAIT_AFTER_SCROLL)
563
- * @param {boolean} options.smoothScroll - Use smooth scroll animation (default: true)
564
- * @param {number} options.waitAfterClick - Wait time after click in ms (default: 1000). Gives modals time to capture scroll position before opening
565
- * @param {boolean} options.waitForNavigation - Wait for navigation to complete if click causes navigation (default: true)
566
- * @param {number} options.navigationCheckDelay - Time to check if navigation started (default: 500ms)
567
- * @param {number} options.timeout - Timeout in ms (default: TIMING.DEFAULT_TIMEOUT)
568
- * @param {boolean} options.verify - Whether to verify the click operation (default: true)
569
- * @param {Function} options.verifyFn - Custom verification function (optional)
570
- * @returns {Promise<Object>} Click result.
571
- * - clicked: true if click was performed
572
- * - navigated: true if click caused navigation
573
- * - verified: true if click was verified (navigation counts as verification)
574
- * @throws {Error} - If selector is missing, element not found, or click operation fails (except navigation/stop errors)
919
+ * @param {string} [options.activation='pointer'] - 'pointer' or 'dom'
920
+ * @param {string} [options.scroll] - 'auto', 'preserve' or 'none'
921
+ * @param {string} [options.actionability='normal'] - 'normal' or 'force'
922
+ * @param {boolean} [options.scrollIntoView=true] - Deprecated alias; `false` means `scroll: 'none'`
923
+ * @param {number} [options.waitAfterScroll] - Wait time after scroll in ms
924
+ * @param {boolean} [options.smoothScroll=true] - Use smooth scroll animation
925
+ * @param {number} [options.waitAfterClick=1000] - Wait time after click in ms
926
+ * @param {boolean} [options.waitForNavigation=true] - Wait for navigation to complete
927
+ * @param {number} [options.navigationCheckDelay=500] - Time to check if navigation started
928
+ * @param {number} [options.timeout] - Element lookup timeout in ms
929
+ * @param {boolean} [options.verify=true] - Whether to verify the click effect
930
+ * @param {Function} [options.verifyFn] - Custom verification function
931
+ * @returns {Promise<Object>} Click result from {@link makeClickResult}
932
+ * @throws {Error} If selector is missing or the click fails for a non-navigation reason
575
933
  */
576
934
  export async function clickButton(options = {}) {
577
935
  const {
578
936
  page,
579
937
  engine,
580
938
  wait,
581
- log,
939
+ log = { debug: () => {} },
582
940
  verbose = false,
583
941
  navigationManager,
584
942
  networkTracker,
585
943
  selector,
586
- scrollIntoView: shouldScroll = true,
944
+ activation,
945
+ scroll,
946
+ actionability,
947
+ scrollIntoView,
587
948
  waitAfterScroll = TIMING.DEFAULT_WAIT_AFTER_SCROLL,
588
949
  smoothScroll = true,
589
950
  waitAfterClick = 1000,
@@ -598,11 +959,22 @@ export async function clickButton(options = {}) {
598
959
  throw new Error('clickButton: selector is required in options');
599
960
  }
600
961
 
601
- // Record URL before click for navigation detection
962
+ const actionId = nextActionId();
963
+ // The same budget covers locating, scrolling, clicking and verifying, so a
964
+ // button click cannot quietly cost several times the timeout asked for.
965
+ const { deadline, elapsed, activationOptions } = beginClickAction({
966
+ activation,
967
+ scroll,
968
+ actionability,
969
+ noAutoScroll: scrollIntoView === undefined ? undefined : !scrollIntoView,
970
+ timeout,
971
+ log,
972
+ });
973
+
602
974
  const startUrl = page.url();
975
+ const startSessionId = navigationManager?.getSessionId?.();
603
976
 
604
977
  try {
605
- // Step 1: Prepare element (find, validate, scroll into view)
606
978
  const prepareResult = await prepareElement({
607
979
  page,
608
980
  engine,
@@ -610,44 +982,51 @@ export async function clickButton(options = {}) {
610
982
  log,
611
983
  verbose,
612
984
  selector,
613
- scrollIntoView: shouldScroll,
985
+ scrollIntoView: activationOptions.scroll === CLICK_SCROLL.AUTO,
614
986
  waitAfterScroll,
615
987
  smoothScroll,
616
988
  timeout,
617
989
  });
618
990
 
619
991
  if (prepareResult.navigated) {
620
- return {
621
- clicked: false,
622
- navigated: true,
623
- verified: true,
624
- reason: 'navigation during scroll',
625
- };
992
+ const detection = detectNavigation({
993
+ page,
994
+ navigationManager,
995
+ startUrl,
996
+ startSessionId,
997
+ actionId,
998
+ log,
999
+ });
1000
+ return makeClickResult({
1001
+ status: CLICK_STATUS.INTERRUPTED,
1002
+ dispatched: false,
1003
+ effect: CLICK_EFFECT.NOT_OBSERVED,
1004
+ navigated: detection.navigated,
1005
+ reason: 'navigation interrupted the scroll before the click',
1006
+ evidence: detection.evidence,
1007
+ elapsedMs: elapsed(),
1008
+ actionId,
1009
+ });
626
1010
  }
627
1011
 
628
- const { locatorOrElement } = prepareResult;
629
-
630
- // Step 2: Execute click operation
631
- const clickResult = await executeClick({
1012
+ const clickResult = await clickElement({
632
1013
  page,
633
1014
  engine,
634
1015
  log,
635
- locatorOrElement,
636
- noAutoScroll: !shouldScroll,
1016
+ locatorOrElement: prepareResult.locatorOrElement,
1017
+ activation: activationOptions.activation,
1018
+ scroll: activationOptions.scroll,
1019
+ actionability: activationOptions.actionability,
637
1020
  verify,
638
1021
  verifyFn,
1022
+ actionId,
1023
+ timeout: deadline.remainingMs(),
639
1024
  });
640
1025
 
641
- if (clickResult.navigated) {
642
- return {
643
- clicked: false,
644
- navigated: true,
645
- verified: true,
646
- reason: 'navigation during click',
647
- };
1026
+ if (clickResult.status === CLICK_STATUS.FAILED) {
1027
+ return { ...clickResult, elapsedMs: elapsed() };
648
1028
  }
649
1029
 
650
- // Step 3: Handle navigation detection and waiting
651
1030
  const navResult = await handleNavigationAfterClick({
652
1031
  page,
653
1032
  wait,
@@ -655,30 +1034,65 @@ export async function clickButton(options = {}) {
655
1034
  navigationManager,
656
1035
  networkTracker,
657
1036
  startUrl,
1037
+ startSessionId,
1038
+ actionId,
658
1039
  waitForNavigation,
659
1040
  navigationCheckDelay,
660
1041
  waitAfterClick,
661
1042
  });
662
1043
 
663
- return {
664
- clicked: true,
1044
+ return makeClickResult({
1045
+ status: combineStatus(clickResult, navResult),
1046
+ dispatched: clickResult.dispatched,
1047
+ effect: combineEffects(clickResult, navResult),
665
1048
  navigated: navResult.navigated,
666
- verified: clickResult.verified && navResult.verified,
667
1049
  reason: navResult.navigated ? navResult.reason : clickResult.reason,
668
- };
1050
+ evidence: [...clickResult.evidence, ...navResult.evidence],
1051
+ elapsedMs: elapsed(),
1052
+ actionId,
1053
+ });
669
1054
  } catch (error) {
670
- if (isNavigationError(error) || isActionStoppedError(error)) {
671
- console.log(
672
- '⚠️ Navigation/stop detected during clickButton, recovering gracefully'
1055
+ if (isInterrupted(error)) {
1056
+ log.debug(
1057
+ () =>
1058
+ '⚠️ Navigation/stop detected during clickButton, recovering gracefully'
673
1059
  );
674
- // Navigation/stop during click is considered successful
675
- return {
676
- clicked: false,
677
- navigated: true,
678
- verified: true,
679
- reason: 'navigation/stop error',
680
- };
1060
+ const detection = detectNavigation({
1061
+ page,
1062
+ navigationManager,
1063
+ startUrl,
1064
+ startSessionId,
1065
+ actionId,
1066
+ log,
1067
+ });
1068
+ return makeClickResult({
1069
+ status: detection.correlated
1070
+ ? CLICK_STATUS.SUCCEEDED
1071
+ : CLICK_STATUS.INTERRUPTED,
1072
+ dispatched: detection.correlated,
1073
+ effect: detection.correlated
1074
+ ? CLICK_EFFECT.CONFIRMED
1075
+ : CLICK_EFFECT.NOT_OBSERVED,
1076
+ navigated: detection.navigated,
1077
+ reason: detection.correlated
1078
+ ? 'click triggered navigation (observed while recovering from a navigation error)'
1079
+ : 'navigation or stop error interrupted the click; effect not attributable',
1080
+ evidence: [
1081
+ ...detection.evidence,
1082
+ evidence('interrupted', { message: error.message }),
1083
+ ],
1084
+ elapsedMs: elapsed(),
1085
+ actionId,
1086
+ });
681
1087
  }
682
1088
  throw error;
683
1089
  }
684
1090
  }
1091
+
1092
+ export { CLICK_EFFECT, CLICK_STATUS } from './click-result.js';
1093
+ export {
1094
+ CLICK_ACTIONABILITY,
1095
+ CLICK_ACTIVATION,
1096
+ CLICK_SCROLL,
1097
+ ScrollConstraintError,
1098
+ } from './click-activation.js';