browser-commander 0.26.3 → 0.27.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 +6 -0
- package/README.md +2 -0
- package/package.json +5 -3
- 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/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launcher.js +28 -8
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/real-browser.js +135 -68
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/storage-state.js +32 -6
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +62 -78
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +40 -16
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +15 -12
- package/src/core/readiness.js +67 -13
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +35 -5
- package/src/elements/reusable.js +92 -0
- package/src/elements/visibility.js +6 -11
- package/src/exports.js +14 -0
- package/src/factory.js +4 -4
- package/src/high-level/universal-logic.js +57 -21
- package/src/interactions/click.js +3 -0
- package/src/interactions/scroll.js +8 -2
- package/src/utilities/subprocess.js +15 -4
- 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-diagnostics.test.js +52 -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/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/traces/links.test.js +2 -2
|
@@ -97,27 +97,51 @@ export function createReadinessWaiter(options = {}) {
|
|
|
97
97
|
|
|
98
98
|
log.debug(() => `⏳ Waiting for page ready (${reason})...`);
|
|
99
99
|
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const parentSignal = opts.deadline?.signal ?? opts.signal;
|
|
102
|
+
const signal = parentSignal
|
|
103
|
+
? AbortSignal.any([parentSignal, controller.signal])
|
|
104
|
+
: controller.signal;
|
|
105
|
+
const deadline = {
|
|
106
|
+
...(opts.deadline ?? createDeadline({ timeout })),
|
|
107
|
+
signal,
|
|
108
|
+
};
|
|
109
|
+
const notify = () =>
|
|
110
|
+
!opts.observeOnly &&
|
|
111
|
+
!parentSignal?.aborted &&
|
|
112
|
+
(opts.shouldNotify?.() ?? true);
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
result = await runReadinessChecks({
|
|
116
|
+
checks: checks ?? defaultReadinessChecks(config),
|
|
117
|
+
deadline,
|
|
118
|
+
context: {
|
|
119
|
+
page,
|
|
120
|
+
engine,
|
|
121
|
+
log,
|
|
122
|
+
networkTracker,
|
|
123
|
+
getAdapter,
|
|
124
|
+
onUrlSample: (url) => {
|
|
125
|
+
if (notify()) {
|
|
126
|
+
onUrlSample?.(url);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
controller.abort();
|
|
133
|
+
}
|
|
114
134
|
const outcome = { ...result, reason, ...getState() };
|
|
115
135
|
|
|
116
136
|
if (outcome.ready) {
|
|
117
|
-
|
|
137
|
+
if (notify()) {
|
|
138
|
+
onReady();
|
|
139
|
+
}
|
|
118
140
|
log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
|
|
119
141
|
} else {
|
|
120
|
-
|
|
142
|
+
if (notify()) {
|
|
143
|
+
onNotReady();
|
|
144
|
+
}
|
|
121
145
|
log.debug(
|
|
122
146
|
() =>
|
|
123
147
|
`⚠️ Page not ready (${reason}): ${outcome.status} after ` +
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeCallback, subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* NetworkTracker - Track all HTTP requests and wait for network idle
|
|
3
4
|
*
|
|
@@ -206,101 +207,54 @@ export function createNetworkTracker(options = {}) {
|
|
|
206
207
|
* @param {number} options.idleTime - Time network must be idle (default: idleTimeout)
|
|
207
208
|
* @returns {Promise<boolean>} - True if idle, false if timeout
|
|
208
209
|
*/
|
|
209
|
-
|
|
210
|
-
const { timeout = 30000, idleTime = idleTimeout } = opts;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// If already idle, wait for idle time
|
|
215
|
-
if (pendingRequests.size === 0) {
|
|
216
|
-
await new Promise((r) => setTimeout(r, idleTime));
|
|
217
|
-
|
|
218
|
-
// Check if still idle and no navigation happened
|
|
219
|
-
if (pendingRequests.size === 0 && navigationId === currentNavId) {
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
210
|
+
function waitForNetworkIdle(opts = {}) {
|
|
211
|
+
const { timeout = 30000, idleTime = idleTimeout, signal } = opts;
|
|
212
|
+
if (signal?.aborted || timeout <= 0) {
|
|
213
|
+
return false;
|
|
222
214
|
}
|
|
223
|
-
|
|
215
|
+
const currentNavId = navigationId;
|
|
216
|
+
const started = performance.now();
|
|
217
|
+
let quietSince = pendingRequests.size === 0 ? started : null;
|
|
224
218
|
return new Promise((resolve) => {
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (checkTimer) {
|
|
231
|
-
clearInterval(checkTimer);
|
|
232
|
-
}
|
|
233
|
-
if (timeoutTimer) {
|
|
234
|
-
clearTimeout(timeoutTimer);
|
|
235
|
-
}
|
|
236
|
-
resolved = true;
|
|
219
|
+
let timer;
|
|
220
|
+
const finish = (value) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
signal?.removeEventListener('abort', onAbort);
|
|
223
|
+
resolve(value);
|
|
237
224
|
};
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
// Log stuck requests
|
|
249
|
-
if (pendingRequests.size > 0) {
|
|
250
|
-
const stuckRequests = [];
|
|
251
|
-
for (const [key, req] of pendingRequests) {
|
|
252
|
-
const startTime = requestStartTimes.get(key);
|
|
253
|
-
const duration = Date.now() - startTime;
|
|
254
|
-
const url = typeof req.url === 'function' ? req.url() : req.url;
|
|
255
|
-
stuckRequests.push(
|
|
256
|
-
` ${url.substring(0, 60)}... (${duration}ms)`
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
log.debug(() => `⚠️ Stuck requests:\n${stuckRequests.join('\n')}`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
resolve(false);
|
|
263
|
-
}
|
|
264
|
-
}, timeout);
|
|
265
|
-
|
|
266
|
-
// Check periodically for idle state
|
|
267
|
-
checkTimer = setInterval(async () => {
|
|
268
|
-
if (resolved) {
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Check for navigation change (abort wait)
|
|
273
|
-
if (navigationId !== currentNavId) {
|
|
274
|
-
cleanup();
|
|
275
|
-
resolve(false);
|
|
225
|
+
const onAbort = () => finish(false);
|
|
226
|
+
const sample = () => {
|
|
227
|
+
const now = performance.now();
|
|
228
|
+
if (
|
|
229
|
+
signal?.aborted ||
|
|
230
|
+
navigationId !== currentNavId ||
|
|
231
|
+
now - started >= timeout
|
|
232
|
+
) {
|
|
233
|
+
finish(false);
|
|
276
234
|
return;
|
|
277
235
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const now = Date.now();
|
|
281
|
-
for (const [key, startTime] of requestStartTimes) {
|
|
282
|
-
if (now - startTime > requestTimeout) {
|
|
283
|
-
log.debug(() => `⚠️ Request timed out, removing: ${key}`);
|
|
236
|
+
for (const [key, requestStart] of requestStartTimes) {
|
|
237
|
+
if (Date.now() - requestStart > requestTimeout) {
|
|
284
238
|
pendingRequests.delete(key);
|
|
285
239
|
requestStartTimes.delete(key);
|
|
286
240
|
}
|
|
287
241
|
}
|
|
288
|
-
|
|
289
|
-
// Check if idle
|
|
290
242
|
if (pendingRequests.size === 0) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
!resolved &&
|
|
296
|
-
pendingRequests.size === 0 &&
|
|
297
|
-
navigationId === currentNavId
|
|
298
|
-
) {
|
|
299
|
-
cleanup();
|
|
300
|
-
resolve(true);
|
|
243
|
+
quietSince ??= now;
|
|
244
|
+
if (now - quietSince >= idleTime) {
|
|
245
|
+
finish(true);
|
|
246
|
+
return;
|
|
301
247
|
}
|
|
248
|
+
} else {
|
|
249
|
+
quietSince = null;
|
|
302
250
|
}
|
|
303
|
-
|
|
251
|
+
timer = setTimeout(
|
|
252
|
+
sample,
|
|
253
|
+
Math.max(1, Math.min(25, timeout - (now - started)))
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
257
|
+
sample();
|
|
304
258
|
});
|
|
305
259
|
}
|
|
306
260
|
|
|
@@ -327,21 +281,14 @@ export function createNetworkTracker(options = {}) {
|
|
|
327
281
|
* Add event listener
|
|
328
282
|
*/
|
|
329
283
|
function on(event, callback) {
|
|
330
|
-
|
|
331
|
-
listeners[event].push(callback);
|
|
332
|
-
}
|
|
284
|
+
return subscribeCallbacks(listeners[event], callback);
|
|
333
285
|
}
|
|
334
286
|
|
|
335
287
|
/**
|
|
336
288
|
* Remove event listener
|
|
337
289
|
*/
|
|
338
290
|
function off(event, callback) {
|
|
339
|
-
|
|
340
|
-
const index = listeners[event].indexOf(callback);
|
|
341
|
-
if (index !== -1) {
|
|
342
|
-
listeners[event].splice(index, 1);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
291
|
+
removeCallback(listeners[event], callback);
|
|
345
292
|
}
|
|
346
293
|
|
|
347
294
|
/**
|
package/src/core/page-session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subscribeCallbacks } from './subscriptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* PageSession - Abstraction for page lifecycle management
|
|
3
4
|
*
|
|
@@ -151,9 +152,9 @@ export function createPageSessionFactory(options = {}) {
|
|
|
151
152
|
log.debug(
|
|
152
153
|
() => `⚠️ Cannot register cleanup on inactive session "${name}"`
|
|
153
154
|
);
|
|
154
|
-
return;
|
|
155
|
+
return () => {};
|
|
155
156
|
}
|
|
156
|
-
cleanupCallbacks
|
|
157
|
+
return subscribeCallbacks(cleanupCallbacks, callback);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/**
|
|
@@ -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
|
*
|
|
@@ -436,18 +438,19 @@ export function createPageTriggerManager(options = {}) {
|
|
|
436
438
|
*/
|
|
437
439
|
const cleanupCallbacks = [];
|
|
438
440
|
function onCleanup(callback) {
|
|
439
|
-
cleanupCallbacks
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
};
|
|
451
454
|
}
|
|
452
455
|
|
|
453
456
|
// Wrap all commander methods to be abort-aware
|
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
|
|
|
@@ -428,7 +466,7 @@ export async function runReadinessChecks(options = {}) {
|
|
|
428
466
|
const evidence = [];
|
|
429
467
|
|
|
430
468
|
for (const [index, check] of checks.entries()) {
|
|
431
|
-
if (deadline.expired() && failed.length > 0) {
|
|
469
|
+
if (deadline.signal?.aborted || (deadline.expired() && failed.length > 0)) {
|
|
432
470
|
pending.push(...checks.slice(index).map((entry) => entry.name));
|
|
433
471
|
break;
|
|
434
472
|
}
|
|
@@ -436,7 +474,20 @@ export async function runReadinessChecks(options = {}) {
|
|
|
436
474
|
const startedAtMs = deadline.elapsedMs();
|
|
437
475
|
let outcome;
|
|
438
476
|
try {
|
|
439
|
-
|
|
477
|
+
const bounded = await runWithinDeadline(deadline, () =>
|
|
478
|
+
check.run({ ...context, deadline })
|
|
479
|
+
);
|
|
480
|
+
outcome =
|
|
481
|
+
bounded.timedOut || bounded.interrupted
|
|
482
|
+
? {
|
|
483
|
+
satisfied: false,
|
|
484
|
+
detail: {
|
|
485
|
+
reason: bounded.interrupted
|
|
486
|
+
? 'interrupted'
|
|
487
|
+
: 'deadline reached',
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
: bounded.value;
|
|
440
491
|
} catch (error) {
|
|
441
492
|
outcome = { satisfied: false, detail: { error: error.message } };
|
|
442
493
|
}
|
|
@@ -460,15 +511,18 @@ export async function runReadinessChecks(options = {}) {
|
|
|
460
511
|
}
|
|
461
512
|
}
|
|
462
513
|
|
|
463
|
-
const
|
|
514
|
+
const interrupted = Boolean(deadline.signal?.aborted);
|
|
515
|
+
const ready = !interrupted && failed.length === 0 && pending.length === 0;
|
|
464
516
|
const timedOut = !ready && deadline.expired();
|
|
465
517
|
|
|
466
518
|
return {
|
|
467
|
-
status:
|
|
468
|
-
? READINESS_STATUS.
|
|
469
|
-
:
|
|
470
|
-
? READINESS_STATUS.
|
|
471
|
-
:
|
|
519
|
+
status: interrupted
|
|
520
|
+
? READINESS_STATUS.INTERRUPTED
|
|
521
|
+
: ready
|
|
522
|
+
? READINESS_STATUS.READY
|
|
523
|
+
: timedOut
|
|
524
|
+
? READINESS_STATUS.TIMED_OUT
|
|
525
|
+
: READINESS_STATUS.FAILED,
|
|
472
526
|
ready,
|
|
473
527
|
checks: { satisfied, failed, skipped, pending },
|
|
474
528
|
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
|
@@ -37,7 +37,10 @@ export function createPlaywrightLocator(options = {}) {
|
|
|
37
37
|
* @returns {Promise<Object|null>} - Locator for Playwright, Element for Puppeteer (can be null)
|
|
38
38
|
*/
|
|
39
39
|
export async function getLocatorOrElement(options = {}) {
|
|
40
|
-
const { page, engine, selector } = options;
|
|
40
|
+
const { page, engine, selector, index } = options;
|
|
41
|
+
if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
|
|
42
|
+
throw new TypeError('index must be a non-negative integer');
|
|
43
|
+
}
|
|
41
44
|
|
|
42
45
|
if (!selector) {
|
|
43
46
|
throw new Error('selector is required in options');
|
|
@@ -47,10 +50,13 @@ export async function getLocatorOrElement(options = {}) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
if (engine === 'playwright') {
|
|
50
|
-
|
|
53
|
+
const locator = createPlaywrightLocator({ page, selector });
|
|
54
|
+
return index === undefined ? locator : locator.nth(index);
|
|
51
55
|
} else {
|
|
52
56
|
// For Puppeteer, return element (can be null if doesn't exist)
|
|
53
|
-
return
|
|
57
|
+
return index === undefined
|
|
58
|
+
? await page.$(selector)
|
|
59
|
+
: ((await page.$$(selector))[index] ?? null);
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -81,15 +87,39 @@ export async function waitForLocatorOrElement(options = {}) {
|
|
|
81
87
|
|
|
82
88
|
try {
|
|
83
89
|
if (engine === 'playwright') {
|
|
84
|
-
const locator = await getLocatorOrElement(
|
|
90
|
+
const locator = await getLocatorOrElement(options);
|
|
85
91
|
// Use .first() to handle multiple matches (Playwright strict mode)
|
|
86
92
|
const firstLocator = locator.first();
|
|
87
93
|
await firstLocator.waitFor({ state: 'visible', timeout });
|
|
88
94
|
return firstLocator;
|
|
89
95
|
} else {
|
|
96
|
+
if (typeof selector !== 'string') {
|
|
97
|
+
await waitForVisible({ engine, locatorOrElement: selector, timeout });
|
|
98
|
+
return selector;
|
|
99
|
+
}
|
|
100
|
+
if (options.index !== undefined && typeof selector === 'string') {
|
|
101
|
+
if (!Number.isInteger(options.index) || options.index < 0) {
|
|
102
|
+
throw new TypeError('index must be a non-negative integer');
|
|
103
|
+
}
|
|
104
|
+
await page.waitForFunction(
|
|
105
|
+
(sel, index) => {
|
|
106
|
+
const element = document.querySelectorAll(sel)[index];
|
|
107
|
+
return (
|
|
108
|
+
element &&
|
|
109
|
+
element.getBoundingClientRect().width > 0 &&
|
|
110
|
+
element.getBoundingClientRect().height > 0 &&
|
|
111
|
+
window.getComputedStyle(element).visibility !== 'hidden'
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
{ timeout },
|
|
115
|
+
selector,
|
|
116
|
+
options.index
|
|
117
|
+
);
|
|
118
|
+
return getLocatorOrElement(options);
|
|
119
|
+
}
|
|
90
120
|
// Puppeteer: wait for selector to be visible (returns first match by default)
|
|
91
121
|
await page.waitForSelector(selector, { visible: true, timeout });
|
|
92
|
-
const element = await
|
|
122
|
+
const element = await getLocatorOrElement(options);
|
|
93
123
|
if (!element) {
|
|
94
124
|
throw new Error(`Element not found after waiting: ${selector}`);
|
|
95
125
|
}
|