browser-commander 0.27.0 → 0.28.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/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/connector.js +117 -13
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +51 -0
- package/src/core/page-trigger-manager.js +115 -30
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +2 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +28 -0
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -170,7 +170,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// Registered triggers
|
|
173
|
-
const triggers = []
|
|
173
|
+
const triggers = [],
|
|
174
|
+
subscriptions = [];
|
|
174
175
|
|
|
175
176
|
// Current action state
|
|
176
177
|
let currentTrigger = null;
|
|
@@ -178,8 +179,9 @@ export function createPageTriggerManager(options = {}) {
|
|
|
178
179
|
let actionPromise = null;
|
|
179
180
|
let actionStopPromise = null;
|
|
180
181
|
let actionStopResolve = null;
|
|
181
|
-
let isActionRunning = false
|
|
182
|
-
|
|
182
|
+
let isActionRunning = false,
|
|
183
|
+
isStopping = false;
|
|
184
|
+
let startQueue = Promise.resolve();
|
|
183
185
|
|
|
184
186
|
/**
|
|
185
187
|
* Register a page trigger
|
|
@@ -191,7 +193,24 @@ export function createPageTriggerManager(options = {}) {
|
|
|
191
193
|
* @returns {Function} - Unregister function
|
|
192
194
|
*/
|
|
193
195
|
function pageTrigger(config) {
|
|
194
|
-
const {
|
|
196
|
+
const {
|
|
197
|
+
condition,
|
|
198
|
+
action,
|
|
199
|
+
name = 'unnamed',
|
|
200
|
+
priority = 0,
|
|
201
|
+
readyOn = 'networkidle',
|
|
202
|
+
concurrency = 'skip',
|
|
203
|
+
} = config;
|
|
204
|
+
if (
|
|
205
|
+
!['urlchange', 'domcontentloaded', 'load', 'networkidle'].includes(
|
|
206
|
+
readyOn
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
throw new RangeError(`Unknown trigger readyOn: ${readyOn}`);
|
|
210
|
+
}
|
|
211
|
+
if (!['skip', 'restart'].includes(concurrency)) {
|
|
212
|
+
throw new RangeError(`Unknown trigger concurrency: ${concurrency}`);
|
|
213
|
+
}
|
|
195
214
|
|
|
196
215
|
if (typeof condition !== 'function') {
|
|
197
216
|
throw new Error('condition must be a function');
|
|
@@ -205,6 +224,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
205
224
|
action,
|
|
206
225
|
name,
|
|
207
226
|
priority,
|
|
227
|
+
readyOn,
|
|
228
|
+
concurrency,
|
|
208
229
|
};
|
|
209
230
|
|
|
210
231
|
triggers.push(triggerConfig);
|
|
@@ -234,7 +255,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
234
255
|
function findMatchingTrigger(ctx) {
|
|
235
256
|
for (const config of triggers) {
|
|
236
257
|
try {
|
|
237
|
-
if (config.condition(ctx)) {
|
|
258
|
+
if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
|
|
238
259
|
return config;
|
|
239
260
|
}
|
|
240
261
|
} catch (e) {
|
|
@@ -278,8 +299,9 @@ export function createPageTriggerManager(options = {}) {
|
|
|
278
299
|
|
|
279
300
|
// Wait for action to finish (with timeout)
|
|
280
301
|
const timeoutMs = 10000; // 10 second max wait
|
|
302
|
+
let stopTimer;
|
|
281
303
|
const timeoutPromise = new Promise((resolve) => {
|
|
282
|
-
setTimeout(() => {
|
|
304
|
+
stopTimer = setTimeout(() => {
|
|
283
305
|
log.debug(
|
|
284
306
|
() =>
|
|
285
307
|
`⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
|
|
@@ -289,6 +311,16 @@ export function createPageTriggerManager(options = {}) {
|
|
|
289
311
|
});
|
|
290
312
|
|
|
291
313
|
await Promise.race([actionPromise, timeoutPromise]);
|
|
314
|
+
clearTimeout(stopTimer);
|
|
315
|
+
// An action can ignore cancellation. Keep it in flight until it settles,
|
|
316
|
+
// so a second form handler can never race it after the grace period.
|
|
317
|
+
if (isActionRunning) {
|
|
318
|
+
isStopping = false;
|
|
319
|
+
actionStopResolve?.();
|
|
320
|
+
actionStopPromise = null;
|
|
321
|
+
actionStopResolve = null;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
292
324
|
|
|
293
325
|
// Cleanup
|
|
294
326
|
isActionRunning = false;
|
|
@@ -312,11 +344,12 @@ export function createPageTriggerManager(options = {}) {
|
|
|
312
344
|
* @param {string} url - URL to start action for
|
|
313
345
|
* @param {Object} commander - BrowserCommander instance
|
|
314
346
|
*/
|
|
315
|
-
function startAction(url, commander) {
|
|
347
|
+
function startAction(url, commander, readyOn = 'networkidle') {
|
|
316
348
|
// Create context for condition checking
|
|
317
349
|
const conditionCtx = {
|
|
318
350
|
url,
|
|
319
351
|
commander,
|
|
352
|
+
readyOn,
|
|
320
353
|
};
|
|
321
354
|
|
|
322
355
|
// Find matching trigger
|
|
@@ -325,6 +358,16 @@ export function createPageTriggerManager(options = {}) {
|
|
|
325
358
|
log.debug(() => `📋 No trigger registered for: ${url}`);
|
|
326
359
|
return Promise.resolve();
|
|
327
360
|
}
|
|
361
|
+
if (isActionRunning) {
|
|
362
|
+
if (matchingTrigger.concurrency === 'skip') {
|
|
363
|
+
return Promise.resolve();
|
|
364
|
+
}
|
|
365
|
+
return stopCurrentAction().then(() => {
|
|
366
|
+
if (!isActionRunning) {
|
|
367
|
+
return startAction(url, commander, readyOn);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
328
371
|
|
|
329
372
|
log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
|
|
330
373
|
|
|
@@ -510,36 +553,32 @@ export function createPageTriggerManager(options = {}) {
|
|
|
510
553
|
/**
|
|
511
554
|
* Handle page ready - start matching action
|
|
512
555
|
*/
|
|
513
|
-
async function onPageReady({ url }, commander) {
|
|
514
|
-
await startAction(url, commander);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
556
|
/**
|
|
518
557
|
* Check if an action is currently running
|
|
519
558
|
*/
|
|
520
|
-
|
|
521
|
-
return isActionRunning;
|
|
522
|
-
}
|
|
559
|
+
const isRunning = () => isActionRunning;
|
|
523
560
|
|
|
524
561
|
/**
|
|
525
562
|
* Get current trigger name
|
|
526
563
|
*/
|
|
527
|
-
|
|
528
|
-
return currentTrigger?.name || null;
|
|
529
|
-
}
|
|
564
|
+
const getCurrentTriggerName = () => currentTrigger?.name || null;
|
|
530
565
|
|
|
531
566
|
/**
|
|
532
567
|
* Initialize - connect to navigation manager
|
|
533
568
|
* @param {Object} commander - BrowserCommander instance
|
|
534
569
|
*/
|
|
535
570
|
function initialize(commander) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
571
|
+
subscribeReadiness({
|
|
572
|
+
commander,
|
|
573
|
+
navigationManager,
|
|
574
|
+
subscriptions,
|
|
575
|
+
onNavigationStart,
|
|
576
|
+
enqueue: (url, readyOn) => {
|
|
577
|
+
startQueue = startQueue.then(() =>
|
|
578
|
+
startAction(url, commander, readyOn)
|
|
579
|
+
);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
543
582
|
|
|
544
583
|
log.debug(() => '📋 PageTriggerManager initialized');
|
|
545
584
|
}
|
|
@@ -547,12 +586,13 @@ export function createPageTriggerManager(options = {}) {
|
|
|
547
586
|
/**
|
|
548
587
|
* Cleanup
|
|
549
588
|
*/
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
589
|
+
const destroy = createTriggerDestroy({
|
|
590
|
+
subscriptions,
|
|
591
|
+
getQueue: () => startQueue,
|
|
592
|
+
stopCurrentAction,
|
|
593
|
+
triggers,
|
|
594
|
+
log,
|
|
595
|
+
});
|
|
556
596
|
|
|
557
597
|
return {
|
|
558
598
|
pageTrigger,
|
|
@@ -567,3 +607,48 @@ export function createPageTriggerManager(options = {}) {
|
|
|
567
607
|
isActionStoppedError,
|
|
568
608
|
};
|
|
569
609
|
}
|
|
610
|
+
|
|
611
|
+
function subscribeReadiness({
|
|
612
|
+
commander,
|
|
613
|
+
navigationManager,
|
|
614
|
+
subscriptions,
|
|
615
|
+
onNavigationStart,
|
|
616
|
+
enqueue,
|
|
617
|
+
}) {
|
|
618
|
+
function subscribe(source, event, listener) {
|
|
619
|
+
source?.on?.(event, listener);
|
|
620
|
+
subscriptions.push(() => source?.off?.(event, listener));
|
|
621
|
+
}
|
|
622
|
+
subscribe(navigationManager, 'onBeforeNavigate', onNavigationStart);
|
|
623
|
+
subscribe(navigationManager, 'onPageReady', (event) =>
|
|
624
|
+
enqueue(event.url, 'networkidle')
|
|
625
|
+
);
|
|
626
|
+
// onNavigationStart happens after old actions have been stopped. Using
|
|
627
|
+
// onUrlChange here would start an action that cleanup immediately aborts.
|
|
628
|
+
subscribe(navigationManager, 'onNavigationStart', (event) =>
|
|
629
|
+
enqueue(event.url, 'urlchange')
|
|
630
|
+
);
|
|
631
|
+
for (const readyOn of ['domcontentloaded', 'load']) {
|
|
632
|
+
subscribe(commander.page, readyOn, () =>
|
|
633
|
+
enqueue(commander.page.url(), readyOn)
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function createTriggerDestroy({
|
|
639
|
+
subscriptions,
|
|
640
|
+
getQueue,
|
|
641
|
+
stopCurrentAction,
|
|
642
|
+
triggers,
|
|
643
|
+
log,
|
|
644
|
+
}) {
|
|
645
|
+
return async () => {
|
|
646
|
+
for (const unsubscribe of subscriptions.splice(0)) {
|
|
647
|
+
unsubscribe();
|
|
648
|
+
}
|
|
649
|
+
await getQueue();
|
|
650
|
+
await stopCurrentAction();
|
|
651
|
+
triggers.length = 0;
|
|
652
|
+
log.debug(() => '📋 PageTriggerManager destroyed');
|
|
653
|
+
};
|
|
654
|
+
}
|
package/src/core/readiness.js
CHANGED
|
@@ -464,9 +464,14 @@ export async function runReadinessChecks(options = {}) {
|
|
|
464
464
|
const skipped = [];
|
|
465
465
|
const pending = [];
|
|
466
466
|
const evidence = [];
|
|
467
|
+
let deadlineReached = false;
|
|
467
468
|
|
|
468
469
|
for (const [index, check] of checks.entries()) {
|
|
469
|
-
if (
|
|
470
|
+
if (
|
|
471
|
+
deadlineReached ||
|
|
472
|
+
deadline.signal?.aborted ||
|
|
473
|
+
(deadline.expired() && failed.length > 0)
|
|
474
|
+
) {
|
|
470
475
|
pending.push(...checks.slice(index).map((entry) => entry.name));
|
|
471
476
|
break;
|
|
472
477
|
}
|
|
@@ -477,6 +482,9 @@ export async function runReadinessChecks(options = {}) {
|
|
|
477
482
|
const bounded = await runWithinDeadline(deadline, () =>
|
|
478
483
|
check.run({ ...context, deadline })
|
|
479
484
|
);
|
|
485
|
+
// Timer callbacks and the rounded monotonic clock can disagree briefly.
|
|
486
|
+
// Once expiry ends a wait, another clock sample must not undo it.
|
|
487
|
+
deadlineReached ||= bounded.timedOut;
|
|
480
488
|
outcome =
|
|
481
489
|
bounded.timedOut || bounded.interrupted
|
|
482
490
|
? {
|
|
@@ -513,7 +521,7 @@ export async function runReadinessChecks(options = {}) {
|
|
|
513
521
|
|
|
514
522
|
const interrupted = Boolean(deadline.signal?.aborted);
|
|
515
523
|
const ready = !interrupted && failed.length === 0 && pending.length === 0;
|
|
516
|
-
const timedOut = !ready && deadline.expired();
|
|
524
|
+
const timedOut = !ready && (deadlineReached || deadline.expired());
|
|
517
525
|
|
|
518
526
|
return {
|
|
519
527
|
status: interrupted
|
package/src/elements/locators.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { TIMING } from '../core/constants.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
waitNavigationState,
|
|
4
|
+
recoverWaitError,
|
|
5
|
+
} from '../core/navigation-safety.js';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Helper to create Playwright locator from selector string
|
|
@@ -9,12 +12,16 @@ import { isNavigationError } from '../core/navigation-safety.js';
|
|
|
9
12
|
* @param {string} options.selector - CSS selector
|
|
10
13
|
* @returns {Object} - Playwright locator
|
|
11
14
|
*/
|
|
12
|
-
export function
|
|
13
|
-
const { page, selector } = options;
|
|
14
|
-
|
|
15
|
+
export function requireSelector(selector) {
|
|
15
16
|
if (!selector) {
|
|
16
17
|
throw new Error('selector is required in options');
|
|
17
18
|
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createPlaywrightLocator(options = {}) {
|
|
22
|
+
const { page, selector } = options;
|
|
23
|
+
|
|
24
|
+
requireSelector(selector);
|
|
18
25
|
// Check if selector has :nth-of-type(n) pattern
|
|
19
26
|
const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
|
|
20
27
|
|
|
@@ -42,9 +49,7 @@ export async function getLocatorOrElement(options = {}) {
|
|
|
42
49
|
throw new TypeError('index must be a non-negative integer');
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
throw new Error('selector is required in options');
|
|
47
|
-
}
|
|
52
|
+
requireSelector(selector);
|
|
48
53
|
if (typeof selector !== 'string') {
|
|
49
54
|
return selector; // Already a locator/element
|
|
50
55
|
}
|
|
@@ -73,18 +78,12 @@ export async function getLocatorOrElement(options = {}) {
|
|
|
73
78
|
* @throws {Error} - If element not found or not visible within timeout (unless navigation error and throwOnNavigation is false)
|
|
74
79
|
*/
|
|
75
80
|
export async function waitForLocatorOrElement(options = {}) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
timeout = TIMING.DEFAULT_TIMEOUT,
|
|
81
|
-
throwOnNavigation = true,
|
|
82
|
-
} = options;
|
|
83
|
-
|
|
84
|
-
if (!selector) {
|
|
85
|
-
throw new Error('selector is required in options');
|
|
86
|
-
}
|
|
81
|
+
requireSelector(options.selector);
|
|
82
|
+
const { page, engine, selector } = options;
|
|
83
|
+
const timeout =
|
|
84
|
+
options.timeout === undefined ? TIMING.DEFAULT_TIMEOUT : options.timeout;
|
|
87
85
|
|
|
86
|
+
const initial = waitNavigationState(page, options.navigationManager);
|
|
88
87
|
try {
|
|
89
88
|
if (engine === 'playwright') {
|
|
90
89
|
const locator = await getLocatorOrElement(options);
|
|
@@ -125,17 +124,14 @@ export async function waitForLocatorOrElement(options = {}) {
|
|
|
125
124
|
}
|
|
126
125
|
return element;
|
|
127
126
|
}
|
|
128
|
-
} catch (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
throw error;
|
|
127
|
+
} catch (caught) {
|
|
128
|
+
return recoverWaitError(
|
|
129
|
+
caught,
|
|
130
|
+
options,
|
|
131
|
+
initial,
|
|
132
|
+
'waitForLocatorOrElement',
|
|
133
|
+
null
|
|
134
|
+
);
|
|
139
135
|
}
|
|
140
136
|
}
|
|
141
137
|
|
|
@@ -179,9 +175,7 @@ export async function waitForVisible(options = {}) {
|
|
|
179
175
|
export function locator(options = {}) {
|
|
180
176
|
const { page, engine, selector } = options;
|
|
181
177
|
|
|
182
|
-
|
|
183
|
-
throw new Error('selector is required in options');
|
|
184
|
-
}
|
|
178
|
+
requireSelector(selector);
|
|
185
179
|
|
|
186
180
|
if (engine === 'playwright') {
|
|
187
181
|
return createPlaywrightLocator({ page, selector });
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
isNavigationError,
|
|
3
|
+
waitNavigationState,
|
|
4
|
+
recoverWaitError,
|
|
5
|
+
} from '../core/navigation-safety.js';
|
|
6
|
+
import { createPlaywrightLocator, requireSelector } from './locators.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Query single element
|
|
@@ -9,67 +13,38 @@ import { createPlaywrightLocator } from './locators.js';
|
|
|
9
13
|
* @param {string} options.selector - CSS selector
|
|
10
14
|
* @returns {Promise<Object|null>} - Element handle or null
|
|
11
15
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!selector) {
|
|
16
|
-
throw new Error('selector is required in options');
|
|
17
|
-
}
|
|
16
|
+
export function querySelector(options = {}) {
|
|
17
|
+
return queryElements(options, true);
|
|
18
|
+
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const count = await locator.count();
|
|
23
|
-
return count > 0 ? locator : null;
|
|
24
|
-
} else {
|
|
25
|
-
return await page.$(selector);
|
|
26
|
-
}
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (isNavigationError(error)) {
|
|
29
|
-
console.log(
|
|
30
|
-
'⚠️ Navigation detected during querySelector, returning null'
|
|
31
|
-
);
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
20
|
+
/** Return every matching element with the same navigation recovery as querySelector. */
|
|
21
|
+
export function querySelectorAll(options = {}) {
|
|
22
|
+
return queryElements(options, false);
|
|
36
23
|
}
|
|
37
24
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* @param {Object} options - Configuration options
|
|
41
|
-
* @param {Object} options.page - Browser page object
|
|
42
|
-
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
43
|
-
* @param {string} options.selector - CSS selector
|
|
44
|
-
* @returns {Promise<Array>} - Array of element handles
|
|
45
|
-
*/
|
|
46
|
-
export async function querySelectorAll(options = {}) {
|
|
25
|
+
async function queryElements(options, single) {
|
|
26
|
+
requireSelector(options.selector);
|
|
47
27
|
const { page, engine, selector } = options;
|
|
48
|
-
|
|
49
|
-
if (!selector) {
|
|
50
|
-
throw new Error('selector is required in options');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
28
|
try {
|
|
54
|
-
if (engine
|
|
55
|
-
|
|
56
|
-
const count = await locator.count();
|
|
57
|
-
const elements = [];
|
|
58
|
-
for (let i = 0; i < count; i++) {
|
|
59
|
-
elements.push(locator.nth(i));
|
|
60
|
-
}
|
|
61
|
-
return elements;
|
|
62
|
-
} else {
|
|
63
|
-
return await page.$$(selector);
|
|
29
|
+
if (engine !== 'playwright') {
|
|
30
|
+
return single ? await page.$(selector) : await page.$$(selector);
|
|
64
31
|
}
|
|
32
|
+
const matching = createPlaywrightLocator({ page, selector });
|
|
33
|
+
const locator = single ? matching.first() : matching;
|
|
34
|
+
const total = await locator.count();
|
|
35
|
+
return single
|
|
36
|
+
? total > 0
|
|
37
|
+
? locator
|
|
38
|
+
: null
|
|
39
|
+
: Array.from({ length: total }, (_, index) => locator.nth(index));
|
|
65
40
|
} catch (error) {
|
|
66
|
-
if (isNavigationError(error)) {
|
|
67
|
-
|
|
68
|
-
'⚠️ Navigation detected during querySelectorAll, returning empty array'
|
|
69
|
-
);
|
|
70
|
-
return [];
|
|
41
|
+
if (!isNavigationError(error)) {
|
|
42
|
+
throw error;
|
|
71
43
|
}
|
|
72
|
-
|
|
44
|
+
console.log(
|
|
45
|
+
`⚠️ Navigation detected during ${single ? 'querySelector' : 'querySelectorAll'}, recovering gracefully`
|
|
46
|
+
);
|
|
47
|
+
return single ? null : [];
|
|
73
48
|
}
|
|
74
49
|
}
|
|
75
50
|
|
|
@@ -175,9 +150,7 @@ function parsePlaywrightTextSelector(selector) {
|
|
|
175
150
|
export async function normalizeSelector(options = {}) {
|
|
176
151
|
const { page, engine, selector } = options;
|
|
177
152
|
|
|
178
|
-
|
|
179
|
-
throw new Error('selector is required in options');
|
|
180
|
-
}
|
|
153
|
+
requireSelector(selector);
|
|
181
154
|
|
|
182
155
|
// DEFENSIVE: Validate selector type - must be string or Puppeteer text selector object
|
|
183
156
|
// Arrays and other invalid types should be rejected to prevent downstream querySelectorAll errors
|
|
@@ -360,19 +333,11 @@ export function withTextSelectorSupport(fn, engine, page) {
|
|
|
360
333
|
* @returns {Promise<boolean>} - True if selector found, false on navigation
|
|
361
334
|
*/
|
|
362
335
|
export async function waitForSelector(options = {}) {
|
|
363
|
-
const {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
selector,
|
|
367
|
-
visible = true,
|
|
368
|
-
timeout = 5000,
|
|
369
|
-
throwOnNavigation = true,
|
|
370
|
-
} = options;
|
|
371
|
-
|
|
372
|
-
if (!selector) {
|
|
373
|
-
throw new Error('selector is required in options');
|
|
374
|
-
}
|
|
336
|
+
const { page, engine, selector, visible = true, timeout = 5000 } = options;
|
|
337
|
+
|
|
338
|
+
requireSelector(selector);
|
|
375
339
|
|
|
340
|
+
const initial = waitNavigationState(page, options.navigationManager);
|
|
376
341
|
try {
|
|
377
342
|
if (engine === 'playwright') {
|
|
378
343
|
const locator = createPlaywrightLocator({ page, selector });
|
|
@@ -384,16 +349,7 @@ export async function waitForSelector(options = {}) {
|
|
|
384
349
|
await page.waitForSelector(selector, { visible, timeout });
|
|
385
350
|
}
|
|
386
351
|
return true;
|
|
387
|
-
} catch (
|
|
388
|
-
|
|
389
|
-
console.log(
|
|
390
|
-
'⚠️ Navigation detected during waitForSelector, recovering gracefully'
|
|
391
|
-
);
|
|
392
|
-
if (throwOnNavigation) {
|
|
393
|
-
throw error;
|
|
394
|
-
}
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
throw error;
|
|
352
|
+
} catch (caught) {
|
|
353
|
+
return recoverWaitError(caught, options, initial, 'waitForSelector', false);
|
|
398
354
|
}
|
|
399
355
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TIMING } from '../core/constants.js';
|
|
2
2
|
import { isNavigationError } from '../core/navigation-safety.js';
|
|
3
|
-
import { getLocatorOrElement } from './locators.js';
|
|
3
|
+
import { getLocatorOrElement, requireSelector } from './locators.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Check if element is visible
|
|
@@ -13,9 +13,7 @@ import { getLocatorOrElement } from './locators.js';
|
|
|
13
13
|
export async function isVisible(options = {}) {
|
|
14
14
|
const { page, engine, selector } = options;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
throw new Error('selector is required in options');
|
|
18
|
-
}
|
|
16
|
+
requireSelector(selector);
|
|
19
17
|
|
|
20
18
|
try {
|
|
21
19
|
if (engine === 'playwright') {
|
|
@@ -62,9 +60,7 @@ export async function isVisible(options = {}) {
|
|
|
62
60
|
export async function isEnabled(options = {}) {
|
|
63
61
|
const { page, engine, selector, disabledClasses = ['disabled'] } = options;
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
throw new Error('selector is required in options');
|
|
67
|
-
}
|
|
63
|
+
requireSelector(selector);
|
|
68
64
|
|
|
69
65
|
try {
|
|
70
66
|
if (engine === 'playwright') {
|
|
@@ -119,9 +115,7 @@ export async function isEnabled(options = {}) {
|
|
|
119
115
|
export async function count(options = {}) {
|
|
120
116
|
const { page, engine, selector } = options;
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
throw new Error('selector is required in options');
|
|
124
|
-
}
|
|
118
|
+
requireSelector(selector);
|
|
125
119
|
|
|
126
120
|
try {
|
|
127
121
|
// Handle Puppeteer text selectors
|
|
@@ -150,9 +144,37 @@ export async function count(options = {}) {
|
|
|
150
144
|
}
|
|
151
145
|
|
|
152
146
|
if (engine === 'playwright') {
|
|
147
|
+
if (options.visible) {
|
|
148
|
+
return await page
|
|
149
|
+
.locator(selector)
|
|
150
|
+
.evaluateAll(
|
|
151
|
+
(elements) =>
|
|
152
|
+
elements.filter(
|
|
153
|
+
(element) =>
|
|
154
|
+
element.getClientRects().length > 0 &&
|
|
155
|
+
element.checkVisibility({ visibilityProperty: true })
|
|
156
|
+
).length
|
|
157
|
+
);
|
|
158
|
+
}
|
|
153
159
|
return await page.locator(selector).count();
|
|
154
160
|
} else {
|
|
155
161
|
const elements = await page.$$(selector);
|
|
162
|
+
if (options.visible) {
|
|
163
|
+
let visible = 0;
|
|
164
|
+
for (const element of elements) {
|
|
165
|
+
if (
|
|
166
|
+
await page.evaluate(
|
|
167
|
+
(element) =>
|
|
168
|
+
element.getClientRects().length > 0 &&
|
|
169
|
+
element.checkVisibility({ visibilityProperty: true }),
|
|
170
|
+
element
|
|
171
|
+
)
|
|
172
|
+
) {
|
|
173
|
+
visible += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return visible;
|
|
177
|
+
}
|
|
156
178
|
return elements.length;
|
|
157
179
|
}
|
|
158
180
|
} catch (error) {
|
package/src/factory.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './core/page-trigger-manager.js';
|
|
23
23
|
import { createBoundFunctions } from './bindings.js';
|
|
24
24
|
import { attachDownloads } from './downloads/attach.js';
|
|
25
|
-
import {
|
|
25
|
+
import { attachCommanderFeatures } from './core/commander-features.js';
|
|
26
26
|
import { createCdpSession } from './browser/cdp-session.js';
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -66,7 +66,7 @@ export function makeBrowserCommander(options = {}) {
|
|
|
66
66
|
page,
|
|
67
67
|
engine,
|
|
68
68
|
log,
|
|
69
|
-
idleTimeout: 30000, // Wait 30 seconds without requests before considering network idle
|
|
69
|
+
idleTimeout: options.networkIdleForMs ?? 30000, // Wait 30 seconds without requests before considering network idle
|
|
70
70
|
});
|
|
71
71
|
networkTracker.startTracking();
|
|
72
72
|
}
|
|
@@ -92,6 +92,7 @@ export function makeBrowserCommander(options = {}) {
|
|
|
92
92
|
log,
|
|
93
93
|
networkTracker,
|
|
94
94
|
});
|
|
95
|
+
navigationManager.configure(navigationOptions(options));
|
|
95
96
|
navigationManager.startListening();
|
|
96
97
|
|
|
97
98
|
// Create PageSession factory
|
|
@@ -159,7 +160,6 @@ export function makeBrowserCommander(options = {}) {
|
|
|
159
160
|
|
|
160
161
|
// All bound functions
|
|
161
162
|
...boundFunctions,
|
|
162
|
-
|
|
163
163
|
// Managed downloads (issue #88). Present once a manager is attached,
|
|
164
164
|
// either by passing the one launchBrowser() returned or by calling
|
|
165
165
|
// configureDownloads() on an existing commander.
|
|
@@ -192,15 +192,6 @@ export function makeBrowserCommander(options = {}) {
|
|
|
192
192
|
*/
|
|
193
193
|
createCdpSession: () => createCdpSession(page, { engine }),
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
* Start recording a privacy-aware trace of this session (issue #87).
|
|
197
|
-
*
|
|
198
|
-
* @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
|
|
199
|
-
* @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
|
|
200
|
-
*/
|
|
201
|
-
startTrace: (traceOptions = {}) =>
|
|
202
|
-
startTrace({ commander, page, log, ...traceOptions }),
|
|
203
|
-
|
|
204
195
|
// Lifecycle
|
|
205
196
|
destroy,
|
|
206
197
|
|
|
@@ -295,5 +286,15 @@ export function makeBrowserCommander(options = {}) {
|
|
|
295
286
|
pageTriggerManager.initialize(commander);
|
|
296
287
|
}
|
|
297
288
|
|
|
289
|
+
attachCommanderFeatures(commander, options, makeBrowserCommander);
|
|
298
290
|
return commander;
|
|
299
291
|
}
|
|
292
|
+
|
|
293
|
+
function navigationOptions(options) {
|
|
294
|
+
return {
|
|
295
|
+
...(options.navigationManager ?? {}),
|
|
296
|
+
...(options.networkIdleTimeout === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { networkIdleTimeout: options.networkIdleTimeout }),
|
|
299
|
+
};
|
|
300
|
+
}
|