craftdriver 1.2.0 → 1.4.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 +20 -0
- package/README.md +2 -1
- package/dist/cli/dispatcher.d.ts.map +1 -1
- package/dist/cli/dispatcher.js +13 -1
- package/dist/cli/dispatcher.js.map +1 -1
- package/dist/cli/mcp/tools.d.ts +1 -2
- package/dist/cli/mcp/tools.d.ts.map +1 -1
- package/dist/cli/mcp/tools.js +18 -0
- package/dist/cli/mcp/tools.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/browser.d.ts +156 -27
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +474 -212
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/capabilities.d.ts +35 -0
- package/dist/lib/capabilities.d.ts.map +1 -0
- package/dist/lib/capabilities.js +58 -0
- package/dist/lib/capabilities.js.map +1 -0
- package/dist/lib/chrome.d.ts +7 -2
- package/dist/lib/chrome.d.ts.map +1 -1
- package/dist/lib/chrome.js +10 -1
- package/dist/lib/chrome.js.map +1 -1
- package/dist/lib/driverManager.d.ts +92 -0
- package/dist/lib/driverManager.d.ts.map +1 -1
- package/dist/lib/driverManager.js +229 -10
- package/dist/lib/driverManager.js.map +1 -1
- package/dist/lib/electron.d.ts +60 -0
- package/dist/lib/electron.d.ts.map +1 -0
- package/dist/lib/electron.js +85 -0
- package/dist/lib/electron.js.map +1 -0
- package/dist/lib/electronDeeplink.d.ts +22 -0
- package/dist/lib/electronDeeplink.d.ts.map +1 -0
- package/dist/lib/electronDeeplink.js +118 -0
- package/dist/lib/electronDeeplink.js.map +1 -0
- package/dist/lib/electronDiagnostics.d.ts +49 -0
- package/dist/lib/electronDiagnostics.d.ts.map +1 -0
- package/dist/lib/electronDiagnostics.js +195 -0
- package/dist/lib/electronDiagnostics.js.map +1 -0
- package/dist/lib/electronDialogMock.d.ts +51 -0
- package/dist/lib/electronDialogMock.d.ts.map +1 -0
- package/dist/lib/electronDialogMock.js +106 -0
- package/dist/lib/electronDialogMock.js.map +1 -0
- package/dist/lib/electronFuses.d.ts +13 -0
- package/dist/lib/electronFuses.d.ts.map +1 -0
- package/dist/lib/electronFuses.js +118 -0
- package/dist/lib/electronFuses.js.map +1 -0
- package/dist/lib/electronMainBridge.d.ts +28 -0
- package/dist/lib/electronMainBridge.d.ts.map +1 -0
- package/dist/lib/electronMainBridge.js +270 -0
- package/dist/lib/electronMainBridge.js.map +1 -0
- package/dist/lib/electronMainLogs.d.ts +81 -0
- package/dist/lib/electronMainLogs.d.ts.map +1 -0
- package/dist/lib/electronMainLogs.js +176 -0
- package/dist/lib/electronMainLogs.js.map +1 -0
- package/dist/lib/electronMock.d.ts +52 -0
- package/dist/lib/electronMock.d.ts.map +1 -0
- package/dist/lib/electronMock.js +96 -0
- package/dist/lib/electronMock.js.map +1 -0
- package/dist/lib/electronRemote.d.ts +106 -0
- package/dist/lib/electronRemote.d.ts.map +1 -0
- package/dist/lib/electronRemote.js +406 -0
- package/dist/lib/electronRemote.js.map +1 -0
- package/dist/lib/electronVersions.d.ts +34 -0
- package/dist/lib/electronVersions.d.ts.map +1 -0
- package/dist/lib/electronVersions.js +90 -0
- package/dist/lib/electronVersions.js.map +1 -0
- package/dist/lib/errors.d.ts +27 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +27 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/launchTarget.d.ts +30 -0
- package/dist/lib/launchTarget.d.ts.map +1 -0
- package/dist/lib/launchTarget.js +112 -0
- package/dist/lib/launchTarget.js.map +1 -0
- package/dist/lib/page.d.ts +1 -0
- package/dist/lib/page.d.ts.map +1 -1
- package/dist/lib/page.js +1 -1
- package/dist/lib/page.js.map +1 -1
- package/dist/lib/service.d.ts +21 -0
- package/dist/lib/service.d.ts.map +1 -1
- package/dist/lib/service.js +94 -6
- package/dist/lib/service.js.map +1 -1
- package/dist/lib/tracing.d.ts +20 -4
- package/dist/lib/tracing.d.ts.map +1 -1
- package/dist/lib/tracing.js +38 -3
- package/dist/lib/tracing.js.map +1 -1
- package/dist/lib/vibiumTrace.d.ts +14 -0
- package/dist/lib/vibiumTrace.d.ts.map +1 -0
- package/dist/lib/vibiumTrace.js +372 -0
- package/dist/lib/vibiumTrace.js.map +1 -0
- package/docs/api-reference.md +38 -2
- package/docs/browser-api.md +7 -1
- package/docs/electron.md +419 -0
- package/docs/error-codes.md +5 -0
- package/docs/index.md +4 -0
- package/docs/mcp.md +17 -3
- package/docs/public/recipes/trace-failing-test.html +16 -0
- package/docs/public/recipes/vitest-vibium-trace.html +16 -0
- package/docs/public/traces/vitest-login.zip +0 -0
- package/docs/recipes/debug-failing-tests-with-traces.md +117 -0
- package/docs/recipes/electron-app-from-another-repo.md +136 -0
- package/docs/recipes/electron-deep-link.md +117 -0
- package/docs/recipes/electron-mock-apis.md +88 -0
- package/docs/recipes/electron-native-dialog.md +105 -0
- package/docs/recipes.md +29 -10
- package/docs/tracing.md +102 -24
- package/package.json +8 -8
- package/skills/craftdriver/cheatsheet.md +4 -2
- package/skills/craftdriver/patterns.md +2 -1
- package/docs/recipes/trace-failing-test.md +0 -51
package/dist/lib/browser.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Builder } from './builder.js';
|
|
2
2
|
import { ChromeService } from './chrome.js';
|
|
3
|
+
import { ElectronService, writeElectronDebug } from './electron.js';
|
|
4
|
+
import { diagnoseElectronLaunchFailure } from './electronDiagnostics.js';
|
|
5
|
+
import { ElectronRemote } from './electronRemote.js';
|
|
6
|
+
import { findFreePort } from './service.js';
|
|
3
7
|
import { FirefoxService } from './firefox.js';
|
|
4
8
|
import { resolveBrowserBinaryPath } from './driverManager.js';
|
|
9
|
+
import { buildLaunchCapabilities } from './capabilities.js';
|
|
10
|
+
import { resolveLaunchTarget } from './launchTarget.js';
|
|
5
11
|
import { By } from './by.js';
|
|
6
12
|
import { until } from './wait.js';
|
|
7
13
|
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';
|
|
@@ -18,7 +24,7 @@ import { Mouse } from './mouse.js';
|
|
|
18
24
|
import { ActionsBuilder } from './actions.js';
|
|
19
25
|
import { BiDiSession, SessionStateManager, } from './bidi/index.js';
|
|
20
26
|
import { Frame } from './frame.js';
|
|
21
|
-
import { Page } from './page.js';
|
|
27
|
+
import { Page, isNoSuchWindowError } from './page.js';
|
|
22
28
|
import { bidiWaitFor } from './loadState.js';
|
|
23
29
|
import { BrowserContext } from './browserContext.js';
|
|
24
30
|
import { Tracer } from './tracing.js';
|
|
@@ -63,6 +69,75 @@ export const devices = {
|
|
|
63
69
|
userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1',
|
|
64
70
|
},
|
|
65
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a `goog:chromeOptions.mobileEmulation` payload from the public
|
|
74
|
+
* `MobileEmulation | DeviceName` option — a built-in device name maps to
|
|
75
|
+
* Chrome's `deviceName`, otherwise a custom `deviceMetrics`/`userAgent` config
|
|
76
|
+
* is built. Kept next to `devices`; `buildLaunchCapabilities` takes the result.
|
|
77
|
+
*/
|
|
78
|
+
function resolveMobileEmulationConfig(mobileEmulation) {
|
|
79
|
+
const emulation = typeof mobileEmulation === 'string' ? devices[mobileEmulation] : mobileEmulation;
|
|
80
|
+
if ('deviceName' in emulation && emulation.deviceName) {
|
|
81
|
+
// Use Chrome's built-in device name
|
|
82
|
+
return { deviceName: emulation.deviceName };
|
|
83
|
+
}
|
|
84
|
+
// Build custom mobile emulation config
|
|
85
|
+
const mobileConfig = {};
|
|
86
|
+
if (emulation.deviceMetrics) {
|
|
87
|
+
mobileConfig.deviceMetrics = {
|
|
88
|
+
width: emulation.deviceMetrics.width,
|
|
89
|
+
height: emulation.deviceMetrics.height,
|
|
90
|
+
pixelRatio: emulation.deviceMetrics.pixelRatio,
|
|
91
|
+
mobile: emulation.deviceMetrics.mobile ?? true,
|
|
92
|
+
touch: emulation.deviceMetrics.touch ?? true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (emulation.userAgent) {
|
|
96
|
+
mobileConfig.userAgent = emulation.userAgent;
|
|
97
|
+
}
|
|
98
|
+
return mobileConfig;
|
|
99
|
+
}
|
|
100
|
+
async function resolveElectronAppBinaryPath(appBinaryPath) {
|
|
101
|
+
const resolved = path.resolve(appBinaryPath);
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = await fs.stat(resolved);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const code = error.code;
|
|
108
|
+
if (code === 'ENOENT') {
|
|
109
|
+
throw new Error(`electron.appBinaryPath "${appBinaryPath}" does not exist.`);
|
|
110
|
+
}
|
|
111
|
+
if (code === 'EACCES') {
|
|
112
|
+
throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not accessible.`);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (!stat.isFile()) {
|
|
117
|
+
throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not a file.`);
|
|
118
|
+
}
|
|
119
|
+
if (process.platform !== 'win32') {
|
|
120
|
+
try {
|
|
121
|
+
await fs.access(resolved, fsSync.constants.X_OK);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error(`electron.appBinaryPath "${appBinaryPath}" is not executable.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pure predicate behind {@link Browser.waitForPage}'s matcher form: do a page's
|
|
131
|
+
* `url`/`title` satisfy the matcher? Only the fields named in `matcher` are checked,
|
|
132
|
+
* and every named field must match (string = substring, RegExp = test). Exported for
|
|
133
|
+
* unit testing.
|
|
134
|
+
*/
|
|
135
|
+
export function matchPageFields(fields, matcher) {
|
|
136
|
+
const hit = (value, pattern) => pattern === undefined ||
|
|
137
|
+
(value !== undefined &&
|
|
138
|
+
(typeof pattern === 'string' ? value.includes(pattern) : pattern.test(value)));
|
|
139
|
+
return hit(fields.url, matcher.url) && hit(fields.title, matcher.title);
|
|
140
|
+
}
|
|
66
141
|
// ---------------------------------------------------------------------------
|
|
67
142
|
// BiDi value helpers — used only by Browser.evaluate()
|
|
68
143
|
// ---------------------------------------------------------------------------
|
|
@@ -93,7 +168,10 @@ function serializeLocalValue(v) {
|
|
|
93
168
|
if (typeof v === 'object') {
|
|
94
169
|
return {
|
|
95
170
|
type: 'object',
|
|
96
|
-
value: Object.entries(v).map(([k, val]) => [
|
|
171
|
+
value: Object.entries(v).map(([k, val]) => [
|
|
172
|
+
k,
|
|
173
|
+
serializeLocalValue(val),
|
|
174
|
+
]),
|
|
97
175
|
};
|
|
98
176
|
}
|
|
99
177
|
throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, `evaluate() argument of type "${typeof v}" is not JSON-serializable. ` +
|
|
@@ -101,11 +179,16 @@ function serializeLocalValue(v) {
|
|
|
101
179
|
}
|
|
102
180
|
function unwrapRemoteValue(v) {
|
|
103
181
|
switch (v.type) {
|
|
104
|
-
case 'undefined':
|
|
105
|
-
|
|
106
|
-
case '
|
|
107
|
-
|
|
108
|
-
case '
|
|
182
|
+
case 'undefined':
|
|
183
|
+
return undefined;
|
|
184
|
+
case 'null':
|
|
185
|
+
return null;
|
|
186
|
+
case 'string':
|
|
187
|
+
return v.value;
|
|
188
|
+
case 'boolean':
|
|
189
|
+
return v.value;
|
|
190
|
+
case 'bigint':
|
|
191
|
+
return BigInt(v.value);
|
|
109
192
|
case 'number': {
|
|
110
193
|
if (v.value === 'NaN')
|
|
111
194
|
return NaN;
|
|
@@ -117,13 +200,15 @@ function unwrapRemoteValue(v) {
|
|
|
117
200
|
return -0;
|
|
118
201
|
return v.value;
|
|
119
202
|
}
|
|
120
|
-
case 'array':
|
|
203
|
+
case 'array':
|
|
204
|
+
return (v.value ?? []).map(unwrapRemoteValue);
|
|
121
205
|
case 'object':
|
|
122
206
|
return Object.fromEntries((v.value ?? []).map(([k, val]) => [
|
|
123
207
|
typeof k === 'string' ? k : String(unwrapRemoteValue(k)),
|
|
124
208
|
unwrapRemoteValue(val),
|
|
125
209
|
]));
|
|
126
|
-
case 'date':
|
|
210
|
+
case 'date':
|
|
211
|
+
return new Date(v.value);
|
|
127
212
|
case 'function':
|
|
128
213
|
throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, 'evaluate() returned a function, which is not JSON-serializable. ' +
|
|
129
214
|
'Return a primitive, array, or plain object instead.', { detail: { returnedType: 'function' } });
|
|
@@ -190,11 +275,19 @@ export class Browser {
|
|
|
190
275
|
_browserInitScriptIds = new Set();
|
|
191
276
|
_downloadsDir;
|
|
192
277
|
_driverService;
|
|
278
|
+
/** Main-process inspector endpoint, set when launched with `electron.mainProcess`. */
|
|
279
|
+
_electronInspect;
|
|
280
|
+
/** The launched Electron app's executable path (for deep-link routing on Windows). */
|
|
281
|
+
_electronAppBinaryPath;
|
|
282
|
+
_electron;
|
|
193
283
|
_browserName = 'chrome';
|
|
194
284
|
/** Active emulation overrides, re-applied to new top-level contexts. */
|
|
195
285
|
_emulation = {};
|
|
196
286
|
/** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
|
|
197
|
-
defaults = {
|
|
287
|
+
defaults = {
|
|
288
|
+
timeout: DEFAULT_ELEMENT_TIMEOUT_MS,
|
|
289
|
+
navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT_MS,
|
|
290
|
+
};
|
|
198
291
|
constructor(driver) {
|
|
199
292
|
this.driver = driver;
|
|
200
293
|
this.keyboard = new Keyboard(this.driver);
|
|
@@ -219,102 +312,91 @@ export class Browser {
|
|
|
219
312
|
hasBrowserInitScripts = () => this._browserInitScriptIds.size > 0;
|
|
220
313
|
hasDefaultNavigationInitScripts = () => this._defaultContext?._hasInitScriptsForNavigation() ?? this.hasBrowserInitScripts();
|
|
221
314
|
static async launch(options = {}) {
|
|
222
|
-
|
|
223
|
-
|
|
315
|
+
// Normalize first: callers may be JavaScript or JSON-driven, so reject
|
|
316
|
+
// malformed/mixed targets before touching disk or starting a driver.
|
|
317
|
+
const target = resolveLaunchTarget(options);
|
|
318
|
+
const isElectron = target.kind === 'electron';
|
|
319
|
+
const name = target.browserName;
|
|
224
320
|
const isChromeFamily = name === 'chrome' || name === 'chromium';
|
|
225
|
-
if (options.mobileEmulation && !isChromeFamily) {
|
|
321
|
+
if (target.kind === 'browser' && options.mobileEmulation && !isChromeFamily) {
|
|
226
322
|
throw new Error(`mobileEmulation is only supported on Chrome/Chromium (got "${name}"). ` +
|
|
227
323
|
`Firefox does not expose a device-emulation API equivalent to goog:chromeOptions.mobileEmulation.`);
|
|
228
324
|
}
|
|
229
|
-
|
|
325
|
+
const bidiRequested = target.bidiRequested;
|
|
326
|
+
// Handle headless mode via env var (never applied to Electron — see capabilities.ts).
|
|
230
327
|
const headlessEnv = process.env.HEADLESS;
|
|
231
328
|
const isHeadless = headlessEnv === 'true' || headlessEnv === '1';
|
|
329
|
+
// Resolve Electron before creating session directories. A malformed or
|
|
330
|
+
// missing executable must never degrade into a regular Chrome launch.
|
|
331
|
+
const electronBinary = target.kind === 'electron'
|
|
332
|
+
? await resolveElectronAppBinaryPath(target.appBinaryPath)
|
|
333
|
+
: undefined;
|
|
334
|
+
if (options.electronService !== undefined &&
|
|
335
|
+
!(options.electronService instanceof ElectronService)) {
|
|
336
|
+
throw new Error('electronService must be an ElectronService instance.');
|
|
337
|
+
}
|
|
338
|
+
if (target.kind === 'electron') {
|
|
339
|
+
writeElectronDebug({
|
|
340
|
+
appPath: electronBinary,
|
|
341
|
+
protocol: bidiRequested ? 'bidi' : 'classic',
|
|
342
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
232
345
|
// Set up downloads directory
|
|
233
|
-
const downloadsDir = options.downloadsDir ??
|
|
346
|
+
const downloadsDir = options.downloadsDir ??
|
|
347
|
+
path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
234
348
|
await fs.mkdir(downloadsDir, { recursive: true });
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// vars in
|
|
239
|
-
// conventions other tools may
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
width: emulation.deviceMetrics.width,
|
|
268
|
-
height: emulation.deviceMetrics.height,
|
|
269
|
-
pixelRatio: emulation.deviceMetrics.pixelRatio,
|
|
270
|
-
mobile: emulation.deviceMetrics.mobile ?? true,
|
|
271
|
-
touch: emulation.deviceMetrics.touch ?? true,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
if (emulation.userAgent) {
|
|
275
|
-
mobileConfig.userAgent = emulation.userAgent;
|
|
276
|
-
}
|
|
277
|
-
chromeOptions.mobileEmulation = mobileConfig;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
caps['goog:chromeOptions'] = chromeOptions;
|
|
281
|
-
}
|
|
282
|
-
else if (isFirefox) {
|
|
283
|
-
const firefoxArgs = [];
|
|
284
|
-
if (isHeadless)
|
|
285
|
-
firefoxArgs.push('-headless');
|
|
286
|
-
if (options.args?.length)
|
|
287
|
-
firefoxArgs.push(...options.args);
|
|
288
|
-
caps['moz:firefoxOptions'] = {
|
|
289
|
-
args: firefoxArgs,
|
|
290
|
-
prefs: {
|
|
291
|
-
'browser.download.folderList': 2,
|
|
292
|
-
'browser.download.dir': downloadsDir,
|
|
293
|
-
'browser.download.useDownloadDir': true,
|
|
294
|
-
'browser.helperApps.neverAsk.saveToDisk': 'application/octet-stream,application/pdf,text/plain,text/csv,application/zip',
|
|
295
|
-
'pdfjs.disabled': true,
|
|
296
|
-
},
|
|
297
|
-
...(browserPath ? { binary: browserPath } : {}),
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
if (options.enableBiDi !== false) {
|
|
301
|
-
// Request BiDi WebSocket URL
|
|
302
|
-
caps.webSocketUrl = true;
|
|
303
|
-
// Set all prompt types to 'ignore' so BiDi events fire and we can handle them
|
|
304
|
-
caps.unhandledPromptBehavior = {
|
|
305
|
-
alert: 'ignore',
|
|
306
|
-
confirm: 'ignore',
|
|
307
|
-
prompt: 'ignore',
|
|
308
|
-
beforeUnload: 'ignore',
|
|
309
|
-
};
|
|
310
|
-
}
|
|
349
|
+
// The binary that becomes goog:chromeOptions.binary / moz:firefoxOptions.binary.
|
|
350
|
+
// For Electron it's the app executable (explicit, required). For browsers
|
|
351
|
+
// it's the optional custom-binary resolution chain — but see
|
|
352
|
+
// resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
|
|
353
|
+
// craftdriver-opt-in, they're ambient conventions other tools may have set.
|
|
354
|
+
const browserBinary = target.kind === 'electron'
|
|
355
|
+
? electronBinary
|
|
356
|
+
: resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
|
|
357
|
+
// Main-process access (opt-in): launch the Electron app with a Node inspector
|
|
358
|
+
// on a free local port. chromedriver forwards this arg to the app, enabling
|
|
359
|
+
// its main-process inspector; browser.electron connects below for log capture
|
|
360
|
+
// and retries on first executeMain() if that eager connection was too early.
|
|
361
|
+
let electronInspect;
|
|
362
|
+
let launchArgs = target.args;
|
|
363
|
+
if (target.kind === 'electron' && target.mainProcess) {
|
|
364
|
+
const host = '127.0.0.1';
|
|
365
|
+
const port = await findFreePort();
|
|
366
|
+
electronInspect = { host, port };
|
|
367
|
+
launchArgs = [...(target.args ?? []), `--inspect=${host}:${port}`];
|
|
368
|
+
}
|
|
369
|
+
const caps = buildLaunchCapabilities({
|
|
370
|
+
browserName: name,
|
|
371
|
+
isElectron,
|
|
372
|
+
isHeadless,
|
|
373
|
+
bidiRequested,
|
|
374
|
+
browserBinary,
|
|
375
|
+
downloadsDir,
|
|
376
|
+
args: launchArgs,
|
|
377
|
+
mobileEmulation: target.kind === 'browser' && isChromeFamily && options.mobileEmulation
|
|
378
|
+
? resolveMobileEmulationConfig(options.mobileEmulation)
|
|
379
|
+
: undefined,
|
|
380
|
+
});
|
|
311
381
|
const builder = new Builder().forBrowser(name);
|
|
312
382
|
let driverService;
|
|
313
|
-
if (
|
|
383
|
+
if (target.kind === 'electron') {
|
|
384
|
+
driverService =
|
|
385
|
+
options.electronService ??
|
|
386
|
+
new ElectronService({
|
|
387
|
+
chromedriverPath: target.chromedriverPath,
|
|
388
|
+
version: target.version,
|
|
389
|
+
// Lets the driver resolver read the Electron version from a packaged app's
|
|
390
|
+
// bundle when no explicit version/driver is given (production apps).
|
|
391
|
+
appBinaryPath: electronBinary,
|
|
392
|
+
});
|
|
393
|
+
builder.setChromeService(driverService);
|
|
394
|
+
}
|
|
395
|
+
else if (isChromeFamily) {
|
|
314
396
|
// Only thread browserPath into a default ChromeService — a
|
|
315
397
|
// caller-supplied one is expected to already pin what it needs (same
|
|
316
398
|
// reasoning as explicit config always winning over auto-detection).
|
|
317
|
-
driverService = options.chromeService ?? new ChromeService({ browserPath });
|
|
399
|
+
driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
|
|
318
400
|
builder.setChromeService(driverService);
|
|
319
401
|
}
|
|
320
402
|
else {
|
|
@@ -322,14 +404,50 @@ export class Browser {
|
|
|
322
404
|
builder.setFirefoxService(driverService);
|
|
323
405
|
}
|
|
324
406
|
builder.withCapabilities(caps);
|
|
325
|
-
|
|
407
|
+
let driver;
|
|
408
|
+
if (target.kind === 'electron') {
|
|
409
|
+
try {
|
|
410
|
+
driver = await builder.build();
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
// Turn an opaque "Chrome instance exited" into a diagnosed, actionable
|
|
414
|
+
// error (macOS signing/Gatekeeper, Linux sandbox) with the chromedriver
|
|
415
|
+
// output tail attached. Non-app-exit failures pass through unchanged.
|
|
416
|
+
throw diagnoseElectronLaunchFailure(err, {
|
|
417
|
+
appBinaryPath: electronBinary,
|
|
418
|
+
args: target.args,
|
|
419
|
+
driverOutputTail: driverService.getOutputTail?.(),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
driver = await builder.build();
|
|
425
|
+
}
|
|
326
426
|
const browser = new Browser(driver);
|
|
327
427
|
browser._downloadsDir = downloadsDir;
|
|
328
428
|
browser._driverService = driverService;
|
|
329
429
|
browser._browserName = name;
|
|
430
|
+
browser._electronInspect = electronInspect;
|
|
431
|
+
browser._electronAppBinaryPath = electronBinary;
|
|
432
|
+
// With main-process access enabled, open the inspector bridge now so
|
|
433
|
+
// main-process console/error capture (browser.electron.mainLogs) starts at
|
|
434
|
+
// launch, mirroring always-on renderer log capture. Best-effort: a disabled
|
|
435
|
+
// EnableNodeCliInspectArguments fuse or an unreachable inspector must never
|
|
436
|
+
// fail renderer automation — executeMain still surfaces the actionable error
|
|
437
|
+
// on first use.
|
|
438
|
+
if (electronInspect) {
|
|
439
|
+
const remote = new ElectronRemote(electronInspect, { appBinaryPath: electronBinary });
|
|
440
|
+
browser._electron = remote;
|
|
441
|
+
await remote.connect().catch((err) => {
|
|
442
|
+
writeElectronDebug({
|
|
443
|
+
event: 'main-bridge-eager-connect-failed',
|
|
444
|
+
error: err instanceof Error ? err.message : String(err),
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
330
448
|
// Initialize BiDi session if WebSocket URL available
|
|
331
449
|
const wsUrl = driver.__wsUrl;
|
|
332
|
-
if (wsUrl &&
|
|
450
|
+
if (wsUrl && bidiRequested) {
|
|
333
451
|
await browser.initBiDi(wsUrl);
|
|
334
452
|
}
|
|
335
453
|
// Load session state if provided
|
|
@@ -434,13 +552,13 @@ export class Browser {
|
|
|
434
552
|
return this._clock;
|
|
435
553
|
}
|
|
436
554
|
/**
|
|
437
|
-
* Accessibility audits via axe-core
|
|
555
|
+
* Accessibility audits via axe-core.
|
|
438
556
|
*
|
|
439
557
|
* ```ts
|
|
440
558
|
* await browser.a11y.check({ disableRules: ['color-contrast'] });
|
|
441
559
|
* ```
|
|
442
560
|
*
|
|
443
|
-
*
|
|
561
|
+
* axe-core ships as a direct dependency of craftdriver.
|
|
444
562
|
*/
|
|
445
563
|
get a11y() {
|
|
446
564
|
if (!this._a11y) {
|
|
@@ -448,6 +566,26 @@ export class Browser {
|
|
|
448
566
|
}
|
|
449
567
|
return this._a11y;
|
|
450
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Electron **main-process** access. `browser.electron.executeMain(fn, ...args)`
|
|
571
|
+
* runs `fn(electron, ...args)` in the app's main process (full `electron`
|
|
572
|
+
* module), complementing the renderer automation on `Browser` itself.
|
|
573
|
+
*
|
|
574
|
+
* Requires launching with `electron: { mainProcess: true }`; otherwise
|
|
575
|
+
* `executeMain` throws `ELECTRON_MAIN_UNAVAILABLE`.
|
|
576
|
+
*
|
|
577
|
+
* ```ts
|
|
578
|
+
* const version = await browser.electron.executeMain((electron) => electron.app.getVersion());
|
|
579
|
+
* ```
|
|
580
|
+
*/
|
|
581
|
+
get electron() {
|
|
582
|
+
if (!this._electron) {
|
|
583
|
+
this._electron = new ElectronRemote(this._electronInspect, {
|
|
584
|
+
appBinaryPath: this._electronAppBinaryPath,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return this._electron;
|
|
588
|
+
}
|
|
451
589
|
/**
|
|
452
590
|
* Save current session state (cookies + localStorage) to file
|
|
453
591
|
*/
|
|
@@ -460,61 +598,77 @@ export class Browser {
|
|
|
460
598
|
async loadState(path) {
|
|
461
599
|
return this.storage.loadState(path);
|
|
462
600
|
}
|
|
601
|
+
async _runTracedAction(name, args, selector, run) {
|
|
602
|
+
const actionIndex = this._tracer?.recordAction(name, args, selector);
|
|
603
|
+
try {
|
|
604
|
+
const result = await run();
|
|
605
|
+
this._tracer?.recordActionEnd(actionIndex);
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
this._tracer?.recordActionEnd(actionIndex, error);
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
463
613
|
async navigateTo(url, opts) {
|
|
464
|
-
this.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
614
|
+
return this._runTracedAction('navigateTo', [url, opts], url, async () => {
|
|
615
|
+
const waitUntil = opts?.waitUntil ?? 'load';
|
|
616
|
+
// Classic-first for ordinary `waitUntil: 'load'` navigations: Classic
|
|
617
|
+
// already blocks until `document.readyState === 'complete'`, so the common
|
|
618
|
+
// case avoids a BiDi round trip. Preload-backed sessions stay on BiDi
|
|
619
|
+
// because the next operation is often a BiDi script evaluation into the new
|
|
620
|
+
// document; mixing Classic navigate + immediate BiDi evaluate can race with
|
|
621
|
+
// the browser clearing old execution contexts on loaded-but-settling pages.
|
|
622
|
+
const needsBiDi = waitUntil !== 'load' || this.hasDefaultNavigationInitScripts();
|
|
623
|
+
const context = needsBiDi ? this.bidiSession?.getContext() : undefined;
|
|
624
|
+
if (context && this.bidiSession?.isConnected()) {
|
|
625
|
+
const conn = this.bidiSession.getConnection();
|
|
626
|
+
const bidiWait = bidiWaitFor(waitUntil);
|
|
627
|
+
await conn.send('browsingContext.navigate', { context, url, wait: bidiWait });
|
|
628
|
+
if (waitUntil === 'networkidle') {
|
|
629
|
+
await this.bidiSession.network.waitForNetworkIdle({
|
|
630
|
+
timeout: this.defaults.navigationTimeout,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Classic path — default 'load', or BiDi unavailable/no context yet.
|
|
636
|
+
await this.driver.navigateTo(url);
|
|
478
637
|
if (waitUntil === 'networkidle') {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
});
|
|
638
|
+
// Best-effort: Classic can't track network events; give it a short settle time
|
|
639
|
+
await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
482
640
|
}
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
// Classic path — default 'load', or BiDi unavailable/no context yet.
|
|
486
|
-
await this.driver.navigateTo(url);
|
|
487
|
-
if (waitUntil === 'networkidle') {
|
|
488
|
-
// Best-effort: Classic can't track network events; give it a short settle time
|
|
489
|
-
await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
490
|
-
}
|
|
641
|
+
});
|
|
491
642
|
}
|
|
492
643
|
/**
|
|
493
644
|
* Navigate the active page back one step in its session history.
|
|
494
645
|
* Proxies to {@link Page.goBack} on {@link activePage}.
|
|
495
646
|
*/
|
|
496
647
|
async goBack() {
|
|
497
|
-
this.
|
|
498
|
-
|
|
499
|
-
|
|
648
|
+
return this._runTracedAction('goBack', undefined, undefined, async () => {
|
|
649
|
+
const page = await this.activePage();
|
|
650
|
+
await page.goBack();
|
|
651
|
+
});
|
|
500
652
|
}
|
|
501
653
|
/**
|
|
502
654
|
* Navigate the active page forward one step in its session history.
|
|
503
655
|
* Proxies to {@link Page.goForward} on {@link activePage}.
|
|
504
656
|
*/
|
|
505
657
|
async goForward() {
|
|
506
|
-
this.
|
|
507
|
-
|
|
508
|
-
|
|
658
|
+
return this._runTracedAction('goForward', undefined, undefined, async () => {
|
|
659
|
+
const page = await this.activePage();
|
|
660
|
+
await page.goForward();
|
|
661
|
+
});
|
|
509
662
|
}
|
|
510
663
|
/**
|
|
511
664
|
* Reload the active page. Proxies to {@link Page.reload} on
|
|
512
665
|
* {@link activePage}.
|
|
513
666
|
*/
|
|
514
667
|
async reload() {
|
|
515
|
-
this.
|
|
516
|
-
|
|
517
|
-
|
|
668
|
+
return this._runTracedAction('reload', undefined, undefined, async () => {
|
|
669
|
+
const page = await this.activePage();
|
|
670
|
+
await page.reload();
|
|
671
|
+
});
|
|
518
672
|
}
|
|
519
673
|
/**
|
|
520
674
|
* Return the full HTML serialization of the active page.
|
|
@@ -529,9 +683,10 @@ export class Browser {
|
|
|
529
683
|
* Proxies to {@link Page.setContent}.
|
|
530
684
|
*/
|
|
531
685
|
async setContent(html, opts) {
|
|
532
|
-
this.
|
|
533
|
-
|
|
534
|
-
|
|
686
|
+
return this._runTracedAction('setContent', [opts], undefined, async () => {
|
|
687
|
+
const page = await this.activePage();
|
|
688
|
+
await page.setContent(html, opts);
|
|
689
|
+
});
|
|
535
690
|
}
|
|
536
691
|
/**
|
|
537
692
|
* Resize the viewport at runtime.
|
|
@@ -643,8 +798,10 @@ export class Browser {
|
|
|
643
798
|
else {
|
|
644
799
|
if (typeof coords.latitude !== 'number' ||
|
|
645
800
|
typeof coords.longitude !== 'number' ||
|
|
646
|
-
coords.latitude < -90 ||
|
|
647
|
-
coords.
|
|
801
|
+
coords.latitude < -90 ||
|
|
802
|
+
coords.latitude > 90 ||
|
|
803
|
+
coords.longitude < -180 ||
|
|
804
|
+
coords.longitude > 180) {
|
|
648
805
|
throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
|
|
649
806
|
'latitude must be in [-90, 90] and longitude in [-180, 180].');
|
|
650
807
|
}
|
|
@@ -819,7 +976,7 @@ export class Browser {
|
|
|
819
976
|
}
|
|
820
977
|
// Classic fallback: best-effort 500ms settle
|
|
821
978
|
else {
|
|
822
|
-
await new Promise(r => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
979
|
+
await new Promise((r) => setTimeout(r, NETWORK_IDLE_SETTLE_MS));
|
|
823
980
|
}
|
|
824
981
|
return;
|
|
825
982
|
}
|
|
@@ -864,7 +1021,7 @@ export class Browser {
|
|
|
864
1021
|
const readyState = await this.driver.executeScript('return document.readyState', []);
|
|
865
1022
|
if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
|
|
866
1023
|
return;
|
|
867
|
-
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1024
|
+
await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
868
1025
|
}
|
|
869
1026
|
throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
|
|
870
1027
|
}
|
|
@@ -884,6 +1041,10 @@ export class Browser {
|
|
|
884
1041
|
// Whether this session had a live BiDi WebSocket. Only the Firefox+BiDi
|
|
885
1042
|
// combination needs the port-release pause below; capture it before close().
|
|
886
1043
|
const bidiWasActive = !!this.bidiSession;
|
|
1044
|
+
// Close the Electron main-process inspector bridge, if one was opened.
|
|
1045
|
+
if (this._electron) {
|
|
1046
|
+
await this._electron.close().catch(() => { });
|
|
1047
|
+
}
|
|
887
1048
|
// Close BiDi connection first
|
|
888
1049
|
if (this.bidiSession) {
|
|
889
1050
|
await this.bidiSession.close().catch(() => { });
|
|
@@ -938,20 +1099,22 @@ export class Browser {
|
|
|
938
1099
|
'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
|
|
939
1100
|
}
|
|
940
1101
|
if (!this._tracer) {
|
|
941
|
-
this._tracer = new Tracer(this, this.bidiSession.getConnection());
|
|
1102
|
+
this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._browserName);
|
|
942
1103
|
}
|
|
943
1104
|
await this._tracer.start(opts);
|
|
944
1105
|
}
|
|
945
1106
|
/**
|
|
946
1107
|
* Stop the active trace: drain in-flight screenshots, write the closing
|
|
947
|
-
* `meta` line, and close the file.
|
|
948
|
-
*
|
|
1108
|
+
* `meta` line, and close the file. Pass `{ path: './trace.zip' }` to also
|
|
1109
|
+
* export a Vibium/Playwright-compatible archive. If your test threw and
|
|
1110
|
+
* never reached here, the raw file is still valid NDJSON — just without
|
|
1111
|
+
* the closing line.
|
|
949
1112
|
*/
|
|
950
|
-
async stopTrace() {
|
|
1113
|
+
async stopTrace(opts) {
|
|
951
1114
|
if (!this._tracer || !this._tracer.isRunning) {
|
|
952
1115
|
throw new CraftdriverError(ErrorCode.STATE_INVALID, 'stopTrace(): no trace is running. Call startTrace() first.', { detail: { feature: 'stopTrace' } });
|
|
953
1116
|
}
|
|
954
|
-
await this._tracer.stop();
|
|
1117
|
+
await this._tracer.stop(opts);
|
|
955
1118
|
}
|
|
956
1119
|
/**
|
|
957
1120
|
* Execute JavaScript in the page and return the result.
|
|
@@ -996,7 +1159,7 @@ export class Browser {
|
|
|
996
1159
|
catch (err) {
|
|
997
1160
|
if (attempt < EVAL_REALM_RETRY_ATTEMPTS &&
|
|
998
1161
|
String(err?.message).includes('execution contexts cleared')) {
|
|
999
|
-
await new Promise(r => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
|
|
1162
|
+
await new Promise((r) => setTimeout(r, EVAL_REALM_RETRY_DELAY_MS));
|
|
1000
1163
|
continue;
|
|
1001
1164
|
}
|
|
1002
1165
|
throw err;
|
|
@@ -1035,9 +1198,7 @@ export class Browser {
|
|
|
1035
1198
|
'BiDi is enabled by default — check that your browser supports it.');
|
|
1036
1199
|
}
|
|
1037
1200
|
const conn = this.bidiSession.getConnection();
|
|
1038
|
-
const fnSrc = typeof fnOrSrc === 'function'
|
|
1039
|
-
? fnOrSrc.toString()
|
|
1040
|
-
: `() => { ${fnOrSrc} }`;
|
|
1201
|
+
const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
|
|
1041
1202
|
const result = await conn.send('script.addPreloadScript', {
|
|
1042
1203
|
functionDeclaration: fnSrc,
|
|
1043
1204
|
});
|
|
@@ -1060,7 +1221,9 @@ export class Browser {
|
|
|
1060
1221
|
if (!this.bidiSession?.isConnected()) {
|
|
1061
1222
|
throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
|
|
1062
1223
|
}
|
|
1063
|
-
return this.network.waitForRequest(pattern, {
|
|
1224
|
+
return this.network.waitForRequest(pattern, {
|
|
1225
|
+
timeout: opts?.timeout ?? this.defaults.navigationTimeout,
|
|
1226
|
+
});
|
|
1064
1227
|
}
|
|
1065
1228
|
/**
|
|
1066
1229
|
* Wait for the first completed response matching a URL glob or predicate.
|
|
@@ -1079,7 +1242,9 @@ export class Browser {
|
|
|
1079
1242
|
if (!this.bidiSession?.isConnected()) {
|
|
1080
1243
|
throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
|
|
1081
1244
|
}
|
|
1082
|
-
return this.network.waitForResponse(pattern, {
|
|
1245
|
+
return this.network.waitForResponse(pattern, {
|
|
1246
|
+
timeout: opts?.timeout ?? this.defaults.navigationTimeout,
|
|
1247
|
+
});
|
|
1083
1248
|
}
|
|
1084
1249
|
on(event, listener) {
|
|
1085
1250
|
if (!this.bidiSession?.isConnected()) {
|
|
@@ -1113,7 +1278,7 @@ export class Browser {
|
|
|
1113
1278
|
const deadline = Date.now() + timeout;
|
|
1114
1279
|
while (Date.now() < deadline) {
|
|
1115
1280
|
const after = fsSync.readdirSync(dir);
|
|
1116
|
-
const newFiles = after.filter(f => !before.has(f) && !f.endsWith('.crdownload'));
|
|
1281
|
+
const newFiles = after.filter((f) => !before.has(f) && !f.endsWith('.crdownload'));
|
|
1117
1282
|
if (newFiles.length > 0) {
|
|
1118
1283
|
const filename = newFiles[0];
|
|
1119
1284
|
const filePath = path.join(dir, filename);
|
|
@@ -1125,7 +1290,7 @@ export class Browser {
|
|
|
1125
1290
|
},
|
|
1126
1291
|
};
|
|
1127
1292
|
}
|
|
1128
|
-
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1293
|
+
await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1129
1294
|
}
|
|
1130
1295
|
throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
|
|
1131
1296
|
}
|
|
@@ -1154,7 +1319,9 @@ export class Browser {
|
|
|
1154
1319
|
}
|
|
1155
1320
|
// Classic: no push events — callers must use the imperative API below
|
|
1156
1321
|
// Return a no-op unsubscribe
|
|
1157
|
-
return () => {
|
|
1322
|
+
return () => {
|
|
1323
|
+
/* no-op */
|
|
1324
|
+
};
|
|
1158
1325
|
}
|
|
1159
1326
|
/**
|
|
1160
1327
|
* Accept the currently open dialog (OK / confirm).
|
|
@@ -1162,26 +1329,28 @@ export class Browser {
|
|
|
1162
1329
|
* Uses BiDi when available, Classic WebDriver otherwise.
|
|
1163
1330
|
*/
|
|
1164
1331
|
async acceptDialog(text) {
|
|
1165
|
-
this.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1332
|
+
return this._runTracedAction('acceptDialog', text !== undefined ? [text] : undefined, undefined, async () => {
|
|
1333
|
+
if (this.bidiSession?.isConnected()) {
|
|
1334
|
+
await this.bidiSession.handleUserPrompt(true, text);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (text !== undefined)
|
|
1338
|
+
await this.driver.sendAlertText(text);
|
|
1339
|
+
await this.driver.acceptAlert();
|
|
1340
|
+
});
|
|
1173
1341
|
}
|
|
1174
1342
|
/**
|
|
1175
1343
|
* Dismiss the currently open dialog (Cancel / close).
|
|
1176
1344
|
* Uses BiDi when available, Classic WebDriver otherwise.
|
|
1177
1345
|
*/
|
|
1178
1346
|
async dismissDialog() {
|
|
1179
|
-
this.
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1347
|
+
return this._runTracedAction('dismissDialog', undefined, undefined, async () => {
|
|
1348
|
+
if (this.bidiSession?.isConnected()) {
|
|
1349
|
+
await this.bidiSession.handleUserPrompt(false);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
await this.driver.dismissAlert();
|
|
1353
|
+
});
|
|
1185
1354
|
}
|
|
1186
1355
|
/**
|
|
1187
1356
|
* Get the message text of the currently open dialog.
|
|
@@ -1210,7 +1379,10 @@ export class Browser {
|
|
|
1210
1379
|
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1211
1380
|
return new Promise((resolve, reject) => {
|
|
1212
1381
|
const tid = timeout > 0
|
|
1213
|
-
? setTimeout(() => {
|
|
1382
|
+
? setTimeout(() => {
|
|
1383
|
+
off();
|
|
1384
|
+
reject(new Error(`waitForDialog timed out after ${timeout}ms`));
|
|
1385
|
+
}, timeout)
|
|
1214
1386
|
: undefined;
|
|
1215
1387
|
const off = this.onDialog((dialog) => {
|
|
1216
1388
|
if (tid !== undefined)
|
|
@@ -1228,8 +1400,12 @@ export class Browser {
|
|
|
1228
1400
|
type: () => _type,
|
|
1229
1401
|
message: () => _message,
|
|
1230
1402
|
defaultValue: () => _defaultValue,
|
|
1231
|
-
accept: async (text) => {
|
|
1232
|
-
|
|
1403
|
+
accept: async (text) => {
|
|
1404
|
+
await this.acceptDialog(text);
|
|
1405
|
+
},
|
|
1406
|
+
dismiss: async () => {
|
|
1407
|
+
await this.dismissDialog();
|
|
1408
|
+
},
|
|
1233
1409
|
};
|
|
1234
1410
|
}
|
|
1235
1411
|
// ─── Frames ───────────────────────────────────────────────────────────────
|
|
@@ -1255,7 +1431,9 @@ export class Browser {
|
|
|
1255
1431
|
const topContextId = this.bidiSession.getContext();
|
|
1256
1432
|
if (topContextId) {
|
|
1257
1433
|
try {
|
|
1258
|
-
const tree = await conn.send('browsingContext.getTree', {
|
|
1434
|
+
const tree = await conn.send('browsingContext.getTree', {
|
|
1435
|
+
root: topContextId,
|
|
1436
|
+
});
|
|
1259
1437
|
// The BiDi context for the iframe has the top-level context as ancestor.
|
|
1260
1438
|
// We find the first child context (iframes appear as children).
|
|
1261
1439
|
const iframeSrc = await el.getAttribute('src');
|
|
@@ -1283,7 +1461,9 @@ export class Browser {
|
|
|
1283
1461
|
let tree;
|
|
1284
1462
|
if (conn && topContextId) {
|
|
1285
1463
|
try {
|
|
1286
|
-
tree = await conn.send('browsingContext.getTree', {
|
|
1464
|
+
tree = await conn.send('browsingContext.getTree', {
|
|
1465
|
+
root: topContextId,
|
|
1466
|
+
});
|
|
1287
1467
|
}
|
|
1288
1468
|
catch {
|
|
1289
1469
|
tree = undefined;
|
|
@@ -1307,7 +1487,9 @@ export class Browser {
|
|
|
1307
1487
|
async pages() {
|
|
1308
1488
|
if (this.bidiSession?.isConnected()) {
|
|
1309
1489
|
const conn = this.bidiSession.getConnection();
|
|
1310
|
-
const tree = await conn.send('browsingContext.getTree', {
|
|
1490
|
+
const tree = await conn.send('browsingContext.getTree', {
|
|
1491
|
+
maxDepth: 0,
|
|
1492
|
+
});
|
|
1311
1493
|
return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
|
|
1312
1494
|
}
|
|
1313
1495
|
// Classic fallback: use window handles
|
|
@@ -1344,16 +1526,11 @@ export class Browser {
|
|
|
1344
1526
|
}
|
|
1345
1527
|
return page;
|
|
1346
1528
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
* const popup = await browser.waitForPage(() => browser.click('#open-popup'));
|
|
1353
|
-
* await popup.waitForLoadState();
|
|
1354
|
-
* const title = await popup.title();
|
|
1355
|
-
*/
|
|
1356
|
-
async waitForPage(action, opts) {
|
|
1529
|
+
async waitForPage(actionOrMatcher, opts) {
|
|
1530
|
+
if (typeof actionOrMatcher !== 'function') {
|
|
1531
|
+
return this._waitForMatchingPage(actionOrMatcher, opts);
|
|
1532
|
+
}
|
|
1533
|
+
const action = actionOrMatcher;
|
|
1357
1534
|
const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
|
|
1358
1535
|
if (this.bidiSession?.isConnected()) {
|
|
1359
1536
|
const conn = this.bidiSession.getConnection();
|
|
@@ -1386,15 +1563,60 @@ export class Browser {
|
|
|
1386
1563
|
const deadline = Date.now() + timeout;
|
|
1387
1564
|
while (Date.now() < deadline) {
|
|
1388
1565
|
const after = await this.driver.getWindowHandles();
|
|
1389
|
-
const newHandles = after.filter(h => !before.has(h));
|
|
1566
|
+
const newHandles = after.filter((h) => !before.has(h));
|
|
1390
1567
|
if (newHandles.length > 0) {
|
|
1391
1568
|
const handle = newHandles[0];
|
|
1392
1569
|
return new Page(this.driver, handle, this.getDefaultTimeout);
|
|
1393
1570
|
}
|
|
1394
|
-
await new Promise(r => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1571
|
+
await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1395
1572
|
}
|
|
1396
1573
|
throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
|
|
1397
1574
|
}
|
|
1575
|
+
/** Poll open top-level pages until one matches `matcher`; see {@link waitForPage}. */
|
|
1576
|
+
async _waitForMatchingPage(matcher, opts) {
|
|
1577
|
+
if (matcher.url === undefined && matcher.title === undefined) {
|
|
1578
|
+
throw new Error('waitForPage(matcher): provide at least one of { url, title }.');
|
|
1579
|
+
}
|
|
1580
|
+
const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
|
|
1581
|
+
const deadline = Date.now() + timeout;
|
|
1582
|
+
const seen = new Set();
|
|
1583
|
+
for (;;) {
|
|
1584
|
+
for (const page of await this.pages()) {
|
|
1585
|
+
try {
|
|
1586
|
+
if (await this._pageMatches(page, matcher, seen)) {
|
|
1587
|
+
// Classic: make the matched window current so browser.find(...) targets
|
|
1588
|
+
// it too (BiDi drives through the returned Page's own context).
|
|
1589
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1590
|
+
await this.driver.switchToWindow(page.id()).catch(() => { });
|
|
1591
|
+
}
|
|
1592
|
+
return page;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
catch (err) {
|
|
1596
|
+
// A window that closed mid-check (e.g. the splash) — skip it, keep looking.
|
|
1597
|
+
if (!isNoSuchWindowError(err))
|
|
1598
|
+
throw err;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (Date.now() >= deadline) {
|
|
1602
|
+
const label = [
|
|
1603
|
+
matcher.title !== undefined ? `title ${String(matcher.title)}` : undefined,
|
|
1604
|
+
matcher.url !== undefined ? `url ${String(matcher.url)}` : undefined,
|
|
1605
|
+
]
|
|
1606
|
+
.filter(Boolean)
|
|
1607
|
+
.join(' and ');
|
|
1608
|
+
throw new Error(`waitForPage(matcher) timed out after ${timeout}ms — no window matched ${label}. ` +
|
|
1609
|
+
`Saw: ${[...seen].join(' | ') || '(no readable windows)'}.`);
|
|
1610
|
+
}
|
|
1611
|
+
await new Promise((r) => setTimeout(r, STATE_POLL_INTERVAL_MS));
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async _pageMatches(page, matcher, seen) {
|
|
1615
|
+
const url = matcher.url !== undefined ? await page.url() : undefined;
|
|
1616
|
+
const title = matcher.title !== undefined ? await page.title() : undefined;
|
|
1617
|
+
seen.add([title !== undefined ? `"${title}"` : '', url ?? ''].filter(Boolean).join(' '));
|
|
1618
|
+
return matchPageFields({ url, title }, matcher);
|
|
1619
|
+
}
|
|
1398
1620
|
// ─── User contexts (isolated profiles, BiDi-only) ────────────────────────
|
|
1399
1621
|
/**
|
|
1400
1622
|
* Create a new isolated user context (an "incognito profile"). Each
|
|
@@ -1451,7 +1673,9 @@ export class Browser {
|
|
|
1451
1673
|
this._contextsById.set(id, ctx);
|
|
1452
1674
|
// Evict on close so long-running suites that create many contexts don't
|
|
1453
1675
|
// accumulate dead wrappers.
|
|
1454
|
-
ctx.on('close', () => {
|
|
1676
|
+
ctx.on('close', () => {
|
|
1677
|
+
this._contextsById.delete(id);
|
|
1678
|
+
});
|
|
1455
1679
|
return ctx;
|
|
1456
1680
|
}
|
|
1457
1681
|
async newContext(opts) {
|
|
@@ -1540,7 +1764,9 @@ export class Browser {
|
|
|
1540
1764
|
// would break that contract if a future refactor made the branch reachable.
|
|
1541
1765
|
await conn
|
|
1542
1766
|
.subscribe(['browsingContext.contextCreated', 'browsingContext.contextDestroyed'])
|
|
1543
|
-
.catch(() => {
|
|
1767
|
+
.catch(() => {
|
|
1768
|
+
/* already subscribed or unsupported; fallback sync will cover us */
|
|
1769
|
+
});
|
|
1544
1770
|
}
|
|
1545
1771
|
const onCreated = (params) => {
|
|
1546
1772
|
if (params.parent)
|
|
@@ -1575,7 +1801,9 @@ export class Browser {
|
|
|
1575
1801
|
}
|
|
1576
1802
|
async _refreshTopLevelContextCache(conn) {
|
|
1577
1803
|
const version = this._topLevelContextCacheVersion;
|
|
1578
|
-
const tree = await conn.send('browsingContext.getTree', {
|
|
1804
|
+
const tree = await conn.send('browsingContext.getTree', {
|
|
1805
|
+
maxDepth: 0,
|
|
1806
|
+
});
|
|
1579
1807
|
if (this._topLevelContextCacheVersion === version) {
|
|
1580
1808
|
this._topLevelContextUserContexts.clear();
|
|
1581
1809
|
}
|
|
@@ -1632,30 +1860,52 @@ export class Browser {
|
|
|
1632
1860
|
}
|
|
1633
1861
|
return new Page(this.driver, fallback, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1634
1862
|
}
|
|
1635
|
-
// Classic mode:
|
|
1636
|
-
|
|
1863
|
+
// Classic mode: wrap the current window. If it was closed (e.g. a splash that
|
|
1864
|
+
// replaced itself), re-point to the sole remaining top-level window when there's
|
|
1865
|
+
// exactly one — unambiguous — and otherwise ask the caller to select explicitly,
|
|
1866
|
+
// rather than guessing among several or throwing an opaque "no such window".
|
|
1867
|
+
let handle;
|
|
1868
|
+
try {
|
|
1869
|
+
handle = await this.driver.getCurrentWindowHandle();
|
|
1870
|
+
}
|
|
1871
|
+
catch (err) {
|
|
1872
|
+
if (!isNoSuchWindowError(err))
|
|
1873
|
+
throw err;
|
|
1874
|
+
}
|
|
1875
|
+
const handles = await this.driver.getWindowHandles();
|
|
1876
|
+
if (handle === undefined || !handles.includes(handle)) {
|
|
1877
|
+
if (handles.length === 0) {
|
|
1878
|
+
throw new Error('activePage(): all windows are closed.');
|
|
1879
|
+
}
|
|
1880
|
+
if (handles.length > 1) {
|
|
1881
|
+
throw new Error('activePage(): the current window was closed and several windows are open. ' +
|
|
1882
|
+
'Select one with browser.waitForPage({ title } | { url }) or browser.pages().');
|
|
1883
|
+
}
|
|
1884
|
+
handle = handles[0];
|
|
1885
|
+
await this.driver.switchToWindow(handle).catch(() => { });
|
|
1886
|
+
}
|
|
1637
1887
|
return new Page(this.driver, handle, this.getDefaultTimeout);
|
|
1638
1888
|
}
|
|
1639
1889
|
async click(selector, opts) {
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1890
|
+
return this._runTracedAction('click', undefined, selectorToString(selector), async () => {
|
|
1891
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1892
|
+
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1893
|
+
await clickWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
|
|
1894
|
+
});
|
|
1645
1895
|
}
|
|
1646
1896
|
async fill(selector, text, opts) {
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1897
|
+
return this._runTracedAction('fill', [text], selectorToString(selector), async () => {
|
|
1898
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1899
|
+
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1900
|
+
await fillWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), text, timeout);
|
|
1901
|
+
});
|
|
1652
1902
|
}
|
|
1653
1903
|
async clear(selector, opts) {
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1904
|
+
return this._runTracedAction('clear', undefined, selectorToString(selector), async () => {
|
|
1905
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1906
|
+
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1907
|
+
await clearWithFastPath(() => this.driver.findElement(by), (remaining) => this.driver.wait(until.elementIsVisible(by), { timeout: remaining }), timeout);
|
|
1908
|
+
});
|
|
1659
1909
|
}
|
|
1660
1910
|
async getValue(selector, opts) {
|
|
1661
1911
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
@@ -1786,19 +2036,27 @@ export class Browser {
|
|
|
1786
2036
|
static Key = Key;
|
|
1787
2037
|
async waitForVisible(selector, opts) {
|
|
1788
2038
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1789
|
-
await this.driver.wait(until.elementIsVisible(by), {
|
|
2039
|
+
await this.driver.wait(until.elementIsVisible(by), {
|
|
2040
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
2041
|
+
});
|
|
1790
2042
|
}
|
|
1791
2043
|
async waitForHidden(selector, opts) {
|
|
1792
2044
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1793
|
-
await this.driver.wait(until.elementIsNotVisible(by), {
|
|
2045
|
+
await this.driver.wait(until.elementIsNotVisible(by), {
|
|
2046
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
2047
|
+
});
|
|
1794
2048
|
}
|
|
1795
2049
|
async waitForAttached(selector, opts) {
|
|
1796
2050
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1797
|
-
await this.driver.wait(until.elementExists(by), {
|
|
2051
|
+
await this.driver.wait(until.elementExists(by), {
|
|
2052
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
2053
|
+
});
|
|
1798
2054
|
}
|
|
1799
2055
|
async waitForDetached(selector, opts) {
|
|
1800
2056
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1801
|
-
await this.driver.wait(until.elementNotExists(by), {
|
|
2057
|
+
await this.driver.wait(until.elementNotExists(by), {
|
|
2058
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
2059
|
+
});
|
|
1802
2060
|
}
|
|
1803
2061
|
/**
|
|
1804
2062
|
* Wait for an element to reach the given state.
|
|
@@ -1812,10 +2070,14 @@ export class Browser {
|
|
|
1812
2070
|
*/
|
|
1813
2071
|
async waitFor(selector, opts) {
|
|
1814
2072
|
switch (opts.state) {
|
|
1815
|
-
case 'visible':
|
|
1816
|
-
|
|
1817
|
-
case '
|
|
1818
|
-
|
|
2073
|
+
case 'visible':
|
|
2074
|
+
return this.waitForVisible(selector, opts);
|
|
2075
|
+
case 'hidden':
|
|
2076
|
+
return this.waitForHidden(selector, opts);
|
|
2077
|
+
case 'attached':
|
|
2078
|
+
return this.waitForAttached(selector, opts);
|
|
2079
|
+
case 'detached':
|
|
2080
|
+
return this.waitForDetached(selector, opts);
|
|
1819
2081
|
}
|
|
1820
2082
|
}
|
|
1821
2083
|
actions() {
|