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.
Files changed (54) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +2 -0
  4. package/package.json +5 -3
  5. package/src/bindings.js +19 -0
  6. package/src/browser/attach/snapshot-launch.js +8 -6
  7. package/src/browser/attach/snapshot.js +39 -2
  8. package/src/browser/browser-cookie-database.js +71 -2
  9. package/src/browser/browser-cookie-session.js +183 -0
  10. package/src/browser/browser-cookies.js +29 -0
  11. package/src/browser/debugging-port.js +4 -2
  12. package/src/browser/default-browser.js +33 -9
  13. package/src/browser/launch-diagnostics.js +102 -0
  14. package/src/browser/launcher.js +28 -8
  15. package/src/browser/migration/sqlite-snapshot.js +5 -27
  16. package/src/browser/navigation.js +173 -98
  17. package/src/browser/real-browser.js +135 -68
  18. package/src/browser/session-cookies.js +78 -0
  19. package/src/browser/session-persistence.js +72 -0
  20. package/src/browser/storage-state.js +32 -6
  21. package/src/core/dialog-manager.js +7 -0
  22. package/src/core/managed-navigation.js +101 -0
  23. package/src/core/navigation-manager.js +62 -78
  24. package/src/core/navigation-operation.js +25 -0
  25. package/src/core/navigation-readiness.js +40 -16
  26. package/src/core/network-tracker.js +39 -92
  27. package/src/core/page-session.js +3 -2
  28. package/src/core/page-trigger-manager.js +15 -12
  29. package/src/core/readiness.js +67 -13
  30. package/src/core/subscriptions.js +20 -0
  31. package/src/core/webdriver-page.js +38 -0
  32. package/src/elements/locators.js +35 -5
  33. package/src/elements/reusable.js +92 -0
  34. package/src/elements/visibility.js +6 -11
  35. package/src/exports.js +14 -0
  36. package/src/factory.js +4 -4
  37. package/src/high-level/universal-logic.js +57 -21
  38. package/src/interactions/click.js +3 -0
  39. package/src/interactions/scroll.js +8 -2
  40. package/src/utilities/subprocess.js +15 -4
  41. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  42. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  43. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  44. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  45. package/tests/unit/browser/default-browser.test.js +18 -0
  46. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  47. package/tests/unit/browser/real-browser.test.js +1 -1
  48. package/tests/unit/browser/session-workflows.test.js +206 -0
  49. package/tests/unit/browser/storage-state.test.js +7 -2
  50. package/tests/unit/core/navigation-budget.test.js +120 -0
  51. package/tests/unit/core/navigation-manager.test.js +23 -1
  52. package/tests/unit/core/webdriver-page.test.js +33 -0
  53. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  54. 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 deadline = createDeadline({ timeout });
101
- const result = await runReadinessChecks({
102
- checks: checks ?? defaultReadinessChecks(config),
103
- deadline,
104
- context: {
105
- page,
106
- engine,
107
- log,
108
- networkTracker,
109
- getAdapter,
110
- onUrlSample,
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
- onReady();
137
+ if (notify()) {
138
+ onReady();
139
+ }
118
140
  log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
119
141
  } else {
120
- onNotReady();
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
- async function waitForNetworkIdle(opts = {}) {
210
- const { timeout = 30000, idleTime = idleTimeout } = opts;
211
-
212
- const currentNavId = navigationId;
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 resolved = false;
226
- let checkTimer = null;
227
- let timeoutTimer = null;
228
-
229
- const cleanup = () => {
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
- // Timeout handler
240
- timeoutTimer = setTimeout(() => {
241
- if (!resolved) {
242
- cleanup();
243
- log.debug(
244
- () =>
245
- `⚠️ Network idle timeout after ${timeout}ms (${pendingRequests.size} pending)`
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
- // Check for timed out requests and remove them
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
- // Wait for idle time to confirm
292
- await new Promise((r) => setTimeout(r, idleTime));
293
-
294
- if (
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
- }, 100);
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
- if (listeners[event]) {
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
- if (listeners[event]) {
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
  /**
@@ -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.push(callback);
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.push(callback);
440
- abortSignal.addEventListener(
441
- 'abort',
442
- async () => {
443
- try {
444
- await callback();
445
- } catch (e) {
446
- log.debug(() => `⚠️ Cleanup error: ${e.message}`);
447
- }
448
- },
449
- { once: true }
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
@@ -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 new Promise((resolve) => setTimeout(resolve, capped));
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(deadline, run) {
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
- outcome = await check.run({ ...context, deadline });
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 ready = failed.length === 0 && pending.length === 0;
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: ready
468
- ? READINESS_STATUS.READY
469
- : timedOut
470
- ? READINESS_STATUS.TIMED_OUT
471
- : READINESS_STATUS.FAILED,
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
  }
@@ -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
- return createPlaywrightLocator({ page, selector });
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 await page.$(selector);
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({ page, engine, selector });
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 page.$(selector);
122
+ const element = await getLocatorOrElement(options);
93
123
  if (!element) {
94
124
  throw new Error(`Element not found after waiting: ${selector}`);
95
125
  }