browser-commander 0.26.3 → 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/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- 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/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- 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 +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- 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/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- 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/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -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/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { subscribeCallbacks } from './subscriptions.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* PageTriggerManager - Manages stoppable page triggers with proper lifecycle
|
|
3
5
|
*
|
|
@@ -168,7 +170,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
// Registered triggers
|
|
171
|
-
const triggers = []
|
|
173
|
+
const triggers = [],
|
|
174
|
+
subscriptions = [];
|
|
172
175
|
|
|
173
176
|
// Current action state
|
|
174
177
|
let currentTrigger = null;
|
|
@@ -176,8 +179,9 @@ export function createPageTriggerManager(options = {}) {
|
|
|
176
179
|
let actionPromise = null;
|
|
177
180
|
let actionStopPromise = null;
|
|
178
181
|
let actionStopResolve = null;
|
|
179
|
-
let isActionRunning = false
|
|
180
|
-
|
|
182
|
+
let isActionRunning = false,
|
|
183
|
+
isStopping = false;
|
|
184
|
+
let startQueue = Promise.resolve();
|
|
181
185
|
|
|
182
186
|
/**
|
|
183
187
|
* Register a page trigger
|
|
@@ -189,7 +193,24 @@ export function createPageTriggerManager(options = {}) {
|
|
|
189
193
|
* @returns {Function} - Unregister function
|
|
190
194
|
*/
|
|
191
195
|
function pageTrigger(config) {
|
|
192
|
-
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
|
+
}
|
|
193
214
|
|
|
194
215
|
if (typeof condition !== 'function') {
|
|
195
216
|
throw new Error('condition must be a function');
|
|
@@ -203,6 +224,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
203
224
|
action,
|
|
204
225
|
name,
|
|
205
226
|
priority,
|
|
227
|
+
readyOn,
|
|
228
|
+
concurrency,
|
|
206
229
|
};
|
|
207
230
|
|
|
208
231
|
triggers.push(triggerConfig);
|
|
@@ -232,7 +255,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
232
255
|
function findMatchingTrigger(ctx) {
|
|
233
256
|
for (const config of triggers) {
|
|
234
257
|
try {
|
|
235
|
-
if (config.condition(ctx)) {
|
|
258
|
+
if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
|
|
236
259
|
return config;
|
|
237
260
|
}
|
|
238
261
|
} catch (e) {
|
|
@@ -276,8 +299,9 @@ export function createPageTriggerManager(options = {}) {
|
|
|
276
299
|
|
|
277
300
|
// Wait for action to finish (with timeout)
|
|
278
301
|
const timeoutMs = 10000; // 10 second max wait
|
|
302
|
+
let stopTimer;
|
|
279
303
|
const timeoutPromise = new Promise((resolve) => {
|
|
280
|
-
setTimeout(() => {
|
|
304
|
+
stopTimer = setTimeout(() => {
|
|
281
305
|
log.debug(
|
|
282
306
|
() =>
|
|
283
307
|
`⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
|
|
@@ -287,6 +311,16 @@ export function createPageTriggerManager(options = {}) {
|
|
|
287
311
|
});
|
|
288
312
|
|
|
289
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
|
+
}
|
|
290
324
|
|
|
291
325
|
// Cleanup
|
|
292
326
|
isActionRunning = false;
|
|
@@ -310,11 +344,12 @@ export function createPageTriggerManager(options = {}) {
|
|
|
310
344
|
* @param {string} url - URL to start action for
|
|
311
345
|
* @param {Object} commander - BrowserCommander instance
|
|
312
346
|
*/
|
|
313
|
-
function startAction(url, commander) {
|
|
347
|
+
function startAction(url, commander, readyOn = 'networkidle') {
|
|
314
348
|
// Create context for condition checking
|
|
315
349
|
const conditionCtx = {
|
|
316
350
|
url,
|
|
317
351
|
commander,
|
|
352
|
+
readyOn,
|
|
318
353
|
};
|
|
319
354
|
|
|
320
355
|
// Find matching trigger
|
|
@@ -323,6 +358,16 @@ export function createPageTriggerManager(options = {}) {
|
|
|
323
358
|
log.debug(() => `📋 No trigger registered for: ${url}`);
|
|
324
359
|
return Promise.resolve();
|
|
325
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
|
+
}
|
|
326
371
|
|
|
327
372
|
log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
|
|
328
373
|
|
|
@@ -436,18 +481,19 @@ export function createPageTriggerManager(options = {}) {
|
|
|
436
481
|
*/
|
|
437
482
|
const cleanupCallbacks = [];
|
|
438
483
|
function onCleanup(callback) {
|
|
439
|
-
cleanupCallbacks
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
484
|
+
const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
|
|
485
|
+
const onAbort = async () => {
|
|
486
|
+
try {
|
|
487
|
+
await callback();
|
|
488
|
+
} catch (e) {
|
|
489
|
+
log.debug(() => `⚠️ Cleanup error: ${e.message}`);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
493
|
+
return () => {
|
|
494
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
495
|
+
unsubscribe();
|
|
496
|
+
};
|
|
451
497
|
}
|
|
452
498
|
|
|
453
499
|
// Wrap all commander methods to be abort-aware
|
|
@@ -507,36 +553,32 @@ export function createPageTriggerManager(options = {}) {
|
|
|
507
553
|
/**
|
|
508
554
|
* Handle page ready - start matching action
|
|
509
555
|
*/
|
|
510
|
-
async function onPageReady({ url }, commander) {
|
|
511
|
-
await startAction(url, commander);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
556
|
/**
|
|
515
557
|
* Check if an action is currently running
|
|
516
558
|
*/
|
|
517
|
-
|
|
518
|
-
return isActionRunning;
|
|
519
|
-
}
|
|
559
|
+
const isRunning = () => isActionRunning;
|
|
520
560
|
|
|
521
561
|
/**
|
|
522
562
|
* Get current trigger name
|
|
523
563
|
*/
|
|
524
|
-
|
|
525
|
-
return currentTrigger?.name || null;
|
|
526
|
-
}
|
|
564
|
+
const getCurrentTriggerName = () => currentTrigger?.name || null;
|
|
527
565
|
|
|
528
566
|
/**
|
|
529
567
|
* Initialize - connect to navigation manager
|
|
530
568
|
* @param {Object} commander - BrowserCommander instance
|
|
531
569
|
*/
|
|
532
570
|
function initialize(commander) {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
});
|
|
540
582
|
|
|
541
583
|
log.debug(() => '📋 PageTriggerManager initialized');
|
|
542
584
|
}
|
|
@@ -544,12 +586,13 @@ export function createPageTriggerManager(options = {}) {
|
|
|
544
586
|
/**
|
|
545
587
|
* Cleanup
|
|
546
588
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
589
|
+
const destroy = createTriggerDestroy({
|
|
590
|
+
subscriptions,
|
|
591
|
+
getQueue: () => startQueue,
|
|
592
|
+
stopCurrentAction,
|
|
593
|
+
triggers,
|
|
594
|
+
log,
|
|
595
|
+
});
|
|
553
596
|
|
|
554
597
|
return {
|
|
555
598
|
pageTrigger,
|
|
@@ -564,3 +607,48 @@ export function createPageTriggerManager(options = {}) {
|
|
|
564
607
|
isActionStoppedError,
|
|
565
608
|
};
|
|
566
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
|
@@ -14,6 +14,7 @@ export const READINESS_STATUS = Object.freeze({
|
|
|
14
14
|
READY: 'ready',
|
|
15
15
|
TIMED_OUT: 'timed_out',
|
|
16
16
|
FAILED: 'failed',
|
|
17
|
+
INTERRUPTED: 'interrupted',
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -57,7 +58,7 @@ export function isLongLivedRequest(
|
|
|
57
58
|
* @returns {{startedAt: number, timeoutMs: number, elapsedMs: Function, remainingMs: Function, expired: Function}} Deadline handle
|
|
58
59
|
*/
|
|
59
60
|
export function createDeadline(options = {}) {
|
|
60
|
-
const { timeout, now = () => performance.now() } = options;
|
|
61
|
+
const { timeout, now = () => performance.now(), signal } = options;
|
|
61
62
|
|
|
62
63
|
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
63
64
|
throw new TypeError('createDeadline requires a non-negative timeout');
|
|
@@ -72,6 +73,7 @@ export function createDeadline(options = {}) {
|
|
|
72
73
|
const remainingMs = () => Math.max(0, Math.round(timeout - elapsed()));
|
|
73
74
|
|
|
74
75
|
return {
|
|
76
|
+
signal,
|
|
75
77
|
startedAt,
|
|
76
78
|
timeoutMs: timeout,
|
|
77
79
|
elapsedMs: () => Math.round(elapsed()),
|
|
@@ -92,7 +94,22 @@ export async function sleepWithinDeadline(ms, deadline) {
|
|
|
92
94
|
if (capped <= 0) {
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
|
-
await
|
|
97
|
+
await runWithinDeadline(
|
|
98
|
+
deadline,
|
|
99
|
+
() =>
|
|
100
|
+
new Promise((resolve) => {
|
|
101
|
+
const timer = setTimeout(done, capped);
|
|
102
|
+
function done() {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
deadline.signal?.removeEventListener('abort', done);
|
|
105
|
+
resolve();
|
|
106
|
+
}
|
|
107
|
+
deadline.signal?.addEventListener('abort', done, { once: true });
|
|
108
|
+
if (deadline.signal?.aborted) {
|
|
109
|
+
done();
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
);
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
/** Marker resolved by the expiry timer in {@link runWithinDeadline}. */
|
|
@@ -110,7 +127,14 @@ const DEADLINE_REACHED = Symbol('deadline-reached');
|
|
|
110
127
|
* @param {Function} run - Zero-argument function returning a promise
|
|
111
128
|
* @returns {Promise<{timedOut: boolean, value: *}>} Outcome, or expiry
|
|
112
129
|
*/
|
|
113
|
-
export async function runWithinDeadline(
|
|
130
|
+
export async function runWithinDeadline(
|
|
131
|
+
deadline,
|
|
132
|
+
run,
|
|
133
|
+
signal = deadline?.signal
|
|
134
|
+
) {
|
|
135
|
+
if (signal?.aborted) {
|
|
136
|
+
return { timedOut: false, interrupted: true };
|
|
137
|
+
}
|
|
114
138
|
if (!deadline) {
|
|
115
139
|
return { timedOut: false, value: await run() };
|
|
116
140
|
}
|
|
@@ -127,18 +151,31 @@ export async function runWithinDeadline(deadline, run) {
|
|
|
127
151
|
Promise.resolve(operation).catch(() => {});
|
|
128
152
|
|
|
129
153
|
let timer;
|
|
154
|
+
let onAbort;
|
|
130
155
|
const expiry = new Promise((resolve) => {
|
|
131
156
|
timer = setTimeout(() => resolve(DEADLINE_REACHED), remaining);
|
|
132
157
|
});
|
|
158
|
+
const interrupted = Symbol('interrupted');
|
|
159
|
+
const cancellation = new Promise((resolve) => {
|
|
160
|
+
onAbort = () => resolve(interrupted);
|
|
161
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
162
|
+
if (signal?.aborted) {
|
|
163
|
+
onAbort();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
133
166
|
|
|
134
167
|
try {
|
|
135
|
-
const outcome = await Promise.race([operation, expiry]);
|
|
168
|
+
const outcome = await Promise.race([operation, expiry, cancellation]);
|
|
169
|
+
if (outcome === interrupted) {
|
|
170
|
+
return { timedOut: false, interrupted: true };
|
|
171
|
+
}
|
|
136
172
|
if (outcome === DEADLINE_REACHED) {
|
|
137
173
|
return { timedOut: true, value: undefined };
|
|
138
174
|
}
|
|
139
175
|
return { timedOut: false, value: outcome };
|
|
140
176
|
} finally {
|
|
141
177
|
clearTimeout(timer);
|
|
178
|
+
signal?.removeEventListener('abort', onAbort);
|
|
142
179
|
}
|
|
143
180
|
}
|
|
144
181
|
|
|
@@ -196,7 +233,7 @@ export function stableCheck(options = {}) {
|
|
|
196
233
|
let streak = 0;
|
|
197
234
|
let lastDetail = {};
|
|
198
235
|
|
|
199
|
-
while (!deadline.expired()) {
|
|
236
|
+
while (!deadline.expired() && !deadline.signal?.aborted) {
|
|
200
237
|
const { stable, detail } = normalizeSampleResult(await sample(context));
|
|
201
238
|
lastDetail = detail;
|
|
202
239
|
|
|
@@ -285,6 +322,7 @@ export function networkIdleFor(options = {}) {
|
|
|
285
322
|
|
|
286
323
|
const idle = await networkTracker.waitForNetworkIdle({
|
|
287
324
|
timeout,
|
|
325
|
+
signal: deadline.signal,
|
|
288
326
|
...(idleForMs === undefined ? {} : { idleTime: idleForMs }),
|
|
289
327
|
});
|
|
290
328
|
|
|
@@ -426,9 +464,14 @@ export async function runReadinessChecks(options = {}) {
|
|
|
426
464
|
const skipped = [];
|
|
427
465
|
const pending = [];
|
|
428
466
|
const evidence = [];
|
|
467
|
+
let deadlineReached = false;
|
|
429
468
|
|
|
430
469
|
for (const [index, check] of checks.entries()) {
|
|
431
|
-
if (
|
|
470
|
+
if (
|
|
471
|
+
deadlineReached ||
|
|
472
|
+
deadline.signal?.aborted ||
|
|
473
|
+
(deadline.expired() && failed.length > 0)
|
|
474
|
+
) {
|
|
432
475
|
pending.push(...checks.slice(index).map((entry) => entry.name));
|
|
433
476
|
break;
|
|
434
477
|
}
|
|
@@ -436,7 +479,23 @@ export async function runReadinessChecks(options = {}) {
|
|
|
436
479
|
const startedAtMs = deadline.elapsedMs();
|
|
437
480
|
let outcome;
|
|
438
481
|
try {
|
|
439
|
-
|
|
482
|
+
const bounded = await runWithinDeadline(deadline, () =>
|
|
483
|
+
check.run({ ...context, deadline })
|
|
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;
|
|
488
|
+
outcome =
|
|
489
|
+
bounded.timedOut || bounded.interrupted
|
|
490
|
+
? {
|
|
491
|
+
satisfied: false,
|
|
492
|
+
detail: {
|
|
493
|
+
reason: bounded.interrupted
|
|
494
|
+
? 'interrupted'
|
|
495
|
+
: 'deadline reached',
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
: bounded.value;
|
|
440
499
|
} catch (error) {
|
|
441
500
|
outcome = { satisfied: false, detail: { error: error.message } };
|
|
442
501
|
}
|
|
@@ -460,15 +519,18 @@ export async function runReadinessChecks(options = {}) {
|
|
|
460
519
|
}
|
|
461
520
|
}
|
|
462
521
|
|
|
463
|
-
const
|
|
464
|
-
const
|
|
522
|
+
const interrupted = Boolean(deadline.signal?.aborted);
|
|
523
|
+
const ready = !interrupted && failed.length === 0 && pending.length === 0;
|
|
524
|
+
const timedOut = !ready && (deadlineReached || deadline.expired());
|
|
465
525
|
|
|
466
526
|
return {
|
|
467
|
-
status:
|
|
468
|
-
? READINESS_STATUS.
|
|
469
|
-
:
|
|
470
|
-
? READINESS_STATUS.
|
|
471
|
-
:
|
|
527
|
+
status: interrupted
|
|
528
|
+
? READINESS_STATUS.INTERRUPTED
|
|
529
|
+
: ready
|
|
530
|
+
? READINESS_STATUS.READY
|
|
531
|
+
: timedOut
|
|
532
|
+
? READINESS_STATUS.TIMED_OUT
|
|
533
|
+
: READINESS_STATUS.FAILED,
|
|
472
534
|
ready,
|
|
473
535
|
checks: { satisfied, failed, skipped, pending },
|
|
474
536
|
evidence,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Register a callback and return a remover that can safely be called again. */
|
|
2
|
+
export function subscribeCallbacks(callbacks, callback) {
|
|
3
|
+
callbacks?.push(callback);
|
|
4
|
+
let removed = false;
|
|
5
|
+
return () => {
|
|
6
|
+
if (removed) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
removed = true;
|
|
10
|
+
removeCallback(callbacks, callback);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Remove one matching callback, retaining other registrations. */
|
|
15
|
+
export function removeCallback(callbacks, callback) {
|
|
16
|
+
const index = callbacks?.indexOf(callback) ?? -1;
|
|
17
|
+
if (index !== -1) {
|
|
18
|
+
callbacks.splice(index, 1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -695,18 +695,56 @@ export class WebDriverPage extends EventEmitter {
|
|
|
695
695
|
// ==========================================================================
|
|
696
696
|
|
|
697
697
|
async cookies() {
|
|
698
|
+
if (await this.bidi()) {
|
|
699
|
+
const state = await this.bidiCommand('storage.getCookies');
|
|
700
|
+
return state.cookies.map((cookie) => ({
|
|
701
|
+
...cookie,
|
|
702
|
+
value: cookie.value.value,
|
|
703
|
+
expires: cookie.expiry ?? -1,
|
|
704
|
+
sameSite: cookie.sameSite?.replace(/^./u, (letter) =>
|
|
705
|
+
letter.toUpperCase()
|
|
706
|
+
),
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
698
709
|
const cookies = await this.driver.manage().getCookies();
|
|
699
710
|
return cookies.map(fromWebDriverCookie);
|
|
700
711
|
}
|
|
701
712
|
|
|
702
713
|
async setCookie(...cookies) {
|
|
703
714
|
for (const cookie of cookies) {
|
|
715
|
+
if (await this.bidi()) {
|
|
716
|
+
const { expires, url, ...value } = cookie;
|
|
717
|
+
await this.bidiCommand('storage.setCookie', {
|
|
718
|
+
cookie: {
|
|
719
|
+
...value,
|
|
720
|
+
domain:
|
|
721
|
+
cookie.domain ?? new URL(url ?? (await this.syncUrl())).hostname,
|
|
722
|
+
...(url
|
|
723
|
+
? { secure: cookie.secure ?? new URL(url).protocol === 'https:' }
|
|
724
|
+
: {}),
|
|
725
|
+
value: { type: 'string', value: cookie.value },
|
|
726
|
+
sameSite: cookie.sameSite?.toLowerCase() ?? 'lax',
|
|
727
|
+
...(expires > 0 ? { expiry: expires } : {}),
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
704
732
|
await this.driver.manage().addCookie(toWebDriverCookie(cookie));
|
|
705
733
|
}
|
|
706
734
|
}
|
|
707
735
|
|
|
708
736
|
async deleteCookie(...cookies) {
|
|
709
737
|
for (const cookie of cookies) {
|
|
738
|
+
if (await this.bidi()) {
|
|
739
|
+
await this.bidiCommand('storage.deleteCookies', {
|
|
740
|
+
filter: {
|
|
741
|
+
name: cookie.name,
|
|
742
|
+
domain: cookie.domain,
|
|
743
|
+
path: cookie.path,
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
710
748
|
await this.driver.manage().deleteCookie(cookie.name);
|
|
711
749
|
}
|
|
712
750
|
}
|
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
|
|
|
@@ -37,20 +44,24 @@ export function createPlaywrightLocator(options = {}) {
|
|
|
37
44
|
* @returns {Promise<Object|null>} - Locator for Playwright, Element for Puppeteer (can be null)
|
|
38
45
|
*/
|
|
39
46
|
export async function getLocatorOrElement(options = {}) {
|
|
40
|
-
const { page, engine, selector } = options;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
throw new Error('selector is required in options');
|
|
47
|
+
const { page, engine, selector, index } = options;
|
|
48
|
+
if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
|
|
49
|
+
throw new TypeError('index must be a non-negative integer');
|
|
44
50
|
}
|
|
51
|
+
|
|
52
|
+
requireSelector(selector);
|
|
45
53
|
if (typeof selector !== 'string') {
|
|
46
54
|
return selector; // Already a locator/element
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
if (engine === 'playwright') {
|
|
50
|
-
|
|
58
|
+
const locator = createPlaywrightLocator({ page, selector });
|
|
59
|
+
return index === undefined ? locator : locator.nth(index);
|
|
51
60
|
} else {
|
|
52
61
|
// For Puppeteer, return element (can be null if doesn't exist)
|
|
53
|
-
return
|
|
62
|
+
return index === undefined
|
|
63
|
+
? await page.$(selector)
|
|
64
|
+
: ((await page.$$(selector))[index] ?? null);
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -67,45 +78,60 @@ export async function getLocatorOrElement(options = {}) {
|
|
|
67
78
|
* @throws {Error} - If element not found or not visible within timeout (unless navigation error and throwOnNavigation is false)
|
|
68
79
|
*/
|
|
69
80
|
export async function waitForLocatorOrElement(options = {}) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
timeout = TIMING.DEFAULT_TIMEOUT,
|
|
75
|
-
throwOnNavigation = true,
|
|
76
|
-
} = options;
|
|
77
|
-
|
|
78
|
-
if (!selector) {
|
|
79
|
-
throw new Error('selector is required in options');
|
|
80
|
-
}
|
|
81
|
+
requireSelector(options.selector);
|
|
82
|
+
const { page, engine, selector } = options;
|
|
83
|
+
const timeout =
|
|
84
|
+
options.timeout === undefined ? TIMING.DEFAULT_TIMEOUT : options.timeout;
|
|
81
85
|
|
|
86
|
+
const initial = waitNavigationState(page, options.navigationManager);
|
|
82
87
|
try {
|
|
83
88
|
if (engine === 'playwright') {
|
|
84
|
-
const locator = await getLocatorOrElement(
|
|
89
|
+
const locator = await getLocatorOrElement(options);
|
|
85
90
|
// Use .first() to handle multiple matches (Playwright strict mode)
|
|
86
91
|
const firstLocator = locator.first();
|
|
87
92
|
await firstLocator.waitFor({ state: 'visible', timeout });
|
|
88
93
|
return firstLocator;
|
|
89
94
|
} else {
|
|
95
|
+
if (typeof selector !== 'string') {
|
|
96
|
+
await waitForVisible({ engine, locatorOrElement: selector, timeout });
|
|
97
|
+
return selector;
|
|
98
|
+
}
|
|
99
|
+
if (options.index !== undefined && typeof selector === 'string') {
|
|
100
|
+
if (!Number.isInteger(options.index) || options.index < 0) {
|
|
101
|
+
throw new TypeError('index must be a non-negative integer');
|
|
102
|
+
}
|
|
103
|
+
await page.waitForFunction(
|
|
104
|
+
(sel, index) => {
|
|
105
|
+
const element = document.querySelectorAll(sel)[index];
|
|
106
|
+
return (
|
|
107
|
+
element &&
|
|
108
|
+
element.getBoundingClientRect().width > 0 &&
|
|
109
|
+
element.getBoundingClientRect().height > 0 &&
|
|
110
|
+
window.getComputedStyle(element).visibility !== 'hidden'
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
{ timeout },
|
|
114
|
+
selector,
|
|
115
|
+
options.index
|
|
116
|
+
);
|
|
117
|
+
return getLocatorOrElement(options);
|
|
118
|
+
}
|
|
90
119
|
// Puppeteer: wait for selector to be visible (returns first match by default)
|
|
91
120
|
await page.waitForSelector(selector, { visible: true, timeout });
|
|
92
|
-
const element = await
|
|
121
|
+
const element = await getLocatorOrElement(options);
|
|
93
122
|
if (!element) {
|
|
94
123
|
throw new Error(`Element not found after waiting: ${selector}`);
|
|
95
124
|
}
|
|
96
125
|
return element;
|
|
97
126
|
}
|
|
98
|
-
} catch (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
throw error;
|
|
127
|
+
} catch (caught) {
|
|
128
|
+
return recoverWaitError(
|
|
129
|
+
caught,
|
|
130
|
+
options,
|
|
131
|
+
initial,
|
|
132
|
+
'waitForLocatorOrElement',
|
|
133
|
+
null
|
|
134
|
+
);
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
|
|
@@ -149,9 +175,7 @@ export async function waitForVisible(options = {}) {
|
|
|
149
175
|
export function locator(options = {}) {
|
|
150
176
|
const { page, engine, selector } = options;
|
|
151
177
|
|
|
152
|
-
|
|
153
|
-
throw new Error('selector is required in options');
|
|
154
|
-
}
|
|
178
|
+
requireSelector(selector);
|
|
155
179
|
|
|
156
180
|
if (engine === 'playwright') {
|
|
157
181
|
return createPlaywrightLocator({ page, selector });
|