browser-commander 0.27.0 → 0.29.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 +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- 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 +432 -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-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -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 +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -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 +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- 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 +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -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/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- 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/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -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/interactions/overlays.test.js +58 -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/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -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,26 @@ 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();
|
|
185
|
+
let pendingStart = null;
|
|
186
|
+
let destroyed = false;
|
|
187
|
+
|
|
188
|
+
function drainPending() {
|
|
189
|
+
if (destroyed || isActionRunning || isStopping || !pendingStart) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const pending = pendingStart;
|
|
193
|
+
pendingStart = null;
|
|
194
|
+
if (
|
|
195
|
+
triggers.includes(pending.trigger) &&
|
|
196
|
+
(!pending.commander.page?.url ||
|
|
197
|
+
pending.commander.page.url() === pending.url)
|
|
198
|
+
) {
|
|
199
|
+
void startAction(pending.url, pending.commander, pending.readyOn);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
183
202
|
|
|
184
203
|
/**
|
|
185
204
|
* Register a page trigger
|
|
@@ -191,7 +210,24 @@ export function createPageTriggerManager(options = {}) {
|
|
|
191
210
|
* @returns {Function} - Unregister function
|
|
192
211
|
*/
|
|
193
212
|
function pageTrigger(config) {
|
|
194
|
-
const {
|
|
213
|
+
const {
|
|
214
|
+
condition,
|
|
215
|
+
action,
|
|
216
|
+
name = 'unnamed',
|
|
217
|
+
priority = 0,
|
|
218
|
+
readyOn = 'networkidle',
|
|
219
|
+
concurrency = 'skip',
|
|
220
|
+
} = config;
|
|
221
|
+
if (
|
|
222
|
+
!['urlchange', 'domcontentloaded', 'load', 'networkidle'].includes(
|
|
223
|
+
readyOn
|
|
224
|
+
)
|
|
225
|
+
) {
|
|
226
|
+
throw new RangeError(`Unknown trigger readyOn: ${readyOn}`);
|
|
227
|
+
}
|
|
228
|
+
if (!['skip', 'restart', 'queue'].includes(concurrency)) {
|
|
229
|
+
throw new RangeError(`Unknown trigger concurrency: ${concurrency}`);
|
|
230
|
+
}
|
|
195
231
|
|
|
196
232
|
if (typeof condition !== 'function') {
|
|
197
233
|
throw new Error('condition must be a function');
|
|
@@ -205,6 +241,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
205
241
|
action,
|
|
206
242
|
name,
|
|
207
243
|
priority,
|
|
244
|
+
readyOn,
|
|
245
|
+
concurrency,
|
|
208
246
|
};
|
|
209
247
|
|
|
210
248
|
triggers.push(triggerConfig);
|
|
@@ -221,6 +259,9 @@ export function createPageTriggerManager(options = {}) {
|
|
|
221
259
|
const index = triggers.indexOf(triggerConfig);
|
|
222
260
|
if (index !== -1) {
|
|
223
261
|
triggers.splice(index, 1);
|
|
262
|
+
if (pendingStart?.trigger === triggerConfig) {
|
|
263
|
+
pendingStart = null;
|
|
264
|
+
}
|
|
224
265
|
log.debug(() => `📋 Unregistered page trigger: "${name}"`);
|
|
225
266
|
}
|
|
226
267
|
};
|
|
@@ -234,7 +275,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
234
275
|
function findMatchingTrigger(ctx) {
|
|
235
276
|
for (const config of triggers) {
|
|
236
277
|
try {
|
|
237
|
-
if (config.condition(ctx)) {
|
|
278
|
+
if (config.readyOn === ctx.readyOn && config.condition(ctx)) {
|
|
238
279
|
return config;
|
|
239
280
|
}
|
|
240
281
|
} catch (e) {
|
|
@@ -277,9 +318,10 @@ export function createPageTriggerManager(options = {}) {
|
|
|
277
318
|
}
|
|
278
319
|
|
|
279
320
|
// Wait for action to finish (with timeout)
|
|
280
|
-
const timeoutMs = 10000;
|
|
321
|
+
const timeoutMs = options.stopGraceMs ?? 10000;
|
|
322
|
+
let stopTimer;
|
|
281
323
|
const timeoutPromise = new Promise((resolve) => {
|
|
282
|
-
setTimeout(() => {
|
|
324
|
+
stopTimer = setTimeout(() => {
|
|
283
325
|
log.debug(
|
|
284
326
|
() =>
|
|
285
327
|
`⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
|
|
@@ -289,6 +331,16 @@ export function createPageTriggerManager(options = {}) {
|
|
|
289
331
|
});
|
|
290
332
|
|
|
291
333
|
await Promise.race([actionPromise, timeoutPromise]);
|
|
334
|
+
clearTimeout(stopTimer);
|
|
335
|
+
// An action can ignore cancellation. Keep it in flight until it settles,
|
|
336
|
+
// so a second form handler can never race it after the grace period.
|
|
337
|
+
if (isActionRunning) {
|
|
338
|
+
isStopping = false;
|
|
339
|
+
actionStopResolve?.();
|
|
340
|
+
actionStopPromise = null;
|
|
341
|
+
actionStopResolve = null;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
292
344
|
|
|
293
345
|
// Cleanup
|
|
294
346
|
isActionRunning = false;
|
|
@@ -305,6 +357,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
305
357
|
}
|
|
306
358
|
|
|
307
359
|
log.debug(() => '✅ Action stopped');
|
|
360
|
+
drainPending();
|
|
308
361
|
}
|
|
309
362
|
|
|
310
363
|
/**
|
|
@@ -312,11 +365,15 @@ export function createPageTriggerManager(options = {}) {
|
|
|
312
365
|
* @param {string} url - URL to start action for
|
|
313
366
|
* @param {Object} commander - BrowserCommander instance
|
|
314
367
|
*/
|
|
315
|
-
function startAction(url, commander) {
|
|
368
|
+
function startAction(url, commander, readyOn = 'networkidle') {
|
|
369
|
+
if (destroyed) {
|
|
370
|
+
return Promise.resolve();
|
|
371
|
+
}
|
|
316
372
|
// Create context for condition checking
|
|
317
373
|
const conditionCtx = {
|
|
318
374
|
url,
|
|
319
375
|
commander,
|
|
376
|
+
readyOn,
|
|
320
377
|
};
|
|
321
378
|
|
|
322
379
|
// Find matching trigger
|
|
@@ -325,6 +382,20 @@ export function createPageTriggerManager(options = {}) {
|
|
|
325
382
|
log.debug(() => `📋 No trigger registered for: ${url}`);
|
|
326
383
|
return Promise.resolve();
|
|
327
384
|
}
|
|
385
|
+
if (isActionRunning) {
|
|
386
|
+
if (
|
|
387
|
+
matchingTrigger.concurrency === 'skip' &&
|
|
388
|
+
!isStopping &&
|
|
389
|
+
!currentAbortController?.signal.aborted
|
|
390
|
+
) {
|
|
391
|
+
return Promise.resolve();
|
|
392
|
+
}
|
|
393
|
+
// One latest start bounds memory while preserving readiness during stop.
|
|
394
|
+
pendingStart = { url, commander, readyOn, trigger: matchingTrigger };
|
|
395
|
+
return matchingTrigger.concurrency === 'restart'
|
|
396
|
+
? stopCurrentAction()
|
|
397
|
+
: Promise.resolve();
|
|
398
|
+
}
|
|
328
399
|
|
|
329
400
|
log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
|
|
330
401
|
|
|
@@ -334,12 +405,15 @@ export function createPageTriggerManager(options = {}) {
|
|
|
334
405
|
isActionRunning = true;
|
|
335
406
|
|
|
336
407
|
// Create action context
|
|
337
|
-
const context = createActionContext(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
408
|
+
const context = createActionContext(
|
|
409
|
+
{
|
|
410
|
+
url,
|
|
411
|
+
abortSignal: currentAbortController.signal,
|
|
412
|
+
commander,
|
|
413
|
+
triggerName: matchingTrigger.name,
|
|
414
|
+
},
|
|
415
|
+
log
|
|
416
|
+
);
|
|
343
417
|
|
|
344
418
|
// Run action
|
|
345
419
|
actionPromise = (async () => {
|
|
@@ -368,6 +442,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
368
442
|
isActionRunning = false;
|
|
369
443
|
currentTrigger = null;
|
|
370
444
|
currentAbortController = null;
|
|
445
|
+
globalThis.queueMicrotask(drainPending);
|
|
371
446
|
}
|
|
372
447
|
}
|
|
373
448
|
})();
|
|
@@ -380,166 +455,44 @@ export function createPageTriggerManager(options = {}) {
|
|
|
380
455
|
* @param {Object} options
|
|
381
456
|
* @returns {Object} - Action context
|
|
382
457
|
*/
|
|
383
|
-
function createActionContext(options) {
|
|
384
|
-
const { url, abortSignal, commander, triggerName } = options;
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Check if stopped and throw if so
|
|
388
|
-
*/
|
|
389
|
-
function checkStopped() {
|
|
390
|
-
if (abortSignal.aborted) {
|
|
391
|
-
throw new ActionStoppedError(`Action "${triggerName}" stopped`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Wrap async function to check abort before and after
|
|
397
|
-
*/
|
|
398
|
-
function wrapAsync(fn) {
|
|
399
|
-
return async (...args) => {
|
|
400
|
-
checkStopped();
|
|
401
|
-
const result = await fn(...args);
|
|
402
|
-
checkStopped();
|
|
403
|
-
return result;
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Create abort-aware loop helper
|
|
409
|
-
* Use this instead of for/while loops for stoppability
|
|
410
|
-
*/
|
|
411
|
-
async function forEach(items, callback) {
|
|
412
|
-
for (let i = 0; i < items.length; i++) {
|
|
413
|
-
checkStopped();
|
|
414
|
-
await callback(items[i], i, items);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Wait with abort support
|
|
420
|
-
*/
|
|
421
|
-
async function wait(ms) {
|
|
422
|
-
checkStopped();
|
|
423
|
-
await new Promise((resolve, reject) => {
|
|
424
|
-
const timeout = setTimeout(resolve, ms);
|
|
425
|
-
abortSignal.addEventListener(
|
|
426
|
-
'abort',
|
|
427
|
-
() => {
|
|
428
|
-
clearTimeout(timeout);
|
|
429
|
-
reject(new ActionStoppedError());
|
|
430
|
-
},
|
|
431
|
-
{ once: true }
|
|
432
|
-
);
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Register cleanup callback (called when action stops)
|
|
438
|
-
*/
|
|
439
|
-
const cleanupCallbacks = [];
|
|
440
|
-
function onCleanup(callback) {
|
|
441
|
-
const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
|
|
442
|
-
const onAbort = async () => {
|
|
443
|
-
try {
|
|
444
|
-
await callback();
|
|
445
|
-
} catch (e) {
|
|
446
|
-
log.debug(() => `⚠️ Cleanup error: ${e.message}`);
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
450
|
-
return () => {
|
|
451
|
-
abortSignal.removeEventListener('abort', onAbort);
|
|
452
|
-
unsubscribe();
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Wrap all commander methods to be abort-aware
|
|
457
|
-
const wrappedCommander = {};
|
|
458
|
-
for (const [key, value] of Object.entries(commander)) {
|
|
459
|
-
if (
|
|
460
|
-
typeof value === 'function' &&
|
|
461
|
-
key !== 'destroy' &&
|
|
462
|
-
key !== 'pageTrigger'
|
|
463
|
-
) {
|
|
464
|
-
wrappedCommander[key] = wrapAsync(value);
|
|
465
|
-
} else {
|
|
466
|
-
wrappedCommander[key] = value;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return {
|
|
471
|
-
// URL this action is running for
|
|
472
|
-
url,
|
|
473
|
-
|
|
474
|
-
// Abort signal - use with fetch() or custom abort logic
|
|
475
|
-
abortSignal,
|
|
476
|
-
|
|
477
|
-
// Check if action should stop
|
|
478
|
-
isStopped: () => abortSignal.aborted,
|
|
479
|
-
|
|
480
|
-
// Throw if stopped - call this in loops
|
|
481
|
-
checkStopped,
|
|
482
|
-
|
|
483
|
-
// Abort-aware iteration helper
|
|
484
|
-
forEach,
|
|
485
|
-
|
|
486
|
-
// Abort-aware wait
|
|
487
|
-
wait,
|
|
488
|
-
|
|
489
|
-
// Register cleanup callback
|
|
490
|
-
onCleanup,
|
|
491
|
-
|
|
492
|
-
// Wrapped commander - all methods throw ActionStoppedError if stopped
|
|
493
|
-
commander: wrappedCommander,
|
|
494
|
-
|
|
495
|
-
// Original commander (use carefully)
|
|
496
|
-
rawCommander: commander,
|
|
497
|
-
|
|
498
|
-
// Trigger name for debugging
|
|
499
|
-
triggerName,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
458
|
|
|
503
459
|
/**
|
|
504
460
|
* Handle navigation start - stop current action first
|
|
505
461
|
*/
|
|
506
462
|
async function onNavigationStart() {
|
|
463
|
+
pendingStart = null;
|
|
507
464
|
await stopCurrentAction();
|
|
508
465
|
}
|
|
509
466
|
|
|
510
467
|
/**
|
|
511
468
|
* Handle page ready - start matching action
|
|
512
469
|
*/
|
|
513
|
-
async function onPageReady({ url }, commander) {
|
|
514
|
-
await startAction(url, commander);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
470
|
/**
|
|
518
471
|
* Check if an action is currently running
|
|
519
472
|
*/
|
|
520
|
-
|
|
521
|
-
return isActionRunning;
|
|
522
|
-
}
|
|
473
|
+
const isRunning = () => isActionRunning;
|
|
523
474
|
|
|
524
475
|
/**
|
|
525
476
|
* Get current trigger name
|
|
526
477
|
*/
|
|
527
|
-
|
|
528
|
-
return currentTrigger?.name || null;
|
|
529
|
-
}
|
|
478
|
+
const getCurrentTriggerName = () => currentTrigger?.name || null;
|
|
530
479
|
|
|
531
480
|
/**
|
|
532
481
|
* Initialize - connect to navigation manager
|
|
533
482
|
* @param {Object} commander - BrowserCommander instance
|
|
534
483
|
*/
|
|
535
484
|
function initialize(commander) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
485
|
+
subscribeReadiness({
|
|
486
|
+
commander,
|
|
487
|
+
navigationManager,
|
|
488
|
+
subscriptions,
|
|
489
|
+
onNavigationStart,
|
|
490
|
+
enqueue: (url, readyOn) => {
|
|
491
|
+
startQueue = startQueue.then(() =>
|
|
492
|
+
startAction(url, commander, readyOn)
|
|
493
|
+
);
|
|
494
|
+
},
|
|
495
|
+
});
|
|
543
496
|
|
|
544
497
|
log.debug(() => '📋 PageTriggerManager initialized');
|
|
545
498
|
}
|
|
@@ -547,12 +500,18 @@ export function createPageTriggerManager(options = {}) {
|
|
|
547
500
|
/**
|
|
548
501
|
* Cleanup
|
|
549
502
|
*/
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
503
|
+
const cleanup = createTriggerDestroy({
|
|
504
|
+
subscriptions,
|
|
505
|
+
getQueue: () => startQueue,
|
|
506
|
+
stopCurrentAction,
|
|
507
|
+
triggers,
|
|
508
|
+
log,
|
|
509
|
+
});
|
|
510
|
+
const destroy = async () => {
|
|
511
|
+
destroyed = true;
|
|
512
|
+
pendingStart = null;
|
|
513
|
+
await cleanup();
|
|
514
|
+
};
|
|
556
515
|
|
|
557
516
|
return {
|
|
558
517
|
pageTrigger,
|
|
@@ -567,3 +526,168 @@ export function createPageTriggerManager(options = {}) {
|
|
|
567
526
|
isActionStoppedError,
|
|
568
527
|
};
|
|
569
528
|
}
|
|
529
|
+
|
|
530
|
+
function subscribeReadiness({
|
|
531
|
+
commander,
|
|
532
|
+
navigationManager,
|
|
533
|
+
subscriptions,
|
|
534
|
+
onNavigationStart,
|
|
535
|
+
enqueue,
|
|
536
|
+
}) {
|
|
537
|
+
function subscribe(source, event, listener) {
|
|
538
|
+
source?.on?.(event, listener);
|
|
539
|
+
subscriptions.push(() => source?.off?.(event, listener));
|
|
540
|
+
}
|
|
541
|
+
subscribe(navigationManager, 'onBeforeNavigate', onNavigationStart);
|
|
542
|
+
subscribe(navigationManager, 'onPageReady', (event) =>
|
|
543
|
+
enqueue(event.url, 'networkidle')
|
|
544
|
+
);
|
|
545
|
+
// onNavigationStart happens after old actions have been stopped. Using
|
|
546
|
+
// onUrlChange here would start an action that cleanup immediately aborts.
|
|
547
|
+
subscribe(navigationManager, 'onNavigationStart', (event) =>
|
|
548
|
+
enqueue(event.url, 'urlchange')
|
|
549
|
+
);
|
|
550
|
+
for (const readyOn of ['domcontentloaded', 'load']) {
|
|
551
|
+
subscribe(commander.page, readyOn, () =>
|
|
552
|
+
enqueue(commander.page.url(), readyOn)
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function createTriggerDestroy({
|
|
558
|
+
subscriptions,
|
|
559
|
+
getQueue,
|
|
560
|
+
stopCurrentAction,
|
|
561
|
+
triggers,
|
|
562
|
+
log,
|
|
563
|
+
}) {
|
|
564
|
+
return async () => {
|
|
565
|
+
for (const unsubscribe of subscriptions.splice(0)) {
|
|
566
|
+
unsubscribe();
|
|
567
|
+
}
|
|
568
|
+
await getQueue();
|
|
569
|
+
await stopCurrentAction();
|
|
570
|
+
triggers.length = 0;
|
|
571
|
+
log.debug(() => '📋 PageTriggerManager destroyed');
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function createActionContext(options, log) {
|
|
576
|
+
const { url, abortSignal, commander, triggerName } = options;
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Check if stopped and throw if so
|
|
580
|
+
*/
|
|
581
|
+
function checkStopped() {
|
|
582
|
+
if (abortSignal.aborted) {
|
|
583
|
+
throw new ActionStoppedError(`Action "${triggerName}" stopped`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Wrap async function to check abort before and after
|
|
589
|
+
*/
|
|
590
|
+
function wrapAsync(fn) {
|
|
591
|
+
return async (...args) => {
|
|
592
|
+
checkStopped();
|
|
593
|
+
const result = await fn(...args);
|
|
594
|
+
checkStopped();
|
|
595
|
+
return result;
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Create abort-aware loop helper
|
|
601
|
+
* Use this instead of for/while loops for stoppability
|
|
602
|
+
*/
|
|
603
|
+
async function forEach(items, callback) {
|
|
604
|
+
for (let i = 0; i < items.length; i++) {
|
|
605
|
+
checkStopped();
|
|
606
|
+
await callback(items[i], i, items);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Wait with abort support
|
|
612
|
+
*/
|
|
613
|
+
async function wait(ms) {
|
|
614
|
+
checkStopped();
|
|
615
|
+
await new Promise((resolve, reject) => {
|
|
616
|
+
const timeout = setTimeout(resolve, ms);
|
|
617
|
+
abortSignal.addEventListener(
|
|
618
|
+
'abort',
|
|
619
|
+
() => {
|
|
620
|
+
clearTimeout(timeout);
|
|
621
|
+
reject(new ActionStoppedError());
|
|
622
|
+
},
|
|
623
|
+
{ once: true }
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Register cleanup callback (called when action stops)
|
|
630
|
+
*/
|
|
631
|
+
const cleanupCallbacks = [];
|
|
632
|
+
function onCleanup(callback) {
|
|
633
|
+
const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
|
|
634
|
+
const onAbort = async () => {
|
|
635
|
+
try {
|
|
636
|
+
await callback();
|
|
637
|
+
} catch (e) {
|
|
638
|
+
log.debug(() => `⚠️ Cleanup error: ${e.message}`);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
642
|
+
return () => {
|
|
643
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
644
|
+
unsubscribe();
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Wrap all commander methods to be abort-aware
|
|
649
|
+
const wrappedCommander = {};
|
|
650
|
+
for (const [key, value] of Object.entries(commander)) {
|
|
651
|
+
if (
|
|
652
|
+
typeof value === 'function' &&
|
|
653
|
+
key !== 'destroy' &&
|
|
654
|
+
key !== 'pageTrigger'
|
|
655
|
+
) {
|
|
656
|
+
wrappedCommander[key] = wrapAsync(value);
|
|
657
|
+
} else {
|
|
658
|
+
wrappedCommander[key] = value;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
// URL this action is running for
|
|
664
|
+
url,
|
|
665
|
+
|
|
666
|
+
// Abort signal - use with fetch() or custom abort logic
|
|
667
|
+
abortSignal,
|
|
668
|
+
|
|
669
|
+
// Check if action should stop
|
|
670
|
+
isStopped: () => abortSignal.aborted,
|
|
671
|
+
|
|
672
|
+
// Throw if stopped - call this in loops
|
|
673
|
+
checkStopped,
|
|
674
|
+
|
|
675
|
+
// Abort-aware iteration helper
|
|
676
|
+
forEach,
|
|
677
|
+
|
|
678
|
+
// Abort-aware wait
|
|
679
|
+
wait,
|
|
680
|
+
|
|
681
|
+
// Register cleanup callback
|
|
682
|
+
onCleanup,
|
|
683
|
+
|
|
684
|
+
// Wrapped commander - all methods throw ActionStoppedError if stopped
|
|
685
|
+
commander: wrappedCommander,
|
|
686
|
+
|
|
687
|
+
// Original commander (use carefully)
|
|
688
|
+
rawCommander: commander,
|
|
689
|
+
|
|
690
|
+
// Trigger name for debugging
|
|
691
|
+
triggerName,
|
|
692
|
+
};
|
|
693
|
+
}
|
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 });
|