browser-commander 0.2.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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +296 -0
- package/.husky/pre-commit +1 -0
- package/.jscpd.json +20 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +24 -0
- package/README.md +320 -0
- package/bunfig.toml +3 -0
- package/deno.json +7 -0
- package/eslint.config.js +125 -0
- package/examples/react-test-app/index.html +25 -0
- package/examples/react-test-app/package.json +19 -0
- package/examples/react-test-app/src/App.jsx +473 -0
- package/examples/react-test-app/src/main.jsx +10 -0
- package/examples/react-test-app/src/styles.css +323 -0
- package/examples/react-test-app/vite.config.js +9 -0
- package/package.json +89 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +86 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +216 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/merge-changesets.mjs +260 -0
- package/scripts/publish-to-npm.mjs +126 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +262 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/ARCHITECTURE.md +270 -0
- package/src/README.md +517 -0
- package/src/bindings.js +298 -0
- package/src/browser/launcher.js +93 -0
- package/src/browser/navigation.js +513 -0
- package/src/core/constants.js +24 -0
- package/src/core/engine-adapter.js +466 -0
- package/src/core/engine-detection.js +49 -0
- package/src/core/logger.js +21 -0
- package/src/core/navigation-manager.js +503 -0
- package/src/core/navigation-safety.js +160 -0
- package/src/core/network-tracker.js +373 -0
- package/src/core/page-session.js +299 -0
- package/src/core/page-trigger-manager.js +564 -0
- package/src/core/preferences.js +46 -0
- package/src/elements/content.js +197 -0
- package/src/elements/locators.js +243 -0
- package/src/elements/selectors.js +360 -0
- package/src/elements/visibility.js +166 -0
- package/src/exports.js +121 -0
- package/src/factory.js +192 -0
- package/src/high-level/universal-logic.js +206 -0
- package/src/index.js +17 -0
- package/src/interactions/click.js +684 -0
- package/src/interactions/fill.js +383 -0
- package/src/interactions/scroll.js +341 -0
- package/src/utilities/url.js +33 -0
- package/src/utilities/wait.js +135 -0
- package/tests/e2e/playwright.e2e.test.js +442 -0
- package/tests/e2e/puppeteer.e2e.test.js +408 -0
- package/tests/helpers/mocks.js +542 -0
- package/tests/unit/bindings.test.js +218 -0
- package/tests/unit/browser/navigation.test.js +345 -0
- package/tests/unit/core/constants.test.js +72 -0
- package/tests/unit/core/engine-adapter.test.js +170 -0
- package/tests/unit/core/engine-detection.test.js +81 -0
- package/tests/unit/core/logger.test.js +80 -0
- package/tests/unit/core/navigation-safety.test.js +202 -0
- package/tests/unit/core/network-tracker.test.js +198 -0
- package/tests/unit/core/page-trigger-manager.test.js +358 -0
- package/tests/unit/elements/content.test.js +318 -0
- package/tests/unit/elements/locators.test.js +236 -0
- package/tests/unit/elements/selectors.test.js +302 -0
- package/tests/unit/elements/visibility.test.js +234 -0
- package/tests/unit/factory.test.js +174 -0
- package/tests/unit/high-level/universal-logic.test.js +299 -0
- package/tests/unit/interactions/click.test.js +340 -0
- package/tests/unit/interactions/fill.test.js +378 -0
- package/tests/unit/interactions/scroll.test.js +330 -0
- package/tests/unit/utilities/url.test.js +63 -0
- package/tests/unit/utilities/wait.test.js +207 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { TIMING } from '../core/constants.js';
|
|
2
|
+
import { isNavigationError } from '../core/navigation-safety.js';
|
|
3
|
+
import { isActionStoppedError } from '../core/page-trigger-manager.js';
|
|
4
|
+
import { waitForLocatorOrElement } from '../elements/locators.js';
|
|
5
|
+
import { scrollIntoViewIfNeeded } from './scroll.js';
|
|
6
|
+
import { logElementInfo } from '../elements/content.js';
|
|
7
|
+
import { createEngineAdapter } from '../core/engine-adapter.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
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
|
|
15
|
+
*
|
|
16
|
+
* Note: Navigation-triggering clicks are considered "verified" if navigation starts.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} options - Verification options
|
|
19
|
+
* @param {Object} options.page - Browser page object
|
|
20
|
+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
21
|
+
* @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}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function defaultClickVerification(options = {}) {
|
|
26
|
+
const {
|
|
27
|
+
page,
|
|
28
|
+
engine,
|
|
29
|
+
locatorOrElement,
|
|
30
|
+
preClickState = {},
|
|
31
|
+
adapter: providedAdapter,
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const adapter = providedAdapter || createEngineAdapter(page, engine);
|
|
36
|
+
|
|
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' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
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) {
|
|
74
|
+
return {
|
|
75
|
+
verified: true,
|
|
76
|
+
reason: 'element still connected (assumed success)',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Element was removed from DOM - likely click triggered UI change
|
|
81
|
+
return { verified: true, reason: 'element removed from DOM (UI updated)' };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (isNavigationError(error) || isActionStoppedError(error)) {
|
|
84
|
+
// Navigation/stop during verification - click likely triggered navigation
|
|
85
|
+
return {
|
|
86
|
+
verified: true,
|
|
87
|
+
reason: 'navigation detected (expected for navigation clicks)',
|
|
88
|
+
navigationError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Capture element state before click for verification
|
|
97
|
+
* @param {Object} options - Options
|
|
98
|
+
* @param {Object} options.page - Browser page object
|
|
99
|
+
* @param {string} options.engine - Engine type
|
|
100
|
+
* @param {Object} options.locatorOrElement - Element to capture state from
|
|
101
|
+
* @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
|
|
102
|
+
* @returns {Promise<Object>} - Pre-click state object
|
|
103
|
+
*/
|
|
104
|
+
export async function capturePreClickState(options = {}) {
|
|
105
|
+
const { page, engine, locatorOrElement, adapter: providedAdapter } = options;
|
|
106
|
+
|
|
107
|
+
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
|
+
}));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isNavigationError(error) || isActionStoppedError(error)) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify click operation
|
|
128
|
+
* @param {Object} options - Verification options
|
|
129
|
+
* @param {Object} options.page - Browser page object
|
|
130
|
+
* @param {string} options.engine - Engine type
|
|
131
|
+
* @param {Object} options.locatorOrElement - Element that was clicked
|
|
132
|
+
* @param {Object} options.preClickState - State captured before click
|
|
133
|
+
* @param {Function} options.verifyFn - Custom verification function (optional)
|
|
134
|
+
* @param {Function} options.log - Logger instance
|
|
135
|
+
* @returns {Promise<{verified: boolean, reason: string}>}
|
|
136
|
+
*/
|
|
137
|
+
export async function verifyClick(options = {}) {
|
|
138
|
+
const {
|
|
139
|
+
page,
|
|
140
|
+
engine,
|
|
141
|
+
locatorOrElement,
|
|
142
|
+
preClickState = {},
|
|
143
|
+
verifyFn = defaultClickVerification,
|
|
144
|
+
log = { debug: () => {} },
|
|
145
|
+
} = options;
|
|
146
|
+
|
|
147
|
+
const result = await verifyFn({
|
|
148
|
+
page,
|
|
149
|
+
engine,
|
|
150
|
+
locatorOrElement,
|
|
151
|
+
preClickState,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (result.verified) {
|
|
155
|
+
log.debug(() => `✅ Click verification passed: ${result.reason}`);
|
|
156
|
+
} else {
|
|
157
|
+
log.debug(
|
|
158
|
+
() => `⚠️ Click verification uncertain: ${result.reason || 'unknown'}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Click an element (low-level)
|
|
167
|
+
* @param {Object} options - Configuration options
|
|
168
|
+
* @param {Object} options.page - Browser page object (required for verification)
|
|
169
|
+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
170
|
+
* @param {Function} options.log - Logger instance
|
|
171
|
+
* @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<{clicked: boolean, verified: boolean, reason?: string}>}
|
|
177
|
+
*/
|
|
178
|
+
export async function clickElement(options = {}) {
|
|
179
|
+
const {
|
|
180
|
+
page,
|
|
181
|
+
engine,
|
|
182
|
+
log,
|
|
183
|
+
locatorOrElement,
|
|
184
|
+
noAutoScroll = false,
|
|
185
|
+
verify = true,
|
|
186
|
+
verifyFn,
|
|
187
|
+
adapter: providedAdapter,
|
|
188
|
+
} = options;
|
|
189
|
+
|
|
190
|
+
if (!locatorOrElement) {
|
|
191
|
+
throw new Error('locatorOrElement is required in options');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const adapter = providedAdapter || createEngineAdapter(page, engine);
|
|
196
|
+
|
|
197
|
+
// Capture pre-click state for verification
|
|
198
|
+
let preClickState = {};
|
|
199
|
+
if (verify && page) {
|
|
200
|
+
preClickState = await capturePreClickState({
|
|
201
|
+
page,
|
|
202
|
+
engine,
|
|
203
|
+
locatorOrElement,
|
|
204
|
+
adapter,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
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)`);
|
|
213
|
+
}
|
|
214
|
+
await adapter.click(locatorOrElement, clickOptions);
|
|
215
|
+
|
|
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,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
clicked: true,
|
|
229
|
+
verified: verificationResult.verified,
|
|
230
|
+
reason: verificationResult.reason,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { clicked: true, verified: true };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (isNavigationError(error) || isActionStoppedError(error)) {
|
|
237
|
+
console.log(
|
|
238
|
+
'⚠️ Navigation/stop detected during click, recovering gracefully'
|
|
239
|
+
);
|
|
240
|
+
// Navigation during click is considered verified (click triggered navigation)
|
|
241
|
+
return {
|
|
242
|
+
clicked: false,
|
|
243
|
+
verified: true,
|
|
244
|
+
reason: 'navigation during click',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Detect if a click caused navigation by checking URL change or navigation state
|
|
253
|
+
* @param {Object} options - Configuration options
|
|
254
|
+
* @param {Object} options.page - Browser page object
|
|
255
|
+
* @param {Object} options.navigationManager - NavigationManager instance (optional)
|
|
256
|
+
* @param {string} options.startUrl - URL before click
|
|
257
|
+
* @param {Function} options.log - Logger instance
|
|
258
|
+
* @returns {Promise<{navigated: boolean, newUrl: string}>}
|
|
259
|
+
*/
|
|
260
|
+
async function detectNavigation(options = {}) {
|
|
261
|
+
const { page, navigationManager, startUrl, log } = options;
|
|
262
|
+
|
|
263
|
+
const currentUrl = page.url();
|
|
264
|
+
const urlChanged = currentUrl !== startUrl;
|
|
265
|
+
|
|
266
|
+
if (navigationManager && navigationManager.isNavigating()) {
|
|
267
|
+
log.debug(() => '🔄 Navigation detected via NavigationManager');
|
|
268
|
+
return { navigated: true, newUrl: currentUrl };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (urlChanged) {
|
|
272
|
+
log.debug(() => `🔄 URL changed: ${startUrl} → ${currentUrl}`);
|
|
273
|
+
return { navigated: true, newUrl: currentUrl };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { navigated: false, newUrl: currentUrl };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prepare element for clicking - find, validate, and optionally scroll into view
|
|
281
|
+
* @param {Object} options - Configuration options
|
|
282
|
+
* @param {Object} options.page - Browser page object
|
|
283
|
+
* @param {string} options.engine - Engine type
|
|
284
|
+
* @param {Function} options.wait - Wait function
|
|
285
|
+
* @param {Function} options.log - Logger instance
|
|
286
|
+
* @param {boolean} options.verbose - Enable verbose logging
|
|
287
|
+
* @param {string|Object} options.selector - CSS selector, ElementHandle, or Playwright Locator
|
|
288
|
+
* @param {boolean} options.scrollIntoView - Scroll into view (default: true)
|
|
289
|
+
* @param {number} options.waitAfterScroll - Wait time after scroll in ms
|
|
290
|
+
* @param {boolean} options.smoothScroll - Use smooth scroll animation
|
|
291
|
+
* @param {number} options.timeout - Timeout in ms
|
|
292
|
+
* @returns {Promise<{locatorOrElement: Object, scrolled: boolean, navigated: boolean}>}
|
|
293
|
+
*/
|
|
294
|
+
async function prepareElement(options = {}) {
|
|
295
|
+
const {
|
|
296
|
+
page,
|
|
297
|
+
engine,
|
|
298
|
+
wait,
|
|
299
|
+
log,
|
|
300
|
+
verbose = false,
|
|
301
|
+
selector,
|
|
302
|
+
scrollIntoView: shouldScroll = true,
|
|
303
|
+
waitAfterScroll,
|
|
304
|
+
smoothScroll = true,
|
|
305
|
+
timeout,
|
|
306
|
+
} = options;
|
|
307
|
+
|
|
308
|
+
// Get locator/element and wait for it to be visible (unified for both engines)
|
|
309
|
+
const locatorOrElement = await waitForLocatorOrElement({
|
|
310
|
+
page,
|
|
311
|
+
engine,
|
|
312
|
+
selector,
|
|
313
|
+
timeout,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Log element info if verbose
|
|
317
|
+
if (verbose) {
|
|
318
|
+
await logElementInfo({ page, engine, log, locatorOrElement });
|
|
319
|
+
}
|
|
320
|
+
|
|
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)`);
|
|
341
|
+
return { locatorOrElement, scrolled: false, navigated: false };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
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<{clicked: boolean, verified: boolean, reason?: string, navigated: boolean}>}
|
|
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({
|
|
371
|
+
page,
|
|
372
|
+
engine,
|
|
373
|
+
log,
|
|
374
|
+
locatorOrElement,
|
|
375
|
+
noAutoScroll,
|
|
376
|
+
verify,
|
|
377
|
+
verifyFn,
|
|
378
|
+
});
|
|
379
|
+
|
|
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
|
+
};
|
|
388
|
+
}
|
|
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
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Handle navigation detection and waiting after a click
|
|
402
|
+
* @param {Object} options - Configuration options
|
|
403
|
+
* @param {Object} options.page - Browser page object
|
|
404
|
+
* @param {Function} options.wait - Wait function
|
|
405
|
+
* @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}>}
|
|
413
|
+
*/
|
|
414
|
+
async function handleNavigationAfterClick(options = {}) {
|
|
415
|
+
const {
|
|
416
|
+
page,
|
|
417
|
+
wait,
|
|
418
|
+
log,
|
|
419
|
+
navigationManager,
|
|
420
|
+
networkTracker,
|
|
421
|
+
startUrl,
|
|
422
|
+
waitForNavigation = true,
|
|
423
|
+
navigationCheckDelay = 500,
|
|
424
|
+
waitAfterClick = 1000,
|
|
425
|
+
} = options;
|
|
426
|
+
|
|
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({
|
|
437
|
+
page,
|
|
438
|
+
navigationManager,
|
|
439
|
+
startUrl,
|
|
440
|
+
log,
|
|
441
|
+
});
|
|
442
|
+
|
|
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
|
+
}
|
|
465
|
+
|
|
466
|
+
// Navigation is considered successful verification
|
|
467
|
+
return {
|
|
468
|
+
navigated: true,
|
|
469
|
+
verified: true,
|
|
470
|
+
reason: 'click triggered navigation',
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// No navigation - wait after click if specified (useful for modals)
|
|
476
|
+
if (waitAfterClick > 0) {
|
|
477
|
+
const waitResult = await wait({
|
|
478
|
+
ms: waitAfterClick,
|
|
479
|
+
reason: 'post-click settling time for modal scroll capture',
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Check if wait was aborted due to navigation that happened during the wait
|
|
483
|
+
if (waitResult && waitResult.aborted) {
|
|
484
|
+
log.debug(
|
|
485
|
+
() => '🔄 Navigation detected during post-click wait (wait was aborted)'
|
|
486
|
+
);
|
|
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
|
+
|
|
508
|
+
return {
|
|
509
|
+
navigated: true,
|
|
510
|
+
verified: true,
|
|
511
|
+
reason: 'late-detected navigation',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Final check: did navigation happen while we were processing?
|
|
518
|
+
// This catches cases where navigation started but wasn't detected earlier
|
|
519
|
+
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
|
+
|
|
529
|
+
return {
|
|
530
|
+
navigated: true,
|
|
531
|
+
verified: true,
|
|
532
|
+
reason: 'abort-signal navigation',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
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
|
+
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
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return { navigated: false, verified: true, reason: 'no navigation detected' };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
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.
|
|
551
|
+
*
|
|
552
|
+
* @param {Object} options - Configuration options
|
|
553
|
+
* @param {Object} options.page - Browser page object
|
|
554
|
+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
555
|
+
* @param {Function} options.wait - Wait function
|
|
556
|
+
* @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)
|
|
560
|
+
* @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<{clicked: boolean, navigated: boolean, verified: boolean, reason?: string}>}
|
|
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)
|
|
575
|
+
*/
|
|
576
|
+
export async function clickButton(options = {}) {
|
|
577
|
+
const {
|
|
578
|
+
page,
|
|
579
|
+
engine,
|
|
580
|
+
wait,
|
|
581
|
+
log,
|
|
582
|
+
verbose = false,
|
|
583
|
+
navigationManager,
|
|
584
|
+
networkTracker,
|
|
585
|
+
selector,
|
|
586
|
+
scrollIntoView: shouldScroll = true,
|
|
587
|
+
waitAfterScroll = TIMING.DEFAULT_WAIT_AFTER_SCROLL,
|
|
588
|
+
smoothScroll = true,
|
|
589
|
+
waitAfterClick = 1000,
|
|
590
|
+
waitForNavigation = true,
|
|
591
|
+
navigationCheckDelay = 500,
|
|
592
|
+
timeout = TIMING.DEFAULT_TIMEOUT,
|
|
593
|
+
verify = true,
|
|
594
|
+
verifyFn,
|
|
595
|
+
} = options;
|
|
596
|
+
|
|
597
|
+
if (!selector) {
|
|
598
|
+
throw new Error('clickButton: selector is required in options');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Record URL before click for navigation detection
|
|
602
|
+
const startUrl = page.url();
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
// Step 1: Prepare element (find, validate, scroll into view)
|
|
606
|
+
const prepareResult = await prepareElement({
|
|
607
|
+
page,
|
|
608
|
+
engine,
|
|
609
|
+
wait,
|
|
610
|
+
log,
|
|
611
|
+
verbose,
|
|
612
|
+
selector,
|
|
613
|
+
scrollIntoView: shouldScroll,
|
|
614
|
+
waitAfterScroll,
|
|
615
|
+
smoothScroll,
|
|
616
|
+
timeout,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
if (prepareResult.navigated) {
|
|
620
|
+
return {
|
|
621
|
+
clicked: false,
|
|
622
|
+
navigated: true,
|
|
623
|
+
verified: true,
|
|
624
|
+
reason: 'navigation during scroll',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const { locatorOrElement } = prepareResult;
|
|
629
|
+
|
|
630
|
+
// Step 2: Execute click operation
|
|
631
|
+
const clickResult = await executeClick({
|
|
632
|
+
page,
|
|
633
|
+
engine,
|
|
634
|
+
log,
|
|
635
|
+
locatorOrElement,
|
|
636
|
+
noAutoScroll: !shouldScroll,
|
|
637
|
+
verify,
|
|
638
|
+
verifyFn,
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
if (clickResult.navigated) {
|
|
642
|
+
return {
|
|
643
|
+
clicked: false,
|
|
644
|
+
navigated: true,
|
|
645
|
+
verified: true,
|
|
646
|
+
reason: 'navigation during click',
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Step 3: Handle navigation detection and waiting
|
|
651
|
+
const navResult = await handleNavigationAfterClick({
|
|
652
|
+
page,
|
|
653
|
+
wait,
|
|
654
|
+
log,
|
|
655
|
+
navigationManager,
|
|
656
|
+
networkTracker,
|
|
657
|
+
startUrl,
|
|
658
|
+
waitForNavigation,
|
|
659
|
+
navigationCheckDelay,
|
|
660
|
+
waitAfterClick,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
clicked: true,
|
|
665
|
+
navigated: navResult.navigated,
|
|
666
|
+
verified: clickResult.verified && navResult.verified,
|
|
667
|
+
reason: navResult.navigated ? navResult.reason : clickResult.reason,
|
|
668
|
+
};
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (isNavigationError(error) || isActionStoppedError(error)) {
|
|
671
|
+
console.log(
|
|
672
|
+
'⚠️ Navigation/stop detected during clickButton, recovering gracefully'
|
|
673
|
+
);
|
|
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
|
+
};
|
|
681
|
+
}
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|