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.
- package/.jscpd-baseline.json +7 -18
- package/CHANGELOG.md +48 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/scripts/version-and-commit.mjs +13 -2
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/scripts/check-release-format.test.js +4 -1
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- 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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
|
23
|
-
* @
|
|
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
|
|
123
|
+
const probe = await probeElementState(options);
|
|
36
124
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 (
|
|
84
|
-
//
|
|
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:
|
|
87
|
-
|
|
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
|
-
* @
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 (
|
|
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<
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
() =>
|
|
275
|
+
() =>
|
|
276
|
+
`⚠️ Click effect ${normalized.effect}: ${normalized.reason || 'unknown'}`
|
|
159
277
|
);
|
|
160
278
|
}
|
|
161
279
|
|
|
162
|
-
return
|
|
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 {
|
|
173
|
-
* @param {
|
|
174
|
-
* @param {
|
|
175
|
-
* @param {
|
|
176
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 {
|
|
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 (
|
|
237
|
-
|
|
238
|
-
'⚠️ Navigation/stop
|
|
516
|
+
if (isInterrupted(error)) {
|
|
517
|
+
log.debug(
|
|
518
|
+
() => '⚠️ Navigation/stop interrupted the click, recovering gracefully'
|
|
239
519
|
);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
reason:
|
|
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
|
|
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
|
|
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 {
|
|
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 } =
|
|
646
|
+
const { page, navigationManager, startUrl, startSessionId, actionId, log } =
|
|
647
|
+
options;
|
|
262
648
|
|
|
263
|
-
const
|
|
264
|
-
const urlChanged =
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
return { navigated: true, newUrl: currentUrl };
|
|
269
|
-
}
|
|
658
|
+
const correlated = urlChanged || sessionAdvanced;
|
|
659
|
+
const navigated = correlated || navigating;
|
|
270
660
|
|
|
271
|
-
if (
|
|
272
|
-
log.debug(
|
|
273
|
-
|
|
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 {
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
741
|
+
behavior,
|
|
742
|
+
waitAfterScroll,
|
|
743
|
+
verify: false, // The overall click result carries the verification.
|
|
378
744
|
});
|
|
379
745
|
|
|
380
|
-
if (!
|
|
381
|
-
|
|
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
|
-
*
|
|
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
|
|
407
|
-
* @param {Object} options.networkTracker - NetworkTracker instance
|
|
408
|
-
* @param {string} options.startUrl - URL before click
|
|
409
|
-
* @param {
|
|
410
|
-
* @param {
|
|
411
|
-
* @param {
|
|
412
|
-
* @
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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 {
|
|
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
|
|
550
|
-
*
|
|
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
|
|
559
|
-
* @param {Object} options.networkTracker - NetworkTracker instance
|
|
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 {
|
|
562
|
-
* @param {
|
|
563
|
-
* @param {
|
|
564
|
-
* @param {
|
|
565
|
-
* @param {
|
|
566
|
-
* @param {
|
|
567
|
-
* @param {number} options.
|
|
568
|
-
* @param {boolean} options.
|
|
569
|
-
* @param {
|
|
570
|
-
* @
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
* @throws {Error}
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
671
|
-
|
|
672
|
-
|
|
1055
|
+
if (isInterrupted(error)) {
|
|
1056
|
+
log.debug(
|
|
1057
|
+
() =>
|
|
1058
|
+
'⚠️ Navigation/stop detected during clickButton, recovering gracefully'
|
|
673
1059
|
);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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';
|