craftdriver 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/dist/lib/bidi/connection.d.ts +1 -1
- package/dist/lib/bidi/connection.d.ts.map +1 -1
- package/dist/lib/bidi/connection.js +3 -2
- package/dist/lib/bidi/connection.js.map +1 -1
- package/dist/lib/bidi/index.d.ts +40 -3
- package/dist/lib/bidi/index.d.ts.map +1 -1
- package/dist/lib/bidi/index.js +55 -18
- package/dist/lib/bidi/index.js.map +1 -1
- package/dist/lib/bidi/logs.d.ts.map +1 -1
- package/dist/lib/bidi/logs.js +7 -2
- package/dist/lib/bidi/logs.js.map +1 -1
- package/dist/lib/bidi/network.d.ts +9 -0
- package/dist/lib/bidi/network.d.ts.map +1 -1
- package/dist/lib/bidi/network.js +26 -11
- package/dist/lib/bidi/network.js.map +1 -1
- package/dist/lib/browser.d.ts +51 -4
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +215 -66
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/browserContext.d.ts +7 -2
- package/dist/lib/browserContext.d.ts.map +1 -1
- package/dist/lib/browserContext.js +17 -8
- package/dist/lib/browserContext.js.map +1 -1
- package/dist/lib/builder.d.ts +2 -1
- package/dist/lib/builder.d.ts.map +1 -1
- package/dist/lib/builder.js +35 -5
- package/dist/lib/builder.js.map +1 -1
- package/dist/lib/driver.d.ts.map +1 -1
- package/dist/lib/driver.js +21 -6
- package/dist/lib/driver.js.map +1 -1
- package/dist/lib/driverManager.d.ts +6 -0
- package/dist/lib/driverManager.d.ts.map +1 -1
- package/dist/lib/driverManager.js +97 -22
- package/dist/lib/driverManager.js.map +1 -1
- package/dist/lib/expect.d.ts.map +1 -1
- package/dist/lib/expect.js +91 -229
- package/dist/lib/expect.js.map +1 -1
- package/dist/lib/firefox.d.ts.map +1 -1
- package/dist/lib/firefox.js +2 -1
- package/dist/lib/firefox.js.map +1 -1
- package/dist/lib/frame.d.ts.map +1 -1
- package/dist/lib/frame.js +4 -3
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/http.d.ts +7 -0
- package/dist/lib/http.d.ts.map +1 -1
- package/dist/lib/http.js +37 -0
- package/dist/lib/http.js.map +1 -1
- package/dist/lib/loadState.d.ts +23 -0
- package/dist/lib/loadState.d.ts.map +1 -0
- package/dist/lib/loadState.js +25 -0
- package/dist/lib/loadState.js.map +1 -0
- package/dist/lib/locator.d.ts.map +1 -1
- package/dist/lib/locator.js +11 -4
- package/dist/lib/locator.js.map +1 -1
- package/dist/lib/mouse.d.ts.map +1 -1
- package/dist/lib/mouse.js +2 -1
- package/dist/lib/mouse.js.map +1 -1
- package/dist/lib/page.d.ts +14 -1
- package/dist/lib/page.d.ts.map +1 -1
- package/dist/lib/page.js +41 -9
- package/dist/lib/page.js.map +1 -1
- package/dist/lib/service.d.ts +1 -0
- package/dist/lib/service.d.ts.map +1 -1
- package/dist/lib/service.js +21 -3
- package/dist/lib/service.js.map +1 -1
- package/dist/lib/timing.d.ts +104 -0
- package/dist/lib/timing.d.ts.map +1 -0
- package/dist/lib/timing.js +108 -0
- package/dist/lib/timing.js.map +1 -0
- package/dist/lib/wait.d.ts.map +1 -1
- package/dist/lib/wait.js +5 -4
- package/dist/lib/wait.js.map +1 -1
- package/docs/bidi-features.md +10 -2
- package/docs/browser-api.md +2 -1
- package/docs/browser-context.md +11 -14
- package/docs/dialogs.md +14 -3
- package/docs/driver-configuration.md +126 -8
- package/docs/emulation.md +8 -4
- package/docs/error-codes.md +8 -3
- package/docs/keyboard-mouse.md +20 -7
- package/docs/session-management.md +1 -1
- package/package.json +4 -1
- package/skills/craftdriver/SKILL.md +3 -2
package/dist/lib/browser.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ChromeService } from './chrome.js';
|
|
|
3
3
|
import { FirefoxService } from './firefox.js';
|
|
4
4
|
import { By } from './by.js';
|
|
5
5
|
import { until } from './wait.js';
|
|
6
|
+
import { DEFAULT_ELEMENT_TIMEOUT_MS, DEFAULT_NAVIGATION_TIMEOUT_MS, STATE_POLL_INTERVAL_MS, NETWORK_IDLE_SETTLE_MS, PORT_RELEASE_DELAY_MS, BIDI_CONNECT_MAX_ATTEMPTS, BIDI_CONNECT_BACKOFF_STEP_MS, EVAL_REALM_RETRY_ATTEMPTS, EVAL_REALM_RETRY_DELAY_MS, } from './timing.js';
|
|
6
7
|
import { ElementHandle } from './elementHandle.js';
|
|
7
8
|
import { Locator } from './locator.js';
|
|
8
9
|
import { expectSelector } from './expect.js';
|
|
@@ -17,6 +18,7 @@ import { ActionsBuilder } from './actions.js';
|
|
|
17
18
|
import { BiDiSession, SessionStateManager, } from './bidi/index.js';
|
|
18
19
|
import { Frame } from './frame.js';
|
|
19
20
|
import { Page } from './page.js';
|
|
21
|
+
import { bidiWaitFor } from './loadState.js';
|
|
20
22
|
import { BrowserContext } from './browserContext.js';
|
|
21
23
|
import { Tracer } from './tracing.js';
|
|
22
24
|
import { A11y } from './a11y.js';
|
|
@@ -170,18 +172,25 @@ export class Browser {
|
|
|
170
172
|
_defaultContext;
|
|
171
173
|
/** Cache of {@link BrowserContext} wrappers keyed by user-context id. */
|
|
172
174
|
_contextsById = new Map();
|
|
175
|
+
/** Top-level browsing-context ids keyed to their BiDi user-context id. */
|
|
176
|
+
_topLevelContextUserContexts = new Map();
|
|
177
|
+
_topLevelContextTracking;
|
|
178
|
+
_topLevelContextTrackingOffs = [];
|
|
179
|
+
_topLevelContextCacheVersion = 0;
|
|
173
180
|
_logs;
|
|
174
181
|
_tracer;
|
|
175
182
|
_storage;
|
|
176
183
|
_a11y;
|
|
177
184
|
_clock;
|
|
185
|
+
/** Browser-scoped BiDi preload scripts registered via addInitScript()/clock. */
|
|
186
|
+
_browserInitScriptIds = new Set();
|
|
178
187
|
_downloadsDir;
|
|
179
188
|
_driverService;
|
|
180
189
|
_browserName = 'chrome';
|
|
181
190
|
/** Active emulation overrides, re-applied to new top-level contexts. */
|
|
182
191
|
_emulation = {};
|
|
183
192
|
/** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
|
|
184
|
-
defaults = { timeout:
|
|
193
|
+
defaults = { timeout: DEFAULT_ELEMENT_TIMEOUT_MS, navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS };
|
|
185
194
|
constructor(driver) {
|
|
186
195
|
this.driver = driver;
|
|
187
196
|
this.keyboard = new Keyboard(this.driver);
|
|
@@ -203,6 +212,8 @@ export class Browser {
|
|
|
203
212
|
}
|
|
204
213
|
/** Returns the current default timeout. Used as a live getter passed to ElementHandle / expectSelector. */
|
|
205
214
|
getDefaultTimeout = () => this.defaults.timeout;
|
|
215
|
+
hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
|
|
216
|
+
hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
|
|
206
217
|
static async launch(options = {}) {
|
|
207
218
|
const name = options.browserName ?? 'chrome';
|
|
208
219
|
const isFirefox = name === 'firefox';
|
|
@@ -220,7 +231,7 @@ export class Browser {
|
|
|
220
231
|
let caps = {};
|
|
221
232
|
if (isChromeFamily) {
|
|
222
233
|
const chromeOptions = {
|
|
223
|
-
args: isHeadless ? ['--headless=new'] : [],
|
|
234
|
+
args: [...(isHeadless ? ['--headless=new'] : []), ...(options.args ?? [])],
|
|
224
235
|
prefs: {
|
|
225
236
|
'download.default_directory': downloadsDir,
|
|
226
237
|
'download.prompt_for_download': false,
|
|
@@ -260,6 +271,8 @@ export class Browser {
|
|
|
260
271
|
const firefoxArgs = [];
|
|
261
272
|
if (isHeadless)
|
|
262
273
|
firefoxArgs.push('-headless');
|
|
274
|
+
if (options.args?.length)
|
|
275
|
+
firefoxArgs.push(...options.args);
|
|
263
276
|
caps['moz:firefoxOptions'] = {
|
|
264
277
|
args: firefoxArgs,
|
|
265
278
|
prefs: {
|
|
@@ -302,6 +315,10 @@ export class Browser {
|
|
|
302
315
|
const wsUrl = driver.__wsUrl;
|
|
303
316
|
if (wsUrl && options.enableBiDi !== false) {
|
|
304
317
|
await browser.initBiDi(wsUrl);
|
|
318
|
+
if (options.captureLogs && browser.bidiSession?.isConnected()) {
|
|
319
|
+
// Arm log capture now instead of lazily on first browser.logs touch.
|
|
320
|
+
void browser.bidiSession.logs.initialize();
|
|
321
|
+
}
|
|
305
322
|
}
|
|
306
323
|
// Load session state if provided
|
|
307
324
|
if (options.storageState) {
|
|
@@ -318,16 +335,24 @@ export class Browser {
|
|
|
318
335
|
// Retry up to 8 times with increasing delays — Firefox may not have finished
|
|
319
336
|
// binding its BiDi WebSocket yet (especially when a previous session just closed
|
|
320
337
|
// on the same port, or the profile is still initialising).
|
|
321
|
-
const maxAttempts =
|
|
338
|
+
const maxAttempts = BIDI_CONNECT_MAX_ATTEMPTS;
|
|
322
339
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
323
340
|
try {
|
|
324
|
-
await session.connect(wsUrl
|
|
341
|
+
await session.connect(wsUrl, {
|
|
342
|
+
// Seed the top-level-context cache from the tree connect() already
|
|
343
|
+
// fetched, off the subscription connect() already armed — avoids a
|
|
344
|
+
// second getTree/subscribe round trip right after this returns.
|
|
345
|
+
onContextTree: (contexts) => {
|
|
346
|
+
this._topLevelContextTracking = this._startTopLevelContextTracking(contexts);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
await this._ensureTopLevelContextTracking().catch(() => { });
|
|
325
350
|
return; // success
|
|
326
351
|
}
|
|
327
352
|
catch (err) {
|
|
328
353
|
if (attempt < maxAttempts) {
|
|
329
354
|
// Back-off: 300ms, 600ms, 900ms, 1200ms …
|
|
330
|
-
await new Promise((r) => setTimeout(r,
|
|
355
|
+
await new Promise((r) => setTimeout(r, BIDI_CONNECT_BACKOFF_STEP_MS * attempt));
|
|
331
356
|
}
|
|
332
357
|
else {
|
|
333
358
|
// All attempts failed; fall back to Classic-only mode.
|
|
@@ -426,32 +451,30 @@ export class Browser {
|
|
|
426
451
|
async navigateTo(url, opts) {
|
|
427
452
|
this._tracer?.recordAction('navigateTo', [url, opts], url);
|
|
428
453
|
const waitUntil = opts?.waitUntil ?? 'load';
|
|
429
|
-
|
|
454
|
+
// Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
|
|
455
|
+
// already blocks until `document.readyState === 'complete'`, so the common
|
|
456
|
+
// case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
|
|
457
|
+
// because the next operation is often a BiDi script evaluation into the new
|
|
458
|
+
// document; mixing Classic navigate + immediate BiDi evaluate can race with
|
|
459
|
+
// the browser clearing old execution contexts on loaded-but-settling pages.
|
|
460
|
+
const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
|
|
461
|
+
const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
|
|
462
|
+
if (context && this.bidiSession?.isConnected()) {
|
|
430
463
|
const conn = this.bidiSession.getConnection();
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// 'networkidle' uses 'complete' (load) then waits for network silence
|
|
438
|
-
const bidiWait = waitUntil === 'none' ? 'none'
|
|
439
|
-
: waitUntil === 'domcontentloaded' ? 'interactive'
|
|
440
|
-
: 'complete'; // 'load' and 'networkidle'
|
|
441
|
-
await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
|
|
442
|
-
if (waitUntil === 'networkidle') {
|
|
443
|
-
await this.bidiSession.network.waitForNetworkIdle({
|
|
444
|
-
timeout: this.defaults.navigationTimeout,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
return;
|
|
464
|
+
const bidiWait = bidiWaitFor(waitUntil);
|
|
465
|
+
await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
|
|
466
|
+
if (waitUntil === 'networkidle') {
|
|
467
|
+
await this.bidiSession.network.waitForNetworkIdle({
|
|
468
|
+
timeout: this.defaults.navigationTimeout,
|
|
469
|
+
});
|
|
448
470
|
}
|
|
471
|
+
return;
|
|
449
472
|
}
|
|
450
|
-
// Classic
|
|
473
|
+
// Classic path — default 'load', or BiDi unavailable/no context yet.
|
|
451
474
|
await this.driver.navigateTo(url);
|
|
452
475
|
if (waitUntil === 'networkidle') {
|
|
453
476
|
// Best-effort: Classic can't track network events; give it a short settle time
|
|
454
|
-
await new Promise(r => setTimeout(r,
|
|
477
|
+
await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
455
478
|
}
|
|
456
479
|
}
|
|
457
480
|
/**
|
|
@@ -598,7 +621,7 @@ export class Browser {
|
|
|
598
621
|
}
|
|
599
622
|
const conn = this.bidiSession.getConnection();
|
|
600
623
|
// Apply across all top-level contexts in the default user context.
|
|
601
|
-
const tree = await conn.send('browsingContext.getTree', {});
|
|
624
|
+
const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
|
|
602
625
|
const tops = (tree.contexts ?? []).filter((c) => !c.parent);
|
|
603
626
|
const contexts = tops.map((c) => c.context);
|
|
604
627
|
const params = { contexts };
|
|
@@ -679,7 +702,7 @@ export class Browser {
|
|
|
679
702
|
}
|
|
680
703
|
}
|
|
681
704
|
const conn = this.bidiSession.getConnection();
|
|
682
|
-
const tree = await conn.send('browsingContext.getTree', {});
|
|
705
|
+
const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
|
|
683
706
|
const contexts = (tree.contexts ?? []).filter((c) => !c.parent).map((c) => c.context);
|
|
684
707
|
// Merge the new options into the persisted state so subsequent pages inherit.
|
|
685
708
|
this._emulation = { ...this._emulation, ...options };
|
|
@@ -784,7 +807,7 @@ export class Browser {
|
|
|
784
807
|
}
|
|
785
808
|
// Classic fallback: best-effort 500ms settle
|
|
786
809
|
else {
|
|
787
|
-
await new Promise(r => setTimeout(r,
|
|
810
|
+
await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
788
811
|
}
|
|
789
812
|
return;
|
|
790
813
|
}
|
|
@@ -829,7 +852,7 @@ export class Browser {
|
|
|
829
852
|
const readyState = await this.driver.executeScript('return document.readyState', []);
|
|
830
853
|
if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
|
|
831
854
|
return;
|
|
832
|
-
await new Promise(r => setTimeout(r,
|
|
855
|
+
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
833
856
|
}
|
|
834
857
|
throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
|
|
835
858
|
}
|
|
@@ -846,16 +869,32 @@ export class Browser {
|
|
|
846
869
|
// Silently abort any running trace so we don't leak the timer.
|
|
847
870
|
if (this._tracer?.isRunning)
|
|
848
871
|
this._tracer.abort();
|
|
872
|
+
// Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
|
|
873
|
+
// combination needs the port-release pause below; capture it before close().
|
|
874
|
+
const bidiWasActive = !!this.bidiSession;
|
|
849
875
|
// Close BiDi connection first
|
|
850
876
|
if (this.bidiSession) {
|
|
851
877
|
await this.bidiSession.close().catch(() => { });
|
|
852
878
|
}
|
|
879
|
+
this._browserInitScriptIds.clear();
|
|
880
|
+
for (const off of this._topLevelContextTrackingOffs)
|
|
881
|
+
off();
|
|
882
|
+
this._topLevelContextTrackingOffs = [];
|
|
883
|
+
this._topLevelContextTracking = undefined;
|
|
884
|
+
this._topLevelContextUserContexts.clear();
|
|
885
|
+
this._topLevelContextCacheVersion++;
|
|
853
886
|
// DELETE the WebDriver session — this tells the driver service to close the browser.
|
|
854
887
|
await this.driver.quit().catch(() => { });
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
888
|
+
// Firefox's BiDi WebSocket binds a fixed port (9222). If we kill geckodriver
|
|
889
|
+
// before Firefox has released it, the next Firefox+BiDi launch can 404 while
|
|
890
|
+
// connecting to the new session's socket — hence this pause. Chrome has no such
|
|
891
|
+
// race: chromedriver assigns an ephemeral --remote-debugging-port=0 and our BiDi
|
|
892
|
+
// URL comes from the session's own webSocketUrl, not a fixed port. So scope the
|
|
893
|
+
// 500ms sleep to Firefox+BiDi; every other quit (all Chrome, Firefox w/o BiDi)
|
|
894
|
+
// skips straight to stopping the driver.
|
|
895
|
+
if (this._browserName === 'firefox' && bidiWasActive) {
|
|
896
|
+
await new Promise((r) => setTimeout(r, PORT_RELEASE_DELAY_MS));
|
|
897
|
+
}
|
|
859
898
|
// Stop the underlying driver service (chromedriver / geckodriver)
|
|
860
899
|
// so we don't leak processes between sessions.
|
|
861
900
|
if (this._driverService) {
|
|
@@ -920,26 +959,36 @@ export class Browser {
|
|
|
920
959
|
const fnSrc = typeof fn === 'function' ? fn.toString() : fn;
|
|
921
960
|
if (this.bidiSession?.isConnected()) {
|
|
922
961
|
const conn = this.bidiSession.getConnection();
|
|
923
|
-
|
|
924
|
-
|
|
962
|
+
// Classic-first navigations return at readyState === 'complete', which is
|
|
963
|
+
// not a barrier the BiDi side respects: an immediately following
|
|
964
|
+
// { context } call can race the browser swapping the old realm for the
|
|
965
|
+
// new one and throw "execution contexts cleared". The error is
|
|
966
|
+
// pre-execution (script never ran, no side effects), so retry a few times,
|
|
967
|
+
// re-resolving the context each attempt. In-script errors take the
|
|
968
|
+
// result.type === 'exception' path below and are never retried here.
|
|
969
|
+
const functionDeclaration = typeof fn === 'function' ? fnSrc : `function() { ${fnSrc} }`;
|
|
970
|
+
const callArgs = typeof fn === 'function' ? args.map(serializeLocalValue) : [];
|
|
925
971
|
let result;
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
972
|
+
for (let attempt = 1;; attempt++) {
|
|
973
|
+
const context = this.bidiSession.getContext();
|
|
974
|
+
const target = context ? { context } : {};
|
|
975
|
+
try {
|
|
976
|
+
result = await conn.send('script.callFunction', {
|
|
977
|
+
functionDeclaration,
|
|
978
|
+
target,
|
|
979
|
+
arguments: callArgs,
|
|
980
|
+
awaitPromise: true,
|
|
981
|
+
});
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
|
|
986
|
+
String(err?.message).includes('execution contexts cleared')) {
|
|
987
|
+
await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
throw err;
|
|
991
|
+
}
|
|
943
992
|
}
|
|
944
993
|
if (result.type === 'exception') {
|
|
945
994
|
throw new CraftdriverError(ErrorCode.EVAL_THREW, `evaluate() threw an exception in the page: ${result.exceptionDetails?.text ?? 'unknown error'}`, { detail: { exception: result.exceptionDetails?.text ?? null } });
|
|
@@ -981,9 +1030,13 @@ export class Browser {
|
|
|
981
1030
|
functionDeclaration: fnSrc,
|
|
982
1031
|
});
|
|
983
1032
|
const scriptId = result.script;
|
|
1033
|
+
this._browserInitScriptIds.add(scriptId);
|
|
984
1034
|
return {
|
|
985
1035
|
remove: async () => {
|
|
1036
|
+
if (!this._browserInitScriptIds.has(scriptId))
|
|
1037
|
+
return;
|
|
986
1038
|
await conn.send('script.removePreloadScript', { script: scriptId });
|
|
1039
|
+
this._browserInitScriptIds.delete(scriptId);
|
|
987
1040
|
},
|
|
988
1041
|
};
|
|
989
1042
|
}
|
|
@@ -1060,7 +1113,7 @@ export class Browser {
|
|
|
1060
1113
|
},
|
|
1061
1114
|
};
|
|
1062
1115
|
}
|
|
1063
|
-
await new Promise(r => setTimeout(r,
|
|
1116
|
+
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1064
1117
|
}
|
|
1065
1118
|
throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
|
|
1066
1119
|
}
|
|
@@ -1242,7 +1295,7 @@ export class Browser {
|
|
|
1242
1295
|
async pages() {
|
|
1243
1296
|
if (this.bidiSession?.isConnected()) {
|
|
1244
1297
|
const conn = this.bidiSession.getConnection();
|
|
1245
|
-
const tree = await conn.send('browsingContext.getTree', {});
|
|
1298
|
+
const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
|
|
1246
1299
|
return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
|
|
1247
1300
|
}
|
|
1248
1301
|
// Classic fallback: use window handles
|
|
@@ -1292,8 +1345,9 @@ export class Browser {
|
|
|
1292
1345
|
const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
|
|
1293
1346
|
if (this.bidiSession?.isConnected()) {
|
|
1294
1347
|
const conn = this.bidiSession.getConnection();
|
|
1295
|
-
//
|
|
1296
|
-
|
|
1348
|
+
// `browsingContext.contextCreated` is already subscribed session-wide by
|
|
1349
|
+
// `BiDiSession.connect()` (which must have run for `isConnected()` to be
|
|
1350
|
+
// true), so no per-call `subscribe()` round trip is needed here.
|
|
1297
1351
|
return new Promise((resolve, reject) => {
|
|
1298
1352
|
const timer = setTimeout(() => {
|
|
1299
1353
|
off();
|
|
@@ -1325,7 +1379,7 @@ export class Browser {
|
|
|
1325
1379
|
const handle = newHandles[0];
|
|
1326
1380
|
return new Page(this.driver, handle, this.getDefaultTimeout);
|
|
1327
1381
|
}
|
|
1328
|
-
await new Promise(r => setTimeout(r,
|
|
1382
|
+
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1329
1383
|
}
|
|
1330
1384
|
throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
|
|
1331
1385
|
}
|
|
@@ -1377,7 +1431,11 @@ export class Browser {
|
|
|
1377
1431
|
if (cached)
|
|
1378
1432
|
return cached;
|
|
1379
1433
|
const conn = this.bidiSession.getConnection();
|
|
1380
|
-
const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, {
|
|
1434
|
+
const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, {
|
|
1435
|
+
getNetwork: () => this.network,
|
|
1436
|
+
getBrowserName: () => this._browserName,
|
|
1437
|
+
hasBrowserInitScripts: this.hasBrowserInitScripts,
|
|
1438
|
+
}, config);
|
|
1381
1439
|
this._contextsById.set(id, ctx);
|
|
1382
1440
|
// Evict on close so long-running suites that create many contexts don't
|
|
1383
1441
|
// accumulate dead wrappers.
|
|
@@ -1438,6 +1496,91 @@ export class Browser {
|
|
|
1438
1496
|
this._defaultContext = this._wrapContext('default');
|
|
1439
1497
|
return this._defaultContext;
|
|
1440
1498
|
}
|
|
1499
|
+
async _ensureTopLevelContextTracking() {
|
|
1500
|
+
if (!this.bidiSession?.isConnected())
|
|
1501
|
+
return;
|
|
1502
|
+
if (this._topLevelContextTracking)
|
|
1503
|
+
return this._topLevelContextTracking;
|
|
1504
|
+
this._topLevelContextTracking = this._startTopLevelContextTracking();
|
|
1505
|
+
return this._topLevelContextTracking;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* @param initialContexts When provided (the normal path — passed via
|
|
1509
|
+
* `BiDiSession.connect()`'s `onContextTree` callback), `contextCreated`/
|
|
1510
|
+
* `contextDestroyed` are already subscribed as part of `connect()`'s
|
|
1511
|
+
* merged batch and the tree is already fetched — this just registers
|
|
1512
|
+
* handlers and seeds the cache from it, no extra round trip. Falls back
|
|
1513
|
+
* to its own subscribe + `getTree` when called without a pre-fetched
|
|
1514
|
+
* tree (defensive — e.g. if `_ensureTopLevelContextTracking()` is ever
|
|
1515
|
+
* reached before `initBiDi()`'s callback has run).
|
|
1516
|
+
*/
|
|
1517
|
+
async _startTopLevelContextTracking(initialContexts) {
|
|
1518
|
+
const conn = this.bidiSession.getConnection();
|
|
1519
|
+
if (!initialContexts) {
|
|
1520
|
+
// Defensive path only. In today's sequencing this branch is unreachable:
|
|
1521
|
+
// `initBiDi()` always seeds `_topLevelContextTracking` via `connect()`'s
|
|
1522
|
+
// `onContextTree` callback (with `initialContexts`) before `launch()`
|
|
1523
|
+
// returns, so no external caller can reach `_ensureTopLevelContextTracking()`
|
|
1524
|
+
// — and thus this arg-less path — before `connect()` has already subscribed.
|
|
1525
|
+
// The `subscribe()` is kept (unlike the redundant ones dropped from the
|
|
1526
|
+
// other context-tracking call sites) precisely because this branch's
|
|
1527
|
+
// contract is "connect()'s subscribe may NOT have run yet"; dropping it
|
|
1528
|
+
// would break that contract if a future refactor made the branch reachable.
|
|
1529
|
+
await conn
|
|
1530
|
+
.subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
|
|
1531
|
+
.catch(() => { });
|
|
1532
|
+
}
|
|
1533
|
+
const onCreated = (params) => {
|
|
1534
|
+
if (params.parent)
|
|
1535
|
+
return;
|
|
1536
|
+
const id = params.context;
|
|
1537
|
+
if (typeof id !== 'string')
|
|
1538
|
+
return;
|
|
1539
|
+
this._topLevelContextUserContexts.set(id, typeof params.userContext === 'string' ? params.userContext : '');
|
|
1540
|
+
this._topLevelContextCacheVersion++;
|
|
1541
|
+
};
|
|
1542
|
+
const onDestroyed = (params) => {
|
|
1543
|
+
if (params.parent)
|
|
1544
|
+
return;
|
|
1545
|
+
if (typeof params.context === 'string') {
|
|
1546
|
+
this._topLevelContextUserContexts.delete(params.context);
|
|
1547
|
+
this._topLevelContextCacheVersion++;
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextCreated', onCreated));
|
|
1551
|
+
this._topLevelContextTrackingOffs.push(conn.on('browsingContext.contextDestroyed', onDestroyed));
|
|
1552
|
+
if (initialContexts) {
|
|
1553
|
+
for (const ctx of initialContexts) {
|
|
1554
|
+
if (!ctx.parent) {
|
|
1555
|
+
this._topLevelContextUserContexts.set(ctx.context, ctx.userContext ?? 'default');
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
this._topLevelContextCacheVersion++;
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
await this._refreshTopLevelContextCache(conn).catch(() => { });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
async _refreshTopLevelContextCache(conn) {
|
|
1565
|
+
const version = this._topLevelContextCacheVersion;
|
|
1566
|
+
const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
|
|
1567
|
+
if (this._topLevelContextCacheVersion === version) {
|
|
1568
|
+
this._topLevelContextUserContexts.clear();
|
|
1569
|
+
}
|
|
1570
|
+
for (const ctx of tree.contexts ?? []) {
|
|
1571
|
+
if (!ctx.parent) {
|
|
1572
|
+
this._topLevelContextUserContexts.set(ctx.context, ctx.userContext ?? 'default');
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
this._topLevelContextCacheVersion++;
|
|
1576
|
+
}
|
|
1577
|
+
_firstDefaultTopLevelContext() {
|
|
1578
|
+
for (const [id, userContext] of this._topLevelContextUserContexts) {
|
|
1579
|
+
if (userContext === 'default')
|
|
1580
|
+
return id;
|
|
1581
|
+
}
|
|
1582
|
+
return undefined;
|
|
1583
|
+
}
|
|
1441
1584
|
/**
|
|
1442
1585
|
* Return the currently focused top-level page in `defaultContext`.
|
|
1443
1586
|
*
|
|
@@ -1460,16 +1603,22 @@ export class Browser {
|
|
|
1460
1603
|
async activePage() {
|
|
1461
1604
|
if (this.bidiSession?.isConnected()) {
|
|
1462
1605
|
const conn = this.bidiSession.getConnection();
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
if (
|
|
1606
|
+
const handle = await this.driver.getCurrentWindowHandle().catch(() => '');
|
|
1607
|
+
await this._ensureTopLevelContextTracking();
|
|
1608
|
+
if (handle && this._topLevelContextUserContexts.get(handle) === 'default') {
|
|
1609
|
+
return new Page(this.driver, handle, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1610
|
+
}
|
|
1611
|
+
// Events should keep the cache warm, but a cheap top-level sync covers
|
|
1612
|
+
// missed startup events and direct protocol/browser changes.
|
|
1613
|
+
await this._refreshTopLevelContextCache(conn);
|
|
1614
|
+
if (handle && this._topLevelContextUserContexts.get(handle) === 'default') {
|
|
1615
|
+
return new Page(this.driver, handle, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1616
|
+
}
|
|
1617
|
+
const fallback = this._firstDefaultTopLevelContext();
|
|
1618
|
+
if (!fallback) {
|
|
1466
1619
|
throw new Error('activePage(): no top-level page in the default context.');
|
|
1467
1620
|
}
|
|
1468
|
-
|
|
1469
|
-
// pages exist in the default context (e.g. after browser.openPage()).
|
|
1470
|
-
const handle = await this.driver.getCurrentWindowHandle().catch(() => '');
|
|
1471
|
-
const focused = tops.find((c) => c.context === handle) ?? tops[0];
|
|
1472
|
-
return new Page(this.driver, focused.context, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1621
|
+
return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1473
1622
|
}
|
|
1474
1623
|
// Classic mode: no user-context concept; just wrap the current window.
|
|
1475
1624
|
const handle = await this.driver.getCurrentWindowHandle();
|