craftdriver 1.4.0 → 1.6.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 +17 -3
- package/README.md +6 -2
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +4 -0
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/server.js +4 -0
- package/dist/cli/mcp/server.js.map +1 -1
- package/dist/cli/parseArgs.d.ts +2 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/bidi/storage.d.ts +13 -0
- package/dist/lib/bidi/storage.d.ts.map +1 -1
- package/dist/lib/bidi/storage.js +81 -32
- package/dist/lib/bidi/storage.js.map +1 -1
- package/dist/lib/browser.d.ts +48 -3
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +212 -86
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/browserContext.d.ts +2 -2
- package/dist/lib/browserContext.d.ts.map +1 -1
- package/dist/lib/browserContext.js +1 -1
- package/dist/lib/builder.d.ts +16 -2
- package/dist/lib/builder.d.ts.map +1 -1
- package/dist/lib/builder.js +46 -2
- package/dist/lib/builder.js.map +1 -1
- package/dist/lib/capabilities.d.ts +20 -2
- package/dist/lib/capabilities.d.ts.map +1 -1
- package/dist/lib/capabilities.js +109 -48
- package/dist/lib/capabilities.js.map +1 -1
- package/dist/lib/driver.d.ts +33 -2
- package/dist/lib/driver.d.ts.map +1 -1
- package/dist/lib/driver.js +91 -2
- package/dist/lib/driver.js.map +1 -1
- package/dist/lib/driverManager.d.ts +28 -0
- package/dist/lib/driverManager.d.ts.map +1 -1
- package/dist/lib/driverManager.js +60 -0
- package/dist/lib/driverManager.js.map +1 -1
- package/dist/lib/elementHandle.d.ts.map +1 -1
- package/dist/lib/elementHandle.js +34 -0
- package/dist/lib/elementHandle.js.map +1 -1
- package/dist/lib/http.d.ts.map +1 -1
- package/dist/lib/http.js +31 -10
- package/dist/lib/http.js.map +1 -1
- package/dist/lib/launchTarget.d.ts +22 -3
- package/dist/lib/launchTarget.d.ts.map +1 -1
- package/dist/lib/launchTarget.js +159 -9
- package/dist/lib/launchTarget.js.map +1 -1
- package/dist/lib/remote.d.ts +55 -0
- package/dist/lib/remote.d.ts.map +1 -0
- package/dist/lib/remote.js +127 -0
- package/dist/lib/remote.js.map +1 -0
- package/dist/lib/safari.d.ts +90 -0
- package/dist/lib/safari.d.ts.map +1 -0
- package/dist/lib/safari.js +136 -0
- package/dist/lib/safari.js.map +1 -0
- package/dist/lib/tracing.d.ts +1 -1
- package/dist/lib/tracing.d.ts.map +1 -1
- package/dist/lib/tracing.js.map +1 -1
- package/dist/lib/types.d.ts +63 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/vibiumTrace.d.ts +1 -1
- package/dist/lib/vibiumTrace.d.ts.map +1 -1
- package/dist/lib/webelement.d.ts.map +1 -1
- package/dist/lib/webelement.js +118 -2
- package/dist/lib/webelement.js.map +1 -1
- package/docs/api-reference.md +6 -2
- package/docs/browser-api.md +5 -4
- package/docs/driver-configuration.md +60 -0
- package/docs/getting-started.md +30 -10
- package/docs/index.md +2 -1
- package/docs/keyboard-mouse.md +7 -5
- package/docs/public/examples/displayed.html +86 -0
- package/docs/recipes/run-on-browserstack.md +265 -0
- package/docs/recipes.md +15 -1
- package/docs/remote-webdriver.md +308 -0
- package/docs/safari.md +118 -0
- package/docs/standards.md +18 -0
- package/docs/why-craftdriver.md +3 -1
- package/docs/zero-config-drivers.md +12 -0
- package/package.json +5 -1
- package/skills/craftdriver/SKILL.md +4 -1
- package/skills/craftdriver/cheatsheet.md +4 -1
package/dist/lib/browser.js
CHANGED
|
@@ -5,9 +5,11 @@ import { diagnoseElectronLaunchFailure } from './electronDiagnostics.js';
|
|
|
5
5
|
import { ElectronRemote } from './electronRemote.js';
|
|
6
6
|
import { findFreePort } from './service.js';
|
|
7
7
|
import { FirefoxService } from './firefox.js';
|
|
8
|
+
import { SafariService } from './safari.js';
|
|
8
9
|
import { resolveBrowserBinaryPath } from './driverManager.js';
|
|
9
10
|
import { buildLaunchCapabilities } from './capabilities.js';
|
|
10
11
|
import { resolveLaunchTarget } from './launchTarget.js';
|
|
12
|
+
import { parseRemoteEndpoint, buildRemoteCapabilities, redactUrlForLog } from './remote.js';
|
|
11
13
|
import { By } from './by.js';
|
|
12
14
|
import { until } from './wait.js';
|
|
13
15
|
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';
|
|
@@ -280,7 +282,12 @@ export class Browser {
|
|
|
280
282
|
/** The launched Electron app's executable path (for deep-link routing on Windows). */
|
|
281
283
|
_electronAppBinaryPath;
|
|
282
284
|
_electron;
|
|
285
|
+
/** Requested local name or provider-facing remote name. */
|
|
283
286
|
_browserName = 'chrome';
|
|
287
|
+
/** Lowercase engine family for engine-specific branches (Edge → `chrome`); see `normalizeEngine`. */
|
|
288
|
+
_engine = 'chrome';
|
|
289
|
+
/** Set for sessions created via `remote` — no local driver process/filesystem ever backs these. */
|
|
290
|
+
_isRemote = false;
|
|
284
291
|
/** Active emulation overrides, re-applied to new top-level contexts. */
|
|
285
292
|
_emulation = {};
|
|
286
293
|
/** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
|
|
@@ -315,6 +322,9 @@ export class Browser {
|
|
|
315
322
|
// Normalize first: callers may be JavaScript or JSON-driven, so reject
|
|
316
323
|
// malformed/mixed targets before touching disk or starting a driver.
|
|
317
324
|
const target = resolveLaunchTarget(options);
|
|
325
|
+
if (target.kind === 'remote') {
|
|
326
|
+
return await Browser.launchRemote(target, options);
|
|
327
|
+
}
|
|
318
328
|
const isElectron = target.kind === 'electron';
|
|
319
329
|
const name = target.browserName;
|
|
320
330
|
const isChromeFamily = name === 'chrome' || name === 'chromium';
|
|
@@ -351,9 +361,18 @@ export class Browser {
|
|
|
351
361
|
// it's the optional custom-binary resolution chain — but see
|
|
352
362
|
// resolveBrowserBinaryPath's doc: some of the env vars in that chain aren't
|
|
353
363
|
// craftdriver-opt-in, they're ambient conventions other tools may have set.
|
|
364
|
+
// Safari has no custom-binary concept: browserPath/args are rejected
|
|
365
|
+
// upstream in resolveLaunchTarget(), so target.browserPath is
|
|
366
|
+
// always undefined here. Skip resolveBrowserBinaryPath() for Safari
|
|
367
|
+
// entirely rather than calling it with 'firefox' as a stand-in — it would
|
|
368
|
+
// still probe FIREFOX_BIN/SE_FIREFOX_PATH-style env vars that have
|
|
369
|
+
// nothing to do with Safari, which is misleading even though it happens
|
|
370
|
+
// to return undefined/harmless today.
|
|
354
371
|
const browserBinary = target.kind === 'electron'
|
|
355
372
|
? electronBinary
|
|
356
|
-
:
|
|
373
|
+
: name === 'safari'
|
|
374
|
+
? undefined
|
|
375
|
+
: resolveBrowserBinaryPath(isChromeFamily ? 'chrome' : 'firefox', target.browserPath);
|
|
357
376
|
// Main-process access (opt-in): launch the Electron app with a Node inspector
|
|
358
377
|
// on a free local port. chromedriver forwards this arg to the app, enabling
|
|
359
378
|
// its main-process inspector; browser.electron connects below for log capture
|
|
@@ -399,6 +418,13 @@ export class Browser {
|
|
|
399
418
|
driverService = options.chromeService ?? new ChromeService({ browserPath: browserBinary });
|
|
400
419
|
builder.setChromeService(driverService);
|
|
401
420
|
}
|
|
421
|
+
else if (name === 'safari') {
|
|
422
|
+
// No browserPath threading (Safari has none — see the browserBinary
|
|
423
|
+
// resolution above) and no headless/args to pass through; SafariService
|
|
424
|
+
// resolves its own driver binary (never auto-downloaded).
|
|
425
|
+
driverService = options.safariService ?? new SafariService();
|
|
426
|
+
builder.setSafariService(driverService);
|
|
427
|
+
}
|
|
402
428
|
else {
|
|
403
429
|
driverService = options.firefoxService ?? new FirefoxService();
|
|
404
430
|
builder.setFirefoxService(driverService);
|
|
@@ -427,6 +453,9 @@ export class Browser {
|
|
|
427
453
|
browser._downloadsDir = downloadsDir;
|
|
428
454
|
browser._driverService = driverService;
|
|
429
455
|
browser._browserName = name;
|
|
456
|
+
// Local names are already the normalized `SupportedBrowserName`, so engine
|
|
457
|
+
// and provider-facing name coincide here (they diverge only for remote).
|
|
458
|
+
browser._engine = name;
|
|
430
459
|
browser._electronInspect = electronInspect;
|
|
431
460
|
browser._electronAppBinaryPath = electronBinary;
|
|
432
461
|
// With main-process access enabled, open the inspector bridge now so
|
|
@@ -445,14 +474,58 @@ export class Browser {
|
|
|
445
474
|
});
|
|
446
475
|
});
|
|
447
476
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
477
|
+
try {
|
|
478
|
+
// Initialize BiDi session if WebSocket URL available
|
|
479
|
+
const wsUrl = driver.__wsUrl;
|
|
480
|
+
if (wsUrl && bidiRequested) {
|
|
481
|
+
await browser.initBiDi(wsUrl);
|
|
482
|
+
}
|
|
483
|
+
// Load session state if provided
|
|
484
|
+
if (options.storageState) {
|
|
485
|
+
await browser.loadState(options.storageState);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
// The driver session (and, for Electron, the app) already exists — don't
|
|
490
|
+
// leak the process if post-launch initialization fails. Tear it down,
|
|
491
|
+
// then surface the original error.
|
|
492
|
+
await browser.quit().catch(() => { });
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
return browser;
|
|
496
|
+
}
|
|
497
|
+
/** Create a remote session without local driver or filesystem setup. */
|
|
498
|
+
static async launchRemote(target, options) {
|
|
499
|
+
const { endpoint, sessionTimeoutMs } = parseRemoteEndpoint(target.remote);
|
|
500
|
+
const caps = buildRemoteCapabilities({
|
|
501
|
+
browserName: target.browserName,
|
|
502
|
+
bidiRequested: target.bidiRequested,
|
|
503
|
+
userCapabilities: target.remote.capabilities,
|
|
504
|
+
});
|
|
505
|
+
const builder = new Builder()
|
|
506
|
+
.forBrowser(target.browserName)
|
|
507
|
+
.usingServer(endpoint, { sessionTimeoutMs })
|
|
508
|
+
.withCapabilities(caps);
|
|
509
|
+
const driver = await builder.build();
|
|
510
|
+
const browser = new Browser(driver);
|
|
511
|
+
browser._isRemote = true;
|
|
512
|
+
browser._browserName = target.browserName;
|
|
513
|
+
browser._engine = target.engine;
|
|
514
|
+
try {
|
|
515
|
+
const wsUrl = driver.__wsUrl;
|
|
516
|
+
if (wsUrl && target.bidiRequested) {
|
|
517
|
+
await browser.initBiDi(wsUrl);
|
|
518
|
+
}
|
|
519
|
+
if (options.storageState) {
|
|
520
|
+
await browser.loadState(options.storageState);
|
|
521
|
+
}
|
|
452
522
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
523
|
+
catch (err) {
|
|
524
|
+
// POST /session already succeeded — a failure initializing the session
|
|
525
|
+
// must not strand it as a paid, orphaned session on the provider. Send
|
|
526
|
+
// DELETE /session (via quit()) before surfacing the original error.
|
|
527
|
+
await browser.quit().catch(() => { });
|
|
528
|
+
throw err;
|
|
456
529
|
}
|
|
457
530
|
return browser;
|
|
458
531
|
}
|
|
@@ -485,8 +558,14 @@ export class Browser {
|
|
|
485
558
|
await new Promise((r) => setTimeout(r, BIDI_CONNECT_BACKOFF_STEP_MS * attempt));
|
|
486
559
|
}
|
|
487
560
|
else {
|
|
488
|
-
// All attempts failed; fall back to Classic-only mode.
|
|
489
|
-
|
|
561
|
+
// All attempts failed; fall back to Classic-only mode. Redact the
|
|
562
|
+
// WebSocket URL and any occurrence of it inside the error message —
|
|
563
|
+
// some remote providers proxy webSocketUrl through query-string
|
|
564
|
+
// tokens or embedded credentials, which must never reach a log.
|
|
565
|
+
const redactedWsUrl = redactUrlForLog(wsUrl);
|
|
566
|
+
const rawMessage = err instanceof Error ? err.message : String(err);
|
|
567
|
+
const redactedMessage = rawMessage.split(wsUrl).join(redactedWsUrl);
|
|
568
|
+
console.warn(`BiDi connection to ${redactedWsUrl} failed after retries, using Classic WebDriver only: ${redactedMessage}`);
|
|
490
569
|
this.bidiSession = undefined;
|
|
491
570
|
}
|
|
492
571
|
}
|
|
@@ -498,6 +577,16 @@ export class Browser {
|
|
|
498
577
|
isBiDiEnabled() {
|
|
499
578
|
return this.bidiSession?.isConnected() ?? false;
|
|
500
579
|
}
|
|
580
|
+
/** Require a connected BiDi session, with a stable Safari error code. */
|
|
581
|
+
requireBiDi(feature, chromeMessage) {
|
|
582
|
+
if (this.bidiSession?.isConnected())
|
|
583
|
+
return;
|
|
584
|
+
if (this._engine === 'safari') {
|
|
585
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} is not supported on Safari (no WebDriver BiDi). ` +
|
|
586
|
+
'Apple does not document a WebDriver BiDi endpoint for Safari.', { detail: { browserName: 'safari', feature } });
|
|
587
|
+
}
|
|
588
|
+
throw new Error(chromeMessage);
|
|
589
|
+
}
|
|
501
590
|
// === BiDi Feature Accessors ===
|
|
502
591
|
/**
|
|
503
592
|
* Network interception API (BiDi)
|
|
@@ -505,10 +594,8 @@ export class Browser {
|
|
|
505
594
|
*/
|
|
506
595
|
get network() {
|
|
507
596
|
if (!this._network) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
511
|
-
}
|
|
597
|
+
this.requireBiDi('network', 'Network interception requires BiDi. ' +
|
|
598
|
+
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
512
599
|
this._network = this.bidiSession.network;
|
|
513
600
|
}
|
|
514
601
|
return this._network;
|
|
@@ -518,10 +605,8 @@ export class Browser {
|
|
|
518
605
|
*/
|
|
519
606
|
get logs() {
|
|
520
607
|
if (!this._logs) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
524
|
-
}
|
|
608
|
+
this.requireBiDi('logs', 'Log monitoring requires BiDi. ' +
|
|
609
|
+
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
525
610
|
this._logs = this.bidiSession.logs;
|
|
526
611
|
}
|
|
527
612
|
return this._logs;
|
|
@@ -738,11 +823,9 @@ export class Browser {
|
|
|
738
823
|
* await browser.grantPermissions(['clipboard-read'], { origin: 'https://example.com' });
|
|
739
824
|
*/
|
|
740
825
|
async grantPermissions(permissions, opts) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
'which has no Classic-WebDriver equivalent.');
|
|
745
|
-
}
|
|
826
|
+
this.requireBiDi('grantPermissions()', 'grantPermissions() requires BiDi (enableBiDi: true). ' +
|
|
827
|
+
'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
|
|
828
|
+
'which has no Classic-WebDriver equivalent.');
|
|
746
829
|
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
747
830
|
throw new Error('grantPermissions: pass a non-empty array of permission names.');
|
|
748
831
|
}
|
|
@@ -781,11 +864,9 @@ export class Browser {
|
|
|
781
864
|
* await browser.setGeolocation(null); // clear
|
|
782
865
|
*/
|
|
783
866
|
async setGeolocation(coords) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
'command, which has no Classic-WebDriver equivalent.');
|
|
788
|
-
}
|
|
867
|
+
this.requireBiDi('setGeolocation()', 'setGeolocation() requires BiDi (enableBiDi: true). ' +
|
|
868
|
+
'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
|
|
869
|
+
'command, which has no Classic-WebDriver equivalent.');
|
|
789
870
|
const conn = this.bidiSession.getConnection();
|
|
790
871
|
// Apply across all top-level contexts in the default user context.
|
|
791
872
|
const tree = await conn.send('browsingContext.getTree', { maxDepth: 0 });
|
|
@@ -851,15 +932,13 @@ export class Browser {
|
|
|
851
932
|
async emulate(options) {
|
|
852
933
|
if (!options || Object.keys(options).length === 0)
|
|
853
934
|
return;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
'which have no Classic-WebDriver equivalent.');
|
|
859
|
-
}
|
|
935
|
+
this.requireBiDi('emulate()', 'emulate() requires BiDi (enableBiDi: true). ' +
|
|
936
|
+
'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
|
|
937
|
+
'(and the BiDi+CDP bridge for media features and offline), ' +
|
|
938
|
+
'which have no Classic-WebDriver equivalent.');
|
|
860
939
|
// Validate Chromium-only fields up front so we fail before mutating state.
|
|
861
940
|
const chromiumOnly = ['colorScheme', 'reducedMotion', 'forcedColors', 'offline'];
|
|
862
|
-
const isFirefox = this.
|
|
941
|
+
const isFirefox = this._engine === 'firefox';
|
|
863
942
|
if (isFirefox) {
|
|
864
943
|
for (const k of chromiumOnly) {
|
|
865
944
|
if (k in options) {
|
|
@@ -1065,7 +1144,7 @@ export class Browser {
|
|
|
1065
1144
|
// URL comes from the session's own webSocketUrl, not a fixed port. So scope the
|
|
1066
1145
|
// 500ms sleep to Firefox+BiDi; every other quit (all Chrome, Firefox w/o BiDi)
|
|
1067
1146
|
// skips straight to stopping the driver.
|
|
1068
|
-
if (this.
|
|
1147
|
+
if (this._engine === 'firefox' && bidiWasActive) {
|
|
1069
1148
|
await new Promise((r) => setTimeout(r, PORT_RELEASE_DELAY_MS));
|
|
1070
1149
|
}
|
|
1071
1150
|
// Stop the underlying driver service (chromedriver / geckodriver)
|
|
@@ -1096,10 +1175,10 @@ export class Browser {
|
|
|
1096
1175
|
async startTrace(opts) {
|
|
1097
1176
|
if (!this.bidiSession?.isConnected()) {
|
|
1098
1177
|
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'startTrace() requires BiDi (enableBiDi: true). ' +
|
|
1099
|
-
'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
|
|
1178
|
+
'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { browserName: this._browserName, feature: 'startTrace' } });
|
|
1100
1179
|
}
|
|
1101
1180
|
if (!this._tracer) {
|
|
1102
|
-
this._tracer = new Tracer(this, this.bidiSession.getConnection(), this.
|
|
1181
|
+
this._tracer = new Tracer(this, this.bidiSession.getConnection(), this._engine);
|
|
1103
1182
|
}
|
|
1104
1183
|
await this._tracer.start(opts);
|
|
1105
1184
|
}
|
|
@@ -1193,10 +1272,8 @@ export class Browser {
|
|
|
1193
1272
|
* ```
|
|
1194
1273
|
*/
|
|
1195
1274
|
async addInitScript(fnOrSrc) {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
'BiDi is enabled by default — check that your browser supports it.');
|
|
1199
|
-
}
|
|
1275
|
+
this.requireBiDi('addInitScript()', 'addInitScript() requires BiDi. ' +
|
|
1276
|
+
'BiDi is enabled by default — check that your browser supports it.');
|
|
1200
1277
|
const conn = this.bidiSession.getConnection();
|
|
1201
1278
|
const fnSrc = typeof fnOrSrc === 'function' ? fnOrSrc.toString() : `() => { ${fnOrSrc} }`;
|
|
1202
1279
|
const result = await conn.send('script.addPreloadScript', {
|
|
@@ -1218,9 +1295,7 @@ export class Browser {
|
|
|
1218
1295
|
* Register **before** the action that triggers the request.
|
|
1219
1296
|
*/
|
|
1220
1297
|
waitForRequest(pattern, opts) {
|
|
1221
|
-
|
|
1222
|
-
throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
|
|
1223
|
-
}
|
|
1298
|
+
this.requireBiDi('waitForRequest()', 'waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
|
|
1224
1299
|
return this.network.waitForRequest(pattern, {
|
|
1225
1300
|
timeout: opts?.timeout ?? this.defaults.navigationTimeout,
|
|
1226
1301
|
});
|
|
@@ -1239,18 +1314,14 @@ export class Browser {
|
|
|
1239
1314
|
* ```
|
|
1240
1315
|
*/
|
|
1241
1316
|
waitForResponse(pattern, opts) {
|
|
1242
|
-
|
|
1243
|
-
throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
|
|
1244
|
-
}
|
|
1317
|
+
this.requireBiDi('waitForResponse()', 'waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
|
|
1245
1318
|
return this.network.waitForResponse(pattern, {
|
|
1246
1319
|
timeout: opts?.timeout ?? this.defaults.navigationTimeout,
|
|
1247
1320
|
});
|
|
1248
1321
|
}
|
|
1249
1322
|
on(event, listener) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
|
|
1253
|
-
}
|
|
1323
|
+
this.requireBiDi(`browser.on('${event}')`, `browser.on('${event}') requires BiDi (enableBiDi: true). ` +
|
|
1324
|
+
'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
|
|
1254
1325
|
if (event === 'request') {
|
|
1255
1326
|
return this.network.on('request', listener);
|
|
1256
1327
|
}
|
|
@@ -1266,6 +1337,26 @@ export class Browser {
|
|
|
1266
1337
|
* await dl.saveAs('/tmp/report.csv');
|
|
1267
1338
|
*/
|
|
1268
1339
|
async waitForDownload(action, opts) {
|
|
1340
|
+
if (this._engine === 'safari') {
|
|
1341
|
+
// safaridriver exposes no download-directory configuration craftdriver
|
|
1342
|
+
// can manage, so a browser download never lands in _downloadsDir — it
|
|
1343
|
+
// goes to the user's ~/Downloads. Without this guard, waitForDownload()
|
|
1344
|
+
// would watch an empty temp dir and fail with an opaque timeout instead
|
|
1345
|
+
// of a clear "unsupported" error. Craftdriver-managed downloads are out
|
|
1346
|
+
// of scope for Safari.
|
|
1347
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'waitForDownload() is not supported on Safari: safaridriver exposes no ' +
|
|
1348
|
+
'download-directory configuration craftdriver can manage, so downloads ' +
|
|
1349
|
+
'cannot be routed to or observed in a controlled directory.', { detail: { browserName: 'safari', feature: 'waitForDownload()' } });
|
|
1350
|
+
}
|
|
1351
|
+
if (this._isRemote) {
|
|
1352
|
+
// Same shape as the Safari guard above: a remote session has no
|
|
1353
|
+
// client-visible downloads directory (downloadsDir is rejected at
|
|
1354
|
+
// remote launch time), so a download never lands anywhere craftdriver
|
|
1355
|
+
// can watch. Fail immediately with a clear error instead of an opaque
|
|
1356
|
+
// timeout on an empty/nonexistent directory.
|
|
1357
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'waitForDownload() is not supported on remote sessions: remote sessions have no ' +
|
|
1358
|
+
"client-visible downloads directory; use your provider's download API if one exists.", { detail: { feature: 'waitForDownload()' } });
|
|
1359
|
+
}
|
|
1269
1360
|
const dir = this._downloadsDir;
|
|
1270
1361
|
if (!dir) {
|
|
1271
1362
|
throw new Error('waitForDownload() requires a downloads directory. Browser was not launched correctly.');
|
|
@@ -1317,7 +1408,23 @@ export class Browser {
|
|
|
1317
1408
|
await handler(dialog);
|
|
1318
1409
|
});
|
|
1319
1410
|
}
|
|
1320
|
-
|
|
1411
|
+
if (this._engine === 'safari') {
|
|
1412
|
+
// Safari is Classic-only and has no WebDriver BiDi push events, so there
|
|
1413
|
+
// is no event-driven way to detect a dialog opening. A polling fallback
|
|
1414
|
+
// (repeatedly calling getAlertText()/driver "no such alert" errors to
|
|
1415
|
+
// detect a dialog) was considered, but would need to be *proven*
|
|
1416
|
+
// reliable on real Safari before shipping. Rather than silently no-op (today's
|
|
1417
|
+
// Classic behavior for other browsers) or let waitForDialog() hang
|
|
1418
|
+
// until its own generic timeout — which would look like a missed
|
|
1419
|
+
// dialog rather than "this API isn't supported here" — fail
|
|
1420
|
+
// immediately with a clear, actionable error. Revisit if a polling
|
|
1421
|
+
// form is later measured reliable on real Safari.
|
|
1422
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'onDialog() / waitForDialog() are not supported on Safari: Safari has no ' +
|
|
1423
|
+
'WebDriver BiDi, so there is no push-event mechanism for dialogs. ' +
|
|
1424
|
+
'Use the imperative dialog API instead — acceptDialog(), dismissDialog(), ' +
|
|
1425
|
+
'getDialogMessage() — which work in Classic mode.', { detail: { browserName: 'safari', feature: 'onDialog()' } });
|
|
1426
|
+
}
|
|
1427
|
+
// Classic (non-Safari): no push events — callers must use the imperative API below
|
|
1321
1428
|
// Return a no-op unsubscribe
|
|
1322
1429
|
return () => {
|
|
1323
1430
|
/* no-op */
|
|
@@ -1378,18 +1485,31 @@ export class Browser {
|
|
|
1378
1485
|
waitForDialog(opts) {
|
|
1379
1486
|
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1380
1487
|
return new Promise((resolve, reject) => {
|
|
1381
|
-
|
|
1382
|
-
|
|
1488
|
+
let tid;
|
|
1489
|
+
let off;
|
|
1490
|
+
// onDialog() throws synchronously on Safari (no BiDi push events for
|
|
1491
|
+
// dialogs — see onDialog()'s comment). Register the handler before
|
|
1492
|
+
// scheduling the timeout so that throw rejects this promise
|
|
1493
|
+
// immediately instead of leaving a dangling timer whose callback would
|
|
1494
|
+
// later reference `off` before it was ever assigned.
|
|
1495
|
+
try {
|
|
1496
|
+
off = this.onDialog((dialog) => {
|
|
1497
|
+
if (tid !== undefined)
|
|
1498
|
+
clearTimeout(tid);
|
|
1499
|
+
off();
|
|
1500
|
+
resolve(dialog);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
reject(err);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (timeout > 0) {
|
|
1508
|
+
tid = setTimeout(() => {
|
|
1383
1509
|
off();
|
|
1384
1510
|
reject(new Error(`waitForDialog timed out after ${timeout}ms`));
|
|
1385
|
-
}, timeout)
|
|
1386
|
-
|
|
1387
|
-
const off = this.onDialog((dialog) => {
|
|
1388
|
-
if (tid !== undefined)
|
|
1389
|
-
clearTimeout(tid);
|
|
1390
|
-
off();
|
|
1391
|
-
resolve(dialog);
|
|
1392
|
-
});
|
|
1511
|
+
}, timeout);
|
|
1512
|
+
}
|
|
1393
1513
|
});
|
|
1394
1514
|
}
|
|
1395
1515
|
_buildDialog(params) {
|
|
@@ -1512,10 +1632,8 @@ export class Browser {
|
|
|
1512
1632
|
* await page.waitForLoadState();
|
|
1513
1633
|
*/
|
|
1514
1634
|
async openPage(opts) {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
'WebDriver Classic cannot create top-level browsing contexts.');
|
|
1518
|
-
}
|
|
1635
|
+
this.requireBiDi('openPage()', 'openPage() requires BiDi (enableBiDi: true). ' +
|
|
1636
|
+
'WebDriver Classic cannot create top-level browsing contexts.');
|
|
1519
1637
|
const conn = this.bidiSession.getConnection();
|
|
1520
1638
|
const created = await conn.send('browsingContext.create', {
|
|
1521
1639
|
type: opts?.type ?? 'tab',
|
|
@@ -1679,10 +1797,8 @@ export class Browser {
|
|
|
1679
1797
|
return ctx;
|
|
1680
1798
|
}
|
|
1681
1799
|
async newContext(opts) {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
'WebDriver Classic has no concept of user contexts.');
|
|
1685
|
-
}
|
|
1800
|
+
this.requireBiDi('newContext()', 'newContext() requires BiDi (enableBiDi: true). ' +
|
|
1801
|
+
'WebDriver Classic has no concept of user contexts.');
|
|
1686
1802
|
const conn = this.bidiSession.getConnection();
|
|
1687
1803
|
const created = await conn.send('browser.createUserContext', {});
|
|
1688
1804
|
const ctx = this._wrapContext(created.userContext, {
|
|
@@ -1692,7 +1808,7 @@ export class Browser {
|
|
|
1692
1808
|
if (opts?.storageState !== undefined) {
|
|
1693
1809
|
await ctx.loadStorageState(opts.storageState);
|
|
1694
1810
|
}
|
|
1695
|
-
// Apply
|
|
1811
|
+
// Apply identity & device emulation options. Each setter is `userContexts`-scoped,
|
|
1696
1812
|
// so future pages in this context inherit automatically.
|
|
1697
1813
|
if (opts?.locale !== undefined)
|
|
1698
1814
|
await ctx.setLocale(opts.locale);
|
|
@@ -1708,11 +1824,9 @@ export class Browser {
|
|
|
1708
1824
|
* Maps to BiDi `browser.getUserContexts`. **BiDi-only.**
|
|
1709
1825
|
*/
|
|
1710
1826
|
async contexts() {
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
'Use browser.pages() to list tabs instead.');
|
|
1715
|
-
}
|
|
1827
|
+
this.requireBiDi('contexts()', 'contexts() requires BiDi (enableBiDi: true). ' +
|
|
1828
|
+
'WebDriver Classic has no concept of user contexts. ' +
|
|
1829
|
+
'Use browser.pages() to list tabs instead.');
|
|
1716
1830
|
const conn = this.bidiSession.getConnection();
|
|
1717
1831
|
const result = await conn.send('browser.getUserContexts', {});
|
|
1718
1832
|
return (result.userContexts ?? []).map((uc) => this._wrapContext(uc.userContext));
|
|
@@ -1723,10 +1837,8 @@ export class Browser {
|
|
|
1723
1837
|
* to this context. **BiDi-only.**
|
|
1724
1838
|
*/
|
|
1725
1839
|
get defaultContext() {
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
'WebDriver Classic has no concept of user contexts.');
|
|
1729
|
-
}
|
|
1840
|
+
this.requireBiDi('defaultContext', 'defaultContext requires BiDi (enableBiDi: true). ' +
|
|
1841
|
+
'WebDriver Classic has no concept of user contexts.');
|
|
1730
1842
|
if (this._defaultContext)
|
|
1731
1843
|
return this._defaultContext;
|
|
1732
1844
|
this._defaultContext = this._wrapContext('default');
|
|
@@ -1924,12 +2036,28 @@ export class Browser {
|
|
|
1924
2036
|
}
|
|
1925
2037
|
gesture = {
|
|
1926
2038
|
swipe: async ({ from, to, durationMs = 300, }) => {
|
|
2039
|
+
this.rejectTouchActionsOnLocalSafari('gesture.swipe()');
|
|
1927
2040
|
await this.driver.performTouchSwipe(from, to, durationMs);
|
|
1928
2041
|
},
|
|
1929
2042
|
pinch: async ({ center, scale = 0.5, distance = 100, durationMs = 250, }) => {
|
|
2043
|
+
this.rejectTouchActionsOnLocalSafari('gesture.pinch()');
|
|
1930
2044
|
await this.driver.performTouchPinch(center, scale, distance, durationMs);
|
|
1931
2045
|
},
|
|
1932
2046
|
};
|
|
2047
|
+
/**
|
|
2048
|
+
* Local desktop Safari has no documented touch-pointer automation surface, so
|
|
2049
|
+
* fail loudly rather than let the action silently no-op. Local only: a remote
|
|
2050
|
+
* `safari` session may be a real iOS device with a touchscreen (BrowserStack
|
|
2051
|
+
* et al.), where these gestures are legitimate — forward those.
|
|
2052
|
+
*/
|
|
2053
|
+
rejectTouchActionsOnLocalSafari(feature) {
|
|
2054
|
+
if (this._isRemote)
|
|
2055
|
+
return;
|
|
2056
|
+
if (this._engine !== 'safari')
|
|
2057
|
+
return;
|
|
2058
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, `${feature} sends a touch-pointer action, which local desktop Safari does not support. ` +
|
|
2059
|
+
'Use a remote real-device session for iPhone/iPad Safari coverage.', { detail: { browserName: 'safari', feature } });
|
|
2060
|
+
}
|
|
1933
2061
|
/**
|
|
1934
2062
|
* Capture a screenshot of the active page (viewport by default), the
|
|
1935
2063
|
* full scrollable document (`fullPage: true`), or an element matching
|
|
@@ -1962,11 +2090,9 @@ export class Browser {
|
|
|
1962
2090
|
buf = Buffer.from(b64, 'base64');
|
|
1963
2091
|
}
|
|
1964
2092
|
else if (opts?.fullPage) {
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
|
|
1969
|
-
}
|
|
2093
|
+
this.requireBiDi('screenshot({ fullPage: true })', 'screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
|
|
2094
|
+
'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
|
|
2095
|
+
'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
|
|
1970
2096
|
const conn = this.bidiSession.getConnection();
|
|
1971
2097
|
const page = await this.activePage();
|
|
1972
2098
|
const result = await conn.send('browsingContext.captureScreenshot', {
|