craftdriver 0.1.0 → 0.2.1
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 +111 -0
- package/README.md +157 -45
- package/bin/craftdriver.mjs +26 -0
- package/dist/cli/client.d.ts +17 -0
- package/dist/cli/client.d.ts.map +1 -0
- package/dist/cli/client.js +88 -0
- package/dist/cli/client.js.map +1 -0
- package/dist/cli/daemon.d.ts +15 -0
- package/dist/cli/daemon.d.ts.map +1 -0
- package/dist/cli/daemon.js +145 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/defaults.d.ts +10 -0
- package/dist/cli/defaults.d.ts.map +1 -0
- package/dist/cli/defaults.js +42 -0
- package/dist/cli/defaults.js.map +1 -0
- package/dist/cli/dispatcher.d.ts +28 -0
- package/dist/cli/dispatcher.d.ts.map +1 -0
- package/dist/cli/dispatcher.js +319 -0
- package/dist/cli/dispatcher.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +404 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +24 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +192 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/mcp/artifacts.d.ts +43 -0
- package/dist/cli/mcp/artifacts.d.ts.map +1 -0
- package/dist/cli/mcp/artifacts.js +104 -0
- package/dist/cli/mcp/artifacts.js.map +1 -0
- package/dist/cli/mcp/server.d.ts +34 -0
- package/dist/cli/mcp/server.d.ts.map +1 -0
- package/dist/cli/mcp/server.js +214 -0
- package/dist/cli/mcp/server.js.map +1 -0
- package/dist/cli/mcp/tools.d.ts +34 -0
- package/dist/cli/mcp/tools.d.ts.map +1 -0
- package/dist/cli/mcp/tools.js +236 -0
- package/dist/cli/mcp/tools.js.map +1 -0
- package/dist/cli/parseArgs.d.ts +37 -0
- package/dist/cli/parseArgs.d.ts.map +1 -0
- package/dist/cli/parseArgs.js +215 -0
- package/dist/cli/parseArgs.js.map +1 -0
- package/dist/cli/protocol.d.ts +34 -0
- package/dist/cli/protocol.d.ts.map +1 -0
- package/dist/cli/protocol.js +2 -0
- package/dist/cli/protocol.js.map +1 -0
- package/dist/cli/selector.d.ts +29 -0
- package/dist/cli/selector.d.ts.map +1 -0
- package/dist/cli/selector.js +100 -0
- package/dist/cli/selector.js.map +1 -0
- package/dist/cli/snapshot.d.ts +48 -0
- package/dist/cli/snapshot.d.ts.map +1 -0
- package/dist/cli/snapshot.js +179 -0
- package/dist/cli/snapshot.js.map +1 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/a11y.d.ts +81 -0
- package/dist/lib/a11y.d.ts.map +1 -0
- package/dist/lib/a11y.js +171 -0
- package/dist/lib/a11y.js.map +1 -0
- package/dist/lib/bidi/index.d.ts +26 -1
- package/dist/lib/bidi/index.d.ts.map +1 -1
- package/dist/lib/bidi/index.js +49 -0
- package/dist/lib/bidi/index.js.map +1 -1
- package/dist/lib/bidi/logs.d.ts +2 -2
- package/dist/lib/bidi/logs.d.ts.map +1 -1
- package/dist/lib/bidi/logs.js +2 -2
- package/dist/lib/bidi/logs.js.map +1 -1
- package/dist/lib/bidi/network.d.ts +74 -1
- package/dist/lib/bidi/network.d.ts.map +1 -1
- package/dist/lib/bidi/network.js +222 -4
- package/dist/lib/bidi/network.js.map +1 -1
- package/dist/lib/bidi/storage.d.ts +5 -3
- package/dist/lib/bidi/storage.d.ts.map +1 -1
- package/dist/lib/bidi/storage.js +14 -7
- package/dist/lib/bidi/storage.js.map +1 -1
- package/dist/lib/bidi/types.d.ts +27 -6
- package/dist/lib/bidi/types.d.ts.map +1 -1
- package/dist/lib/browser.d.ts +637 -16
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +1428 -102
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/browserContext.d.ts +486 -0
- package/dist/lib/browserContext.d.ts.map +1 -0
- package/dist/lib/browserContext.js +937 -0
- package/dist/lib/browserContext.js.map +1 -0
- package/dist/lib/builder.d.ts +3 -0
- package/dist/lib/builder.d.ts.map +1 -1
- package/dist/lib/builder.js +31 -4
- package/dist/lib/builder.js.map +1 -1
- package/dist/lib/by.d.ts +18 -2
- package/dist/lib/by.d.ts.map +1 -1
- package/dist/lib/by.js +20 -2
- package/dist/lib/by.js.map +1 -1
- package/dist/lib/chrome.d.ts +13 -1
- package/dist/lib/chrome.d.ts.map +1 -1
- package/dist/lib/chrome.js +13 -17
- package/dist/lib/chrome.js.map +1 -1
- package/dist/lib/clock.d.ts +115 -0
- package/dist/lib/clock.d.ts.map +1 -0
- package/dist/lib/clock.js +407 -0
- package/dist/lib/clock.js.map +1 -0
- package/dist/lib/driver.d.ts +43 -0
- package/dist/lib/driver.d.ts.map +1 -1
- package/dist/lib/driver.js +120 -0
- package/dist/lib/driver.js.map +1 -1
- package/dist/lib/driverManager.d.ts +25 -0
- package/dist/lib/driverManager.d.ts.map +1 -0
- package/dist/lib/driverManager.js +399 -0
- package/dist/lib/driverManager.js.map +1 -0
- package/dist/lib/elementHandle.d.ts +67 -3
- package/dist/lib/elementHandle.d.ts.map +1 -1
- package/dist/lib/elementHandle.js +209 -67
- package/dist/lib/elementHandle.js.map +1 -1
- package/dist/lib/errors.d.ts +71 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +73 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/expect.d.ts +6 -1
- package/dist/lib/expect.d.ts.map +1 -1
- package/dist/lib/expect.js +55 -37
- package/dist/lib/expect.js.map +1 -1
- package/dist/lib/firefox.d.ts +18 -0
- package/dist/lib/firefox.d.ts.map +1 -0
- package/dist/lib/firefox.js +33 -0
- package/dist/lib/firefox.js.map +1 -0
- package/dist/lib/frame.d.ts +57 -0
- package/dist/lib/frame.d.ts.map +1 -0
- package/dist/lib/frame.js +277 -0
- package/dist/lib/frame.js.map +1 -0
- package/dist/lib/keyboard.d.ts +1 -1
- package/dist/lib/keyboard.d.ts.map +1 -1
- package/dist/lib/keyboard.js +1 -1
- package/dist/lib/keyboard.js.map +1 -1
- package/dist/lib/locator.d.ts +71 -2
- package/dist/lib/locator.d.ts.map +1 -1
- package/dist/lib/locator.js +289 -31
- package/dist/lib/locator.js.map +1 -1
- package/dist/lib/mouse.d.ts +1 -1
- package/dist/lib/mouse.d.ts.map +1 -1
- package/dist/lib/mouse.js +1 -1
- package/dist/lib/mouse.js.map +1 -1
- package/dist/lib/page.d.ts +107 -0
- package/dist/lib/page.d.ts.map +1 -0
- package/dist/lib/page.js +388 -0
- package/dist/lib/page.js.map +1 -0
- package/dist/lib/tracing.d.ts +128 -0
- package/dist/lib/tracing.d.ts.map +1 -0
- package/dist/lib/tracing.js +272 -0
- package/dist/lib/tracing.js.map +1 -0
- package/dist/lib/wait.d.ts.map +1 -1
- package/dist/lib/wait.js +14 -1
- package/dist/lib/wait.js.map +1 -1
- package/dist/lib/webelement.d.ts +2 -0
- package/dist/lib/webelement.d.ts.map +1 -1
- package/dist/lib/webelement.js +18 -0
- package/dist/lib/webelement.js.map +1 -1
- package/docs/accessibility.md +177 -0
- package/docs/api-reference.md +71 -0
- package/docs/assertions.md +182 -0
- package/docs/bidi-features.md +462 -0
- package/docs/browser-api.md +673 -0
- package/docs/browser-context.md +522 -0
- package/docs/cli.md +233 -0
- package/docs/clock.md +245 -0
- package/docs/dialogs.md +143 -0
- package/docs/driver-configuration.md +64 -0
- package/docs/element-api.md +219 -0
- package/docs/emulation.md +155 -0
- package/docs/error-codes.md +67 -0
- package/docs/getting-started.md +168 -0
- package/docs/keyboard-mouse.md +250 -0
- package/docs/mcp.md +250 -0
- package/docs/mobile-emulation.md +194 -0
- package/docs/screenshots.md +146 -0
- package/docs/selectors.md +382 -0
- package/docs/session-management.md +229 -0
- package/docs/tracing.md +282 -0
- package/package.json +26 -8
- package/skills/craftdriver/SKILL.md +84 -0
- package/skills/craftdriver/cheatsheet.md +194 -0
- package/skills/craftdriver/cli.md +104 -0
- package/skills/craftdriver/patterns.md +139 -0
package/dist/lib/browser.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { Builder } from './builder.js';
|
|
2
2
|
import { ChromeService } from './chrome.js';
|
|
3
|
+
import { FirefoxService } from './firefox.js';
|
|
3
4
|
import { By } from './by.js';
|
|
4
5
|
import { until } from './wait.js';
|
|
5
6
|
import { ElementHandle } from './elementHandle.js';
|
|
7
|
+
import { Locator } from './locator.js';
|
|
6
8
|
import { expectSelector } from './expect.js';
|
|
7
9
|
import fs from 'fs/promises';
|
|
8
|
-
import
|
|
10
|
+
import fsSync from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { Keyboard } from './keyboard.js';
|
|
9
14
|
import { Key } from './keys.js';
|
|
10
|
-
import {
|
|
15
|
+
import { Mouse } from './mouse.js';
|
|
11
16
|
import { ActionsBuilder } from './actions.js';
|
|
12
17
|
import { BiDiSession, SessionStateManager, } from './bidi/index.js';
|
|
18
|
+
import { Frame } from './frame.js';
|
|
19
|
+
import { Page } from './page.js';
|
|
20
|
+
import { BrowserContext } from './browserContext.js';
|
|
21
|
+
import { Tracer } from './tracing.js';
|
|
22
|
+
import { A11y } from './a11y.js';
|
|
23
|
+
import { Clock } from './clock.js';
|
|
24
|
+
import { CraftdriverError, ErrorCode } from './errors.js';
|
|
13
25
|
/** Common mobile device presets */
|
|
14
26
|
export const devices = {
|
|
15
27
|
'iPhone 14': {
|
|
@@ -45,72 +57,247 @@ export const devices = {
|
|
|
45
57
|
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',
|
|
46
58
|
},
|
|
47
59
|
};
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// BiDi value helpers — used only by Browser.evaluate()
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function serializeLocalValue(v) {
|
|
64
|
+
if (v === undefined)
|
|
65
|
+
return { type: 'undefined' };
|
|
66
|
+
if (v === null)
|
|
67
|
+
return { type: 'null' };
|
|
68
|
+
if (typeof v === 'string')
|
|
69
|
+
return { type: 'string', value: v };
|
|
70
|
+
if (typeof v === 'boolean')
|
|
71
|
+
return { type: 'boolean', value: v };
|
|
72
|
+
if (typeof v === 'bigint')
|
|
73
|
+
return { type: 'bigint', value: String(v) };
|
|
74
|
+
if (typeof v === 'number') {
|
|
75
|
+
if (Number.isNaN(v))
|
|
76
|
+
return { type: 'number', value: 'NaN' };
|
|
77
|
+
if (v === Infinity)
|
|
78
|
+
return { type: 'number', value: 'Infinity' };
|
|
79
|
+
if (v === -Infinity)
|
|
80
|
+
return { type: 'number', value: '-Infinity' };
|
|
81
|
+
if (Object.is(v, -0))
|
|
82
|
+
return { type: 'number', value: '-0' };
|
|
83
|
+
return { type: 'number', value: v };
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(v))
|
|
86
|
+
return { type: 'array', value: v.map(serializeLocalValue) };
|
|
87
|
+
if (typeof v === 'object') {
|
|
88
|
+
return {
|
|
89
|
+
type: 'object',
|
|
90
|
+
value: Object.entries(v).map(([k, val]) => [k, serializeLocalValue(val)]),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, `evaluate() argument of type "${typeof v}" is not JSON-serializable. ` +
|
|
94
|
+
`Only primitive types, arrays, and plain objects are supported.`, { detail: { argType: typeof v } });
|
|
95
|
+
}
|
|
96
|
+
function unwrapRemoteValue(v) {
|
|
97
|
+
switch (v.type) {
|
|
98
|
+
case 'undefined': return undefined;
|
|
99
|
+
case 'null': return null;
|
|
100
|
+
case 'string': return v.value;
|
|
101
|
+
case 'boolean': return v.value;
|
|
102
|
+
case 'bigint': return BigInt(v.value);
|
|
103
|
+
case 'number': {
|
|
104
|
+
if (v.value === 'NaN')
|
|
105
|
+
return NaN;
|
|
106
|
+
if (v.value === 'Infinity')
|
|
107
|
+
return Infinity;
|
|
108
|
+
if (v.value === '-Infinity')
|
|
109
|
+
return -Infinity;
|
|
110
|
+
if (v.value === '-0')
|
|
111
|
+
return -0;
|
|
112
|
+
return v.value;
|
|
113
|
+
}
|
|
114
|
+
case 'array': return (v.value ?? []).map(unwrapRemoteValue);
|
|
115
|
+
case 'object':
|
|
116
|
+
return Object.fromEntries((v.value ?? []).map(([k, val]) => [
|
|
117
|
+
typeof k === 'string' ? k : String(unwrapRemoteValue(k)),
|
|
118
|
+
unwrapRemoteValue(val),
|
|
119
|
+
]));
|
|
120
|
+
case 'date': return new Date(v.value);
|
|
121
|
+
case 'function':
|
|
122
|
+
throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, 'evaluate() returned a function, which is not JSON-serializable. ' +
|
|
123
|
+
'Return a primitive, array, or plain object instead.', { detail: { returnedType: 'function' } });
|
|
124
|
+
case 'node':
|
|
125
|
+
case 'window':
|
|
126
|
+
throw new CraftdriverError(ErrorCode.EVAL_BAD_ARG, `evaluate() returned a ${v.type} reference, which is not JSON-serializable. ` +
|
|
127
|
+
'Return a primitive, array, or plain object instead.', { detail: { returnedType: v.type } });
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Best-effort string form of a selector (CSS string or By descriptor) for tracing. */
|
|
133
|
+
function selectorToString(sel) {
|
|
134
|
+
if (sel === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
if (typeof sel === 'string')
|
|
137
|
+
return sel;
|
|
138
|
+
try {
|
|
139
|
+
if (sel && typeof sel.using === 'string') {
|
|
140
|
+
const b = sel;
|
|
141
|
+
return `${b.using}=${b.value}`;
|
|
142
|
+
}
|
|
143
|
+
return String(sel);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Recursively find the child context that matches an iframe's src URL. */
|
|
150
|
+
function findIframeContext(ctx, iframeSrc) {
|
|
151
|
+
if (!ctx.children)
|
|
152
|
+
return undefined;
|
|
153
|
+
for (const child of ctx.children) {
|
|
154
|
+
if (!iframeSrc || (child.url && child.url.startsWith(iframeSrc.replace(/\/$/, '')))) {
|
|
155
|
+
return child.context;
|
|
156
|
+
}
|
|
157
|
+
const nested = findIframeContext(child, iframeSrc);
|
|
158
|
+
if (nested)
|
|
159
|
+
return nested;
|
|
160
|
+
}
|
|
161
|
+
// If src matching fails, return the first child context
|
|
162
|
+
if (ctx.children.length > 0)
|
|
163
|
+
return ctx.children[0].context;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
48
166
|
export class Browser {
|
|
49
167
|
driver;
|
|
50
168
|
bidiSession;
|
|
51
169
|
_network;
|
|
170
|
+
_defaultContext;
|
|
171
|
+
/** Cache of {@link BrowserContext} wrappers keyed by user-context id. */
|
|
172
|
+
_contextsById = new Map();
|
|
52
173
|
_logs;
|
|
174
|
+
_tracer;
|
|
53
175
|
_storage;
|
|
176
|
+
_a11y;
|
|
177
|
+
_clock;
|
|
178
|
+
_downloadsDir;
|
|
179
|
+
_driverService;
|
|
180
|
+
_browserName = 'chrome';
|
|
181
|
+
/** Active emulation overrides, re-applied to new top-level contexts. */
|
|
182
|
+
_emulation = {};
|
|
183
|
+
/** Mutable browser-level defaults. Use setDefaultTimeout() / setDefaultNavigationTimeout() to change. */
|
|
184
|
+
defaults = { timeout: 5000, navigationTimeout: 30000 };
|
|
54
185
|
constructor(driver) {
|
|
55
186
|
this.driver = driver;
|
|
56
|
-
this.keyboard = new
|
|
57
|
-
this.mouse = new
|
|
187
|
+
this.keyboard = new Keyboard(this.driver);
|
|
188
|
+
this.mouse = new Mouse(this.driver);
|
|
58
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Set the default timeout (ms) used by all element actions, waits, and assertions
|
|
192
|
+
* when no per-call `{ timeout }` option is provided. Default: 5000.
|
|
193
|
+
*/
|
|
194
|
+
setDefaultTimeout(ms) {
|
|
195
|
+
this.defaults.timeout = ms;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Set the default navigation timeout (ms) used by navigateTo and load-state waits.
|
|
199
|
+
* Default: 30000.
|
|
200
|
+
*/
|
|
201
|
+
setDefaultNavigationTimeout(ms) {
|
|
202
|
+
this.defaults.navigationTimeout = ms;
|
|
203
|
+
}
|
|
204
|
+
/** Returns the current default timeout. Used as a live getter passed to ElementHandle / expectSelector. */
|
|
205
|
+
getDefaultTimeout = () => this.defaults.timeout;
|
|
59
206
|
static async launch(options = {}) {
|
|
60
207
|
const name = options.browserName ?? 'chrome';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
chromeService = new ChromeService();
|
|
208
|
+
const isFirefox = name === 'firefox';
|
|
209
|
+
const isChromeFamily = name === 'chrome' || name === 'chromium';
|
|
210
|
+
if (options.mobileEmulation && !isChromeFamily) {
|
|
211
|
+
throw new Error(`mobileEmulation is only supported on Chrome/Chromium (got "${name}"). ` +
|
|
212
|
+
`Firefox does not expose a device-emulation API equivalent to goog:chromeOptions.mobileEmulation.`);
|
|
67
213
|
}
|
|
68
214
|
// Handle headless mode via env var
|
|
69
215
|
const headlessEnv = process.env.HEADLESS;
|
|
70
216
|
const isHeadless = headlessEnv === 'true' || headlessEnv === '1';
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
mobile: emulation.deviceMetrics.mobile ?? true,
|
|
93
|
-
touch: emulation.deviceMetrics.touch ?? true,
|
|
94
|
-
};
|
|
217
|
+
// Set up downloads directory
|
|
218
|
+
const downloadsDir = options.downloadsDir ?? path.join(os.tmpdir(), 'craftdriver-downloads', `session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
219
|
+
await fs.mkdir(downloadsDir, { recursive: true });
|
|
220
|
+
let caps = {};
|
|
221
|
+
if (isChromeFamily) {
|
|
222
|
+
const chromeOptions = {
|
|
223
|
+
args: isHeadless ? ['--headless=new'] : [],
|
|
224
|
+
prefs: {
|
|
225
|
+
'download.default_directory': downloadsDir,
|
|
226
|
+
'download.prompt_for_download': false,
|
|
227
|
+
'safebrowsing.enabled': true,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
// Add mobile emulation if specified
|
|
231
|
+
if (options.mobileEmulation) {
|
|
232
|
+
const emulation = typeof options.mobileEmulation === 'string'
|
|
233
|
+
? devices[options.mobileEmulation]
|
|
234
|
+
: options.mobileEmulation;
|
|
235
|
+
if ('deviceName' in emulation && emulation.deviceName) {
|
|
236
|
+
// Use Chrome's built-in device name
|
|
237
|
+
chromeOptions.mobileEmulation = { deviceName: emulation.deviceName };
|
|
95
238
|
}
|
|
96
|
-
|
|
97
|
-
|
|
239
|
+
else {
|
|
240
|
+
// Build custom mobile emulation config
|
|
241
|
+
const mobileConfig = {};
|
|
242
|
+
if (emulation.deviceMetrics) {
|
|
243
|
+
mobileConfig.deviceMetrics = {
|
|
244
|
+
width: emulation.deviceMetrics.width,
|
|
245
|
+
height: emulation.deviceMetrics.height,
|
|
246
|
+
pixelRatio: emulation.deviceMetrics.pixelRatio,
|
|
247
|
+
mobile: emulation.deviceMetrics.mobile ?? true,
|
|
248
|
+
touch: emulation.deviceMetrics.touch ?? true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (emulation.userAgent) {
|
|
252
|
+
mobileConfig.userAgent = emulation.userAgent;
|
|
253
|
+
}
|
|
254
|
+
chromeOptions.mobileEmulation = mobileConfig;
|
|
98
255
|
}
|
|
99
|
-
chromeOptions.mobileEmulation = mobileConfig;
|
|
100
256
|
}
|
|
257
|
+
caps['goog:chromeOptions'] = chromeOptions;
|
|
258
|
+
}
|
|
259
|
+
else if (isFirefox) {
|
|
260
|
+
const firefoxArgs = [];
|
|
261
|
+
if (isHeadless)
|
|
262
|
+
firefoxArgs.push('-headless');
|
|
263
|
+
caps['moz:firefoxOptions'] = {
|
|
264
|
+
args: firefoxArgs,
|
|
265
|
+
prefs: {
|
|
266
|
+
'browser.download.folderList': 2,
|
|
267
|
+
'browser.download.dir': downloadsDir,
|
|
268
|
+
'browser.download.useDownloadDir': true,
|
|
269
|
+
'browser.helperApps.neverAsk.saveToDisk': 'application/octet-stream,application/pdf,text/plain,text/csv,application/zip',
|
|
270
|
+
'pdfjs.disabled': true,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
101
273
|
}
|
|
102
|
-
// Request webSocketUrl for BiDi if enabled
|
|
103
|
-
let caps = {
|
|
104
|
-
'goog:chromeOptions': chromeOptions,
|
|
105
|
-
};
|
|
106
274
|
if (options.enableBiDi !== false) {
|
|
107
275
|
// Request BiDi WebSocket URL
|
|
108
276
|
caps.webSocketUrl = true;
|
|
277
|
+
// Set all prompt types to 'ignore' so BiDi events fire and we can handle them
|
|
278
|
+
caps.unhandledPromptBehavior = {
|
|
279
|
+
alert: 'ignore',
|
|
280
|
+
confirm: 'ignore',
|
|
281
|
+
prompt: 'ignore',
|
|
282
|
+
beforeUnload: 'ignore',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const builder = new Builder().forBrowser(name);
|
|
286
|
+
let driverService;
|
|
287
|
+
if (isChromeFamily) {
|
|
288
|
+
driverService = options.chromeService ?? new ChromeService();
|
|
289
|
+
builder.setChromeService(driverService);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
driverService = options.firefoxService ?? new FirefoxService();
|
|
293
|
+
builder.setFirefoxService(driverService);
|
|
109
294
|
}
|
|
110
|
-
const builder = new Builder().forBrowser(name).setChromeService(chromeService);
|
|
111
295
|
builder.withCapabilities(caps);
|
|
112
296
|
const driver = await builder.build();
|
|
113
297
|
const browser = new Browser(driver);
|
|
298
|
+
browser._downloadsDir = downloadsDir;
|
|
299
|
+
browser._driverService = driverService;
|
|
300
|
+
browser._browserName = name;
|
|
114
301
|
// Initialize BiDi session if WebSocket URL available
|
|
115
302
|
const wsUrl = driver.__wsUrl;
|
|
116
303
|
if (wsUrl && options.enableBiDi !== false) {
|
|
@@ -126,14 +313,28 @@ export class Browser {
|
|
|
126
313
|
* Initialize BiDi WebSocket connection
|
|
127
314
|
*/
|
|
128
315
|
async initBiDi(wsUrl) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
316
|
+
const session = new BiDiSession(this.driver);
|
|
317
|
+
this.bidiSession = session;
|
|
318
|
+
// Retry up to 8 times with increasing delays — Firefox may not have finished
|
|
319
|
+
// binding its BiDi WebSocket yet (especially when a previous session just closed
|
|
320
|
+
// on the same port, or the profile is still initialising).
|
|
321
|
+
const maxAttempts = 8;
|
|
322
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
323
|
+
try {
|
|
324
|
+
await session.connect(wsUrl);
|
|
325
|
+
return; // success
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
if (attempt < maxAttempts) {
|
|
329
|
+
// Back-off: 300ms, 600ms, 900ms, 1200ms …
|
|
330
|
+
await new Promise((r) => setTimeout(r, 300 * attempt));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
// All attempts failed; fall back to Classic-only mode.
|
|
334
|
+
console.warn('BiDi connection failed after retries, using Classic WebDriver only:', err);
|
|
335
|
+
this.bidiSession = undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
137
338
|
}
|
|
138
339
|
}
|
|
139
340
|
/**
|
|
@@ -150,7 +351,8 @@ export class Browser {
|
|
|
150
351
|
get network() {
|
|
151
352
|
if (!this._network) {
|
|
152
353
|
if (!this.bidiSession?.isConnected()) {
|
|
153
|
-
throw new Error('Network interception requires BiDi.
|
|
354
|
+
throw new Error('Network interception requires BiDi. ' +
|
|
355
|
+
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
154
356
|
}
|
|
155
357
|
this._network = this.bidiSession.network;
|
|
156
358
|
}
|
|
@@ -162,7 +364,8 @@ export class Browser {
|
|
|
162
364
|
get logs() {
|
|
163
365
|
if (!this._logs) {
|
|
164
366
|
if (!this.bidiSession?.isConnected()) {
|
|
165
|
-
throw new Error('Log monitoring requires BiDi.
|
|
367
|
+
throw new Error('Log monitoring requires BiDi. ' +
|
|
368
|
+
'BiDi negotiation may have failed at launch — check browser logs for WebSocket errors.');
|
|
166
369
|
}
|
|
167
370
|
this._logs = this.bidiSession.logs;
|
|
168
371
|
}
|
|
@@ -178,6 +381,36 @@ export class Browser {
|
|
|
178
381
|
}
|
|
179
382
|
return this._storage;
|
|
180
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Virtual clock control — freeze or advance `Date`, `setTimeout`, and
|
|
386
|
+
* `setInterval` inside the page. Requires BiDi (enabled by default).
|
|
387
|
+
*
|
|
388
|
+
* ```ts
|
|
389
|
+
* await browser.clock.install({ time: '2026-01-01T09:00:00Z' });
|
|
390
|
+
* await browser.clock.fastForward('15:01');
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
get clock() {
|
|
394
|
+
if (!this._clock) {
|
|
395
|
+
this._clock = new Clock(this);
|
|
396
|
+
}
|
|
397
|
+
return this._clock;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Accessibility audits via axe-core (optional peer dependency).
|
|
401
|
+
*
|
|
402
|
+
* ```ts
|
|
403
|
+
* await browser.a11y.check({ disableRules: ['color-contrast'] });
|
|
404
|
+
* ```
|
|
405
|
+
*
|
|
406
|
+
* Requires `axe-core` to be installed: `npm install --save-dev axe-core`.
|
|
407
|
+
*/
|
|
408
|
+
get a11y() {
|
|
409
|
+
if (!this._a11y) {
|
|
410
|
+
this._a11y = new A11y({ driver: this.driver });
|
|
411
|
+
}
|
|
412
|
+
return this._a11y;
|
|
413
|
+
}
|
|
181
414
|
/**
|
|
182
415
|
* Save current session state (cookies + localStorage) to file
|
|
183
416
|
*/
|
|
@@ -190,8 +423,415 @@ export class Browser {
|
|
|
190
423
|
async loadState(path) {
|
|
191
424
|
return this.storage.loadState(path);
|
|
192
425
|
}
|
|
193
|
-
async navigateTo(url) {
|
|
194
|
-
|
|
426
|
+
async navigateTo(url, opts) {
|
|
427
|
+
this._tracer?.recordAction('navigateTo', [url, opts], url);
|
|
428
|
+
const waitUntil = opts?.waitUntil ?? 'load';
|
|
429
|
+
if (this.bidiSession?.isConnected()) {
|
|
430
|
+
const conn = this.bidiSession.getConnection();
|
|
431
|
+
const context = this.bidiSession.getContext();
|
|
432
|
+
if (!context) {
|
|
433
|
+
// No context yet — fall through to Classic
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
// BiDi path: browsingContext.navigate with the appropriate wait
|
|
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;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Classic fallback (already waits for document.readyState === 'complete')
|
|
451
|
+
await this.driver.navigateTo(url);
|
|
452
|
+
if (waitUntil === 'networkidle') {
|
|
453
|
+
// Best-effort: Classic can't track network events; give it a short settle time
|
|
454
|
+
await new Promise(r => setTimeout(r, 500));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Navigate the active page back one step in its session history.
|
|
459
|
+
* Proxies to {@link Page.goBack} on {@link activePage}.
|
|
460
|
+
*/
|
|
461
|
+
async goBack() {
|
|
462
|
+
this._tracer?.recordAction('goBack');
|
|
463
|
+
const page = await this.activePage();
|
|
464
|
+
await page.goBack();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Navigate the active page forward one step in its session history.
|
|
468
|
+
* Proxies to {@link Page.goForward} on {@link activePage}.
|
|
469
|
+
*/
|
|
470
|
+
async goForward() {
|
|
471
|
+
this._tracer?.recordAction('goForward');
|
|
472
|
+
const page = await this.activePage();
|
|
473
|
+
await page.goForward();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Reload the active page. Proxies to {@link Page.reload} on
|
|
477
|
+
* {@link activePage}.
|
|
478
|
+
*/
|
|
479
|
+
async reload() {
|
|
480
|
+
this._tracer?.recordAction('reload');
|
|
481
|
+
const page = await this.activePage();
|
|
482
|
+
await page.reload();
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Return the full HTML serialization of the active page.
|
|
486
|
+
* Proxies to {@link Page.content}.
|
|
487
|
+
*/
|
|
488
|
+
async content() {
|
|
489
|
+
const page = await this.activePage();
|
|
490
|
+
return page.content();
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Replace the active page contents with the given HTML.
|
|
494
|
+
* Proxies to {@link Page.setContent}.
|
|
495
|
+
*/
|
|
496
|
+
async setContent(html, opts) {
|
|
497
|
+
this._tracer?.recordAction('setContent', [opts]);
|
|
498
|
+
const page = await this.activePage();
|
|
499
|
+
await page.setContent(html, opts);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Resize the viewport at runtime.
|
|
503
|
+
*
|
|
504
|
+
* - **BiDi** path uses `browsingContext.setViewport` on the active page,
|
|
505
|
+
* which resizes the *layout* viewport without changing the OS window.
|
|
506
|
+
* - **Classic** fallback uses `POST /session/{id}/window/rect`, which
|
|
507
|
+
* resizes the OS window — the inner viewport may end up a few pixels
|
|
508
|
+
* smaller than `width`/`height` because of browser chrome.
|
|
509
|
+
*
|
|
510
|
+
* For full mobile emulation (DPR, user-agent, touch), pass
|
|
511
|
+
* `mobileEmulation` to {@link Browser.launch}; this method only changes
|
|
512
|
+
* the viewport box.
|
|
513
|
+
*/
|
|
514
|
+
async setViewportSize(size) {
|
|
515
|
+
if (size.width <= 0 || size.height <= 0) {
|
|
516
|
+
throw new Error(`setViewportSize: width and height must be positive integers (got ${size.width}x${size.height}).`);
|
|
517
|
+
}
|
|
518
|
+
if (this.bidiSession?.isConnected()) {
|
|
519
|
+
const conn = this.bidiSession.getConnection();
|
|
520
|
+
const page = await this.activePage();
|
|
521
|
+
await conn.send('browsingContext.setViewport', {
|
|
522
|
+
context: page.id(),
|
|
523
|
+
viewport: {
|
|
524
|
+
width: Math.round(size.width),
|
|
525
|
+
height: Math.round(size.height),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// Classic fallback: resize the OS window. Best-effort approximation.
|
|
531
|
+
await this.driver.setWindowRect({
|
|
532
|
+
width: Math.round(size.width),
|
|
533
|
+
height: Math.round(size.height),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Override one or more permissions for an origin (or for every origin
|
|
538
|
+
* if `origin` is omitted).
|
|
539
|
+
*
|
|
540
|
+
* Uses BiDi `permissions.setPermission` (W3C Permissions module).
|
|
541
|
+
* Each name in `permissions` corresponds to a `PermissionDescriptor.name`,
|
|
542
|
+
* for example: `'geolocation'`, `'notifications'`, `'clipboard-read'`,
|
|
543
|
+
* `'clipboard-write'`, `'camera'`, `'microphone'`, `'midi'`,
|
|
544
|
+
* `'background-sync'`, `'persistent-storage'`.
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* await browser.grantPermissions(['geolocation', 'notifications']);
|
|
548
|
+
* await browser.grantPermissions(['clipboard-read'], { origin: 'https://example.com' });
|
|
549
|
+
*/
|
|
550
|
+
async grantPermissions(permissions, opts) {
|
|
551
|
+
if (!this.bidiSession?.isConnected()) {
|
|
552
|
+
throw new Error('grantPermissions() requires BiDi (enableBiDi: true). ' +
|
|
553
|
+
'Permission overrides use the W3C BiDi `permissions.setPermission` command, ' +
|
|
554
|
+
'which has no Classic-WebDriver equivalent.');
|
|
555
|
+
}
|
|
556
|
+
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
557
|
+
throw new Error('grantPermissions: pass a non-empty array of permission names.');
|
|
558
|
+
}
|
|
559
|
+
const conn = this.bidiSession.getConnection();
|
|
560
|
+
const state = opts?.state ?? 'granted';
|
|
561
|
+
const origin = opts?.origin ?? (await this._currentOrigin());
|
|
562
|
+
if (!origin) {
|
|
563
|
+
throw new Error('grantPermissions: cannot infer origin from the current page. ' +
|
|
564
|
+
'Navigate to a page first, or pass `{ origin }` explicitly.');
|
|
565
|
+
}
|
|
566
|
+
for (const name of permissions) {
|
|
567
|
+
await conn.send('permissions.setPermission', {
|
|
568
|
+
descriptor: { name },
|
|
569
|
+
state,
|
|
570
|
+
origin,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Reset all permissions for an origin (or every origin) back to the
|
|
576
|
+
* browser default of `'prompt'`. Convenience wrapper around
|
|
577
|
+
* {@link grantPermissions} with `state: 'prompt'`.
|
|
578
|
+
*/
|
|
579
|
+
async clearPermissions(permissions, opts) {
|
|
580
|
+
await this.grantPermissions(permissions, { ...opts, state: 'prompt' });
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Override the geolocation reported by `navigator.geolocation`.
|
|
584
|
+
*
|
|
585
|
+
* Uses BiDi `emulation.setGeolocationOverride` (W3C BiDi
|
|
586
|
+
* `emulation` module). Pass `null` to clear the override and return
|
|
587
|
+
* to the real device location.
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* await browser.setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
|
591
|
+
* await browser.setGeolocation(null); // clear
|
|
592
|
+
*/
|
|
593
|
+
async setGeolocation(coords) {
|
|
594
|
+
if (!this.bidiSession?.isConnected()) {
|
|
595
|
+
throw new Error('setGeolocation() requires BiDi (enableBiDi: true). ' +
|
|
596
|
+
'Geolocation overrides use the W3C BiDi `emulation.setGeolocationOverride` ' +
|
|
597
|
+
'command, which has no Classic-WebDriver equivalent.');
|
|
598
|
+
}
|
|
599
|
+
const conn = this.bidiSession.getConnection();
|
|
600
|
+
// Apply across all top-level contexts in the default user context.
|
|
601
|
+
const tree = await conn.send('browsingContext.getTree', {});
|
|
602
|
+
const tops = (tree.contexts ?? []).filter((c) => !c.parent);
|
|
603
|
+
const contexts = tops.map((c) => c.context);
|
|
604
|
+
const params = { contexts };
|
|
605
|
+
if (coords === null) {
|
|
606
|
+
params.coordinates = null;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
if (typeof coords.latitude !== 'number' ||
|
|
610
|
+
typeof coords.longitude !== 'number' ||
|
|
611
|
+
coords.latitude < -90 || coords.latitude > 90 ||
|
|
612
|
+
coords.longitude < -180 || coords.longitude > 180) {
|
|
613
|
+
throw new Error(`setGeolocation: invalid coordinates ${JSON.stringify(coords)}. ` +
|
|
614
|
+
'latitude must be in [-90, 90] and longitude in [-180, 180].');
|
|
615
|
+
}
|
|
616
|
+
params.coordinates = {
|
|
617
|
+
latitude: coords.latitude,
|
|
618
|
+
longitude: coords.longitude,
|
|
619
|
+
accuracy: coords.accuracy ?? 1,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
await conn.send('emulation.setGeolocationOverride', params);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Override what the page sees from `matchMedia`, `navigator.language`,
|
|
626
|
+
* `Intl.*`, and the network stack.
|
|
627
|
+
*
|
|
628
|
+
* Pass `null` for any field to clear that override; pass `{}` for a no-op.
|
|
629
|
+
* Settings persist across navigations and are re-applied to new top-level
|
|
630
|
+
* pages opened in the same session.
|
|
631
|
+
*
|
|
632
|
+
* | Field | Transport | Cross-browser |
|
|
633
|
+
* |---|---|---|
|
|
634
|
+
* | `locale` | BiDi `emulation.setLocaleOverride` | yes |
|
|
635
|
+
* | `timezoneId` | BiDi `emulation.setTimezoneOverride` | yes |
|
|
636
|
+
* | `colorScheme` / `reducedMotion` / `forcedColors` | CDP `Emulation.setEmulatedMedia` | Chromium only |
|
|
637
|
+
* | `offline` | CDP `Network.emulateNetworkConditions` | Chromium only |
|
|
638
|
+
*
|
|
639
|
+
* Throws with a clear message if a Chromium-only field is requested on
|
|
640
|
+
* Firefox, or if BiDi negotiation failed at launch.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* // Dark mode + German formatting + Berlin time
|
|
645
|
+
* await browser.emulate({
|
|
646
|
+
* colorScheme: 'dark',
|
|
647
|
+
* locale: 'de-DE',
|
|
648
|
+
* timezoneId: 'Europe/Berlin',
|
|
649
|
+
* });
|
|
650
|
+
*
|
|
651
|
+
* // Offline PWA UI
|
|
652
|
+
* await browser.emulate({ offline: true });
|
|
653
|
+
* await browser.click('#refresh');
|
|
654
|
+
*
|
|
655
|
+
* // Clear a single override
|
|
656
|
+
* await browser.emulate({ locale: null });
|
|
657
|
+
* ```
|
|
658
|
+
*/
|
|
659
|
+
async emulate(options) {
|
|
660
|
+
if (!options || Object.keys(options).length === 0)
|
|
661
|
+
return;
|
|
662
|
+
if (!this.bidiSession?.isConnected()) {
|
|
663
|
+
throw new Error('emulate() requires BiDi (enableBiDi: true). ' +
|
|
664
|
+
'Emulation overrides use the W3C BiDi `emulation.*` commands ' +
|
|
665
|
+
'(and the BiDi+CDP bridge for media features and offline), ' +
|
|
666
|
+
'which have no Classic-WebDriver equivalent.');
|
|
667
|
+
}
|
|
668
|
+
// Validate Chromium-only fields up front so we fail before mutating state.
|
|
669
|
+
const chromiumOnly = ['colorScheme', 'reducedMotion', 'forcedColors', 'offline'];
|
|
670
|
+
const isFirefox = this._browserName === 'firefox';
|
|
671
|
+
if (isFirefox) {
|
|
672
|
+
for (const k of chromiumOnly) {
|
|
673
|
+
if (k in options) {
|
|
674
|
+
throw new Error(`emulate({ ${k} }) is not supported on Firefox yet. ` +
|
|
675
|
+
'CSS media-feature and offline overrides currently require the ' +
|
|
676
|
+
'BiDi+CDP bridge, which only Chromium exposes. ' +
|
|
677
|
+
'`locale` and `timezoneId` work on Firefox.');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const conn = this.bidiSession.getConnection();
|
|
682
|
+
const tree = await conn.send('browsingContext.getTree', {});
|
|
683
|
+
const contexts = (tree.contexts ?? []).filter((c) => !c.parent).map((c) => c.context);
|
|
684
|
+
// Merge the new options into the persisted state so subsequent pages inherit.
|
|
685
|
+
this._emulation = { ...this._emulation, ...options };
|
|
686
|
+
// --- locale (BiDi) ---
|
|
687
|
+
if ('locale' in options) {
|
|
688
|
+
await conn.send('emulation.setLocaleOverride', {
|
|
689
|
+
contexts,
|
|
690
|
+
locale: options.locale ?? null,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
// --- timezoneId (BiDi) ---
|
|
694
|
+
if ('timezoneId' in options) {
|
|
695
|
+
await conn.send('emulation.setTimezoneOverride', {
|
|
696
|
+
contexts,
|
|
697
|
+
timezone: options.timezoneId ?? null,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
// --- media features (CDP, Chromium only) ---
|
|
701
|
+
if ('colorScheme' in options || 'reducedMotion' in options || 'forcedColors' in options) {
|
|
702
|
+
const features = [];
|
|
703
|
+
const cs = this._emulation.colorScheme;
|
|
704
|
+
const rm = this._emulation.reducedMotion;
|
|
705
|
+
const fc = this._emulation.forcedColors;
|
|
706
|
+
if (cs != null)
|
|
707
|
+
features.push({ name: 'prefers-color-scheme', value: cs });
|
|
708
|
+
if (rm != null)
|
|
709
|
+
features.push({ name: 'prefers-reduced-motion', value: rm });
|
|
710
|
+
if (fc != null)
|
|
711
|
+
features.push({ name: 'forced-colors', value: fc });
|
|
712
|
+
for (const context of contexts) {
|
|
713
|
+
await this._cdpForContext(conn, context, 'Emulation.setEmulatedMedia', { features });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// --- offline (CDP, Chromium only) ---
|
|
717
|
+
if ('offline' in options) {
|
|
718
|
+
const offline = options.offline === true;
|
|
719
|
+
for (const context of contexts) {
|
|
720
|
+
await this._cdpForContext(conn, context, 'Network.emulateNetworkConditions', {
|
|
721
|
+
offline,
|
|
722
|
+
latency: 0,
|
|
723
|
+
downloadThroughput: -1,
|
|
724
|
+
uploadThroughput: -1,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Send a CDP command scoped to a single top-level browsing context.
|
|
731
|
+
* Uses the chromium-bidi `goog:cdp` vendor extension. Wraps protocol
|
|
732
|
+
* errors so callers see an actionable message.
|
|
733
|
+
*/
|
|
734
|
+
async _cdpForContext(conn, context, method, params) {
|
|
735
|
+
try {
|
|
736
|
+
const sess = await conn.send('goog:cdp.getSession', { context });
|
|
737
|
+
await conn.send('goog:cdp.sendCommand', {
|
|
738
|
+
session: sess.session,
|
|
739
|
+
method,
|
|
740
|
+
params,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
745
|
+
throw new Error(`emulate(): CDP command \`${method}\` failed (${msg}). ` +
|
|
746
|
+
'This path requires Chromium with the BiDi+CDP bridge — confirm you are running Chrome/Chromium.');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** Best-effort: derive the active page's origin (`scheme://host[:port]`). */
|
|
750
|
+
async _currentOrigin() {
|
|
751
|
+
try {
|
|
752
|
+
const u = await this.url();
|
|
753
|
+
if (!u || u === 'about:blank' || u.startsWith('data:'))
|
|
754
|
+
return undefined;
|
|
755
|
+
const parsed = new URL(u);
|
|
756
|
+
return parsed.origin;
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Wait for the page to reach a given load state.
|
|
764
|
+
* Call this after an action that triggers navigation (clicking a link, submitting a form).
|
|
765
|
+
*
|
|
766
|
+
* - `'load'` — page `load` event (default)
|
|
767
|
+
* - `'domcontentloaded'` — DOMContentLoaded (faster)
|
|
768
|
+
* - `'networkidle'` — load + no in-flight requests for 500 ms
|
|
769
|
+
*
|
|
770
|
+
* Uses BiDi events when available, falls back to polling `document.readyState`.
|
|
771
|
+
*/
|
|
772
|
+
async waitForLoadState(state = 'load', opts) {
|
|
773
|
+
// Validate-or-fallback to bound timer duration (defends against resource
|
|
774
|
+
// exhaustion from a hostile / buggy caller). CodeQL does not treat
|
|
775
|
+
// Math.min as a sanitizer, so we use an explicit range check.
|
|
776
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
777
|
+
const requested = opts?.timeout;
|
|
778
|
+
const timeout = typeof requested === 'number' && requested >= 0 && requested <= MAX_TIMEOUT_MS
|
|
779
|
+
? requested
|
|
780
|
+
: this.defaults.navigationTimeout;
|
|
781
|
+
if (state === 'networkidle') {
|
|
782
|
+
if (this.bidiSession?.isConnected()) {
|
|
783
|
+
await this.bidiSession.network.waitForNetworkIdle({ timeout });
|
|
784
|
+
}
|
|
785
|
+
// Classic fallback: best-effort 500ms settle
|
|
786
|
+
else {
|
|
787
|
+
await new Promise(r => setTimeout(r, 500));
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// --- 'load' or 'domcontentloaded' ---
|
|
792
|
+
if (this.bidiSession?.isConnected()) {
|
|
793
|
+
const context = this.bidiSession.getContext();
|
|
794
|
+
// Check readyState first. If already satisfied, return immediately —
|
|
795
|
+
// this avoids creating a timeout-Promise that could reject before the
|
|
796
|
+
// caller attaches a handler (unhandled-rejection warning).
|
|
797
|
+
const readyState = await this.driver.executeScript('return document.readyState', []);
|
|
798
|
+
const satisfied = state === 'load'
|
|
799
|
+
? readyState === 'complete'
|
|
800
|
+
: readyState === 'interactive' || readyState === 'complete';
|
|
801
|
+
if (satisfied)
|
|
802
|
+
return;
|
|
803
|
+
// Not yet in the required state — register the BiDi event listener and
|
|
804
|
+
// wait. The risk window between the executeScript above and the listener
|
|
805
|
+
// registration below is a single synchronous turn of the event loop
|
|
806
|
+
// (no await), so in practice the event cannot be missed.
|
|
807
|
+
return new Promise((resolve, reject) => {
|
|
808
|
+
const timer = setTimeout(() => {
|
|
809
|
+
unsubscribe();
|
|
810
|
+
reject(new Error(`waitForLoadState('${state}') timed out after ${timeout}ms`));
|
|
811
|
+
}, timeout);
|
|
812
|
+
const handler = (params) => {
|
|
813
|
+
if (!context || params.context === context) {
|
|
814
|
+
clearTimeout(timer);
|
|
815
|
+
unsubscribe();
|
|
816
|
+
resolve();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
// unsubscribe is assigned synchronously before any await
|
|
820
|
+
let unsubscribe = state === 'load'
|
|
821
|
+
? this.bidiSession.onLoad(handler)
|
|
822
|
+
: this.bidiSession.onDomContentLoaded(handler);
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
// Classic fallback: poll document.readyState
|
|
826
|
+
const target = state === 'load' ? 'complete' : 'interactive';
|
|
827
|
+
const deadline = Date.now() + timeout;
|
|
828
|
+
while (Date.now() < deadline) {
|
|
829
|
+
const readyState = await this.driver.executeScript('return document.readyState', []);
|
|
830
|
+
if (readyState === 'complete' || (target === 'interactive' && readyState === 'interactive'))
|
|
831
|
+
return;
|
|
832
|
+
await new Promise(r => setTimeout(r, 100));
|
|
833
|
+
}
|
|
834
|
+
throw new CraftdriverError(ErrorCode.TIMEOUT_WAITING_LOAD, `waitForLoadState('${state}') timed out after ${timeout}ms`, { detail: { state, timeout } });
|
|
195
835
|
}
|
|
196
836
|
async url() {
|
|
197
837
|
return this.driver.getCurrentUrl();
|
|
@@ -203,37 +843,669 @@ export class Browser {
|
|
|
203
843
|
return this.driver.wait(condition, a, b, c);
|
|
204
844
|
}
|
|
205
845
|
async quit() {
|
|
846
|
+
// Silently abort any running trace so we don't leak the timer.
|
|
847
|
+
if (this._tracer?.isRunning)
|
|
848
|
+
this._tracer.abort();
|
|
206
849
|
// Close BiDi connection first
|
|
207
850
|
if (this.bidiSession) {
|
|
208
851
|
await this.bidiSession.close().catch(() => { });
|
|
209
852
|
}
|
|
210
|
-
|
|
853
|
+
// DELETE the WebDriver session — this tells the driver service to close the browser.
|
|
854
|
+
await this.driver.quit().catch(() => { });
|
|
855
|
+
// Give the browser process a moment to fully release any ports (e.g. Firefox BiDi
|
|
856
|
+
// WebSocket port 9222) before we kill the driver service. Without this pause, a
|
|
857
|
+
// subsequent launch may see a 404 when connecting to the new session's WebSocket.
|
|
858
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
859
|
+
// Stop the underlying driver service (chromedriver / geckodriver)
|
|
860
|
+
// so we don't leak processes between sessions.
|
|
861
|
+
if (this._driverService) {
|
|
862
|
+
await this._driverService.stop().catch(() => { });
|
|
863
|
+
this._driverService = undefined;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// ─── Tracing ─────────────────────────────────────────────────────────────
|
|
867
|
+
/**
|
|
868
|
+
* Start a streaming trace session. Writes every event synchronously to
|
|
869
|
+
* `<outDir>/trace.ndjson` so a thrown `expect` on the next line cannot
|
|
870
|
+
* lose data. Screenshots, when enabled, land in `<outDir>/screenshots/`.
|
|
871
|
+
*
|
|
872
|
+
* **BiDi-only.** Call `stopTrace()` to write the closing marker and
|
|
873
|
+
* close the file.
|
|
874
|
+
*
|
|
875
|
+
* @example
|
|
876
|
+
* await browser.startTrace({ outDir: './artefacts/login' });
|
|
877
|
+
* try {
|
|
878
|
+
* await browser.navigateTo('/checkout');
|
|
879
|
+
* await browser.click('#pay');
|
|
880
|
+
* } finally {
|
|
881
|
+
* await browser.stopTrace();
|
|
882
|
+
* }
|
|
883
|
+
*/
|
|
884
|
+
async startTrace(opts) {
|
|
885
|
+
if (!this.bidiSession?.isConnected()) {
|
|
886
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'startTrace() requires BiDi (enableBiDi: true). ' +
|
|
887
|
+
'Tracing relies on BiDi events; Classic WebDriver does not expose them.', { detail: { feature: 'startTrace' } });
|
|
888
|
+
}
|
|
889
|
+
if (!this._tracer) {
|
|
890
|
+
this._tracer = new Tracer(this, this.bidiSession.getConnection());
|
|
891
|
+
}
|
|
892
|
+
await this._tracer.start(opts);
|
|
211
893
|
}
|
|
212
|
-
|
|
894
|
+
/**
|
|
895
|
+
* Stop the active trace: drain in-flight screenshots, write the closing
|
|
896
|
+
* `meta` line, and close the file. If your test threw and never reached
|
|
897
|
+
* here, the file is still valid NDJSON — just without the closing line.
|
|
898
|
+
*/
|
|
899
|
+
async stopTrace() {
|
|
900
|
+
if (!this._tracer || !this._tracer.isRunning) {
|
|
901
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, 'stopTrace(): no trace is running. Call startTrace() first.', { detail: { feature: 'stopTrace' } });
|
|
902
|
+
}
|
|
903
|
+
await this._tracer.stop();
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Execute JavaScript in the page and return the result.
|
|
907
|
+
*
|
|
908
|
+
* Accepts a function (with optional args) or a script string:
|
|
909
|
+
* ```ts
|
|
910
|
+
* await browser.evaluate(() => document.title);
|
|
911
|
+
* await browser.evaluate((a, b) => a + b, 2, 3); // 5
|
|
912
|
+
* await browser.evaluate('return document.title');
|
|
913
|
+
* ```
|
|
914
|
+
*
|
|
915
|
+
* Uses BiDi `script.callFunction` / `script.evaluate` when available,
|
|
916
|
+
* falls back to Classic WebDriver `executeScript`.
|
|
917
|
+
* Only JSON-serializable return values are supported.
|
|
918
|
+
*/
|
|
919
|
+
async evaluate(fn, ...args) {
|
|
920
|
+
const fnSrc = typeof fn === 'function' ? fn.toString() : fn;
|
|
921
|
+
if (this.bidiSession?.isConnected()) {
|
|
922
|
+
const conn = this.bidiSession.getConnection();
|
|
923
|
+
const context = this.bidiSession.getContext();
|
|
924
|
+
const target = context ? { context } : {};
|
|
925
|
+
let result;
|
|
926
|
+
if (typeof fn === 'function') {
|
|
927
|
+
result = await conn.send('script.callFunction', {
|
|
928
|
+
functionDeclaration: fnSrc,
|
|
929
|
+
target,
|
|
930
|
+
arguments: args.map(serializeLocalValue),
|
|
931
|
+
awaitPromise: true,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
// String form: wrap in a function so `return` statements are valid,
|
|
936
|
+
// matching the behaviour of Classic WebDriver executeScript.
|
|
937
|
+
result = await conn.send('script.callFunction', {
|
|
938
|
+
functionDeclaration: `function() { ${fnSrc} }`,
|
|
939
|
+
target,
|
|
940
|
+
arguments: [],
|
|
941
|
+
awaitPromise: true,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (result.type === 'exception') {
|
|
945
|
+
throw new CraftdriverError(ErrorCode.EVAL_THREW, `evaluate() threw an exception in the page: ${result.exceptionDetails?.text ?? 'unknown error'}`, { detail: { exception: result.exceptionDetails?.text ?? null } });
|
|
946
|
+
}
|
|
947
|
+
if (!result.result)
|
|
948
|
+
return undefined;
|
|
949
|
+
return unwrapRemoteValue(result.result);
|
|
950
|
+
}
|
|
951
|
+
// Classic fallback
|
|
952
|
+
if (typeof fn === 'function') {
|
|
953
|
+
return this.driver.executeScript(`return (${fnSrc}).apply(null, Array.from(arguments))`, args);
|
|
954
|
+
}
|
|
955
|
+
return this.driver.executeScript(fn, args);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Register a script that runs before any page script on every navigation.
|
|
959
|
+
* Returns a handle with a `remove()` method to unregister it.
|
|
960
|
+
*
|
|
961
|
+
* Requires BiDi (enabled by default). Throws a clear error otherwise.
|
|
962
|
+
*
|
|
963
|
+
* ```ts
|
|
964
|
+
* const script = await browser.addInitScript(() => {
|
|
965
|
+
* window.__flags = { darkMode: true };
|
|
966
|
+
* });
|
|
967
|
+
* await browser.navigateTo(url); // __flags is available
|
|
968
|
+
* await script.remove(); // unregister
|
|
969
|
+
* ```
|
|
970
|
+
*/
|
|
971
|
+
async addInitScript(fnOrSrc) {
|
|
972
|
+
if (!this.bidiSession?.isConnected()) {
|
|
973
|
+
throw new Error('addInitScript() requires BiDi. ' +
|
|
974
|
+
'BiDi is enabled by default — check that your browser supports it.');
|
|
975
|
+
}
|
|
976
|
+
const conn = this.bidiSession.getConnection();
|
|
977
|
+
const fnSrc = typeof fnOrSrc === 'function'
|
|
978
|
+
? fnOrSrc.toString()
|
|
979
|
+
: `() => { ${fnOrSrc} }`;
|
|
980
|
+
const result = await conn.send('script.addPreloadScript', {
|
|
981
|
+
functionDeclaration: fnSrc,
|
|
982
|
+
});
|
|
983
|
+
const scriptId = result.script;
|
|
984
|
+
return {
|
|
985
|
+
remove: async () => {
|
|
986
|
+
await conn.send('script.removePreloadScript', { script: scriptId });
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Wait for the first network request matching a URL glob or predicate.
|
|
992
|
+
* Register **before** the action that triggers the request.
|
|
993
|
+
*/
|
|
994
|
+
waitForRequest(pattern, opts) {
|
|
995
|
+
if (!this.bidiSession?.isConnected()) {
|
|
996
|
+
throw new Error('waitForRequest() requires BiDi. BiDi is enabled by default — check your browser supports it.');
|
|
997
|
+
}
|
|
998
|
+
return this.network.waitForRequest(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Wait for the first completed response matching a URL glob or predicate.
|
|
1002
|
+
* Register **before** the action that triggers the request.
|
|
1003
|
+
*
|
|
1004
|
+
* @example
|
|
1005
|
+
* ```ts
|
|
1006
|
+
* const [res] = await Promise.all([
|
|
1007
|
+
* browser.waitForResponse('/api/users'),
|
|
1008
|
+
* browser.click('#load-btn'),
|
|
1009
|
+
* ]);
|
|
1010
|
+
* expect(res.status).toBe(200);
|
|
1011
|
+
* ```
|
|
1012
|
+
*/
|
|
1013
|
+
waitForResponse(pattern, opts) {
|
|
1014
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1015
|
+
throw new Error('waitForResponse() requires BiDi. BiDi is enabled by default \u2014 check your browser supports it.');
|
|
1016
|
+
}
|
|
1017
|
+
return this.network.waitForResponse(pattern, { timeout: opts?.timeout ?? this.defaults.navigationTimeout });
|
|
1018
|
+
}
|
|
1019
|
+
on(event, listener) {
|
|
1020
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1021
|
+
throw new Error(`browser.on('${event}') requires BiDi (enableBiDi: true). ` +
|
|
1022
|
+
'Network event listeners use the W3C BiDi `network` module, which has no Classic-WebDriver equivalent.');
|
|
1023
|
+
}
|
|
1024
|
+
if (event === 'request') {
|
|
1025
|
+
return this.network.on('request', listener);
|
|
1026
|
+
}
|
|
1027
|
+
return this.network.on('response', listener);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Run `action`, then wait until a new file appears in the downloads directory.
|
|
1031
|
+
* Returns a `Download` handle with `path`, `suggestedFilename`, and `saveAs()`.
|
|
1032
|
+
*
|
|
1033
|
+
* @example
|
|
1034
|
+
* const dl = await browser.waitForDownload(() => browser.click('#download-btn'));
|
|
1035
|
+
* expect(dl.suggestedFilename).toBe('report.csv');
|
|
1036
|
+
* await dl.saveAs('/tmp/report.csv');
|
|
1037
|
+
*/
|
|
1038
|
+
async waitForDownload(action, opts) {
|
|
1039
|
+
const dir = this._downloadsDir;
|
|
1040
|
+
if (!dir) {
|
|
1041
|
+
throw new Error('waitForDownload() requires a downloads directory. Browser was not launched correctly.');
|
|
1042
|
+
}
|
|
1043
|
+
const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
|
|
1044
|
+
// Snapshot existing files before action
|
|
1045
|
+
const before = new Set(fsSync.readdirSync(dir));
|
|
1046
|
+
await action();
|
|
1047
|
+
// Poll for a new non-.crdownload file
|
|
1048
|
+
const deadline = Date.now() + timeout;
|
|
1049
|
+
while (Date.now() < deadline) {
|
|
1050
|
+
const after = fsSync.readdirSync(dir);
|
|
1051
|
+
const newFiles = after.filter(f => !before.has(f) && !f.endsWith('.crdownload'));
|
|
1052
|
+
if (newFiles.length > 0) {
|
|
1053
|
+
const filename = newFiles[0];
|
|
1054
|
+
const filePath = path.join(dir, filename);
|
|
1055
|
+
return {
|
|
1056
|
+
path: filePath,
|
|
1057
|
+
suggestedFilename: filename,
|
|
1058
|
+
saveAs: async (targetPath) => {
|
|
1059
|
+
await fs.copyFile(filePath, targetPath);
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1064
|
+
}
|
|
1065
|
+
throw new Error(`waitForDownload() timed out after ${timeout}ms — no file appeared in ${dir}`);
|
|
1066
|
+
}
|
|
1067
|
+
// ─── Dialog handling ──────────────────────────────────────────────────────
|
|
1068
|
+
/**
|
|
1069
|
+
* Register a handler called every time a browser dialog opens (alert / confirm / prompt).
|
|
1070
|
+
* The handler receives a `Dialog` object with `accept()` and `dismiss()` methods.
|
|
1071
|
+
* Returns an unsubscribe function — call it to stop listening.
|
|
1072
|
+
*
|
|
1073
|
+
* If no handler is registered, dialogs are auto-dismissed so they don't hang the test.
|
|
1074
|
+
*
|
|
1075
|
+
* @example
|
|
1076
|
+
* const off = browser.onDialog(async dialog => {
|
|
1077
|
+
* expect(dialog.message).toBe('Are you sure?');
|
|
1078
|
+
* await dialog.accept();
|
|
1079
|
+
* });
|
|
1080
|
+
* await browser.click('#confirm-btn');
|
|
1081
|
+
* off(); // stop listening
|
|
1082
|
+
*/
|
|
1083
|
+
onDialog(handler) {
|
|
1084
|
+
if (this.bidiSession?.isConnected()) {
|
|
1085
|
+
return this.bidiSession.onDialog(async (params) => {
|
|
1086
|
+
const dialog = this._buildDialog(params);
|
|
1087
|
+
await handler(dialog);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
// Classic: no push events — callers must use the imperative API below
|
|
1091
|
+
// Return a no-op unsubscribe
|
|
1092
|
+
return () => { };
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Accept the currently open dialog (OK / confirm).
|
|
1096
|
+
* For prompt dialogs, pass `text` to type into the input first.
|
|
1097
|
+
* Uses BiDi when available, Classic WebDriver otherwise.
|
|
1098
|
+
*/
|
|
1099
|
+
async acceptDialog(text) {
|
|
1100
|
+
this._tracer?.recordAction('acceptDialog', text !== undefined ? [text] : undefined);
|
|
1101
|
+
if (this.bidiSession?.isConnected()) {
|
|
1102
|
+
await this.bidiSession.handleUserPrompt(true, text);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (text !== undefined)
|
|
1106
|
+
await this.driver.sendAlertText(text);
|
|
1107
|
+
await this.driver.acceptAlert();
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Dismiss the currently open dialog (Cancel / close).
|
|
1111
|
+
* Uses BiDi when available, Classic WebDriver otherwise.
|
|
1112
|
+
*/
|
|
1113
|
+
async dismissDialog() {
|
|
1114
|
+
this._tracer?.recordAction('dismissDialog');
|
|
1115
|
+
if (this.bidiSession?.isConnected()) {
|
|
1116
|
+
await this.bidiSession.handleUserPrompt(false);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
await this.driver.dismissAlert();
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Get the message text of the currently open dialog.
|
|
1123
|
+
* Uses BiDi when available, Classic WebDriver otherwise.
|
|
1124
|
+
*/
|
|
1125
|
+
async getDialogMessage() {
|
|
1126
|
+
// BiDi surfaces the message in the userPromptOpened event, not via a query.
|
|
1127
|
+
// For the imperative (non-event) case we fall through to Classic.
|
|
1128
|
+
return this.driver.getAlertText();
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Wait for the next dialog (alert / confirm / prompt) to open and return it.
|
|
1132
|
+
* The dialog object has `accept()` and `dismiss()` methods — call one of them
|
|
1133
|
+
* or the page will be frozen waiting for the dialog.
|
|
1134
|
+
*
|
|
1135
|
+
* Matches Playwright's `page.waitForEvent('dialog')` pattern:
|
|
1136
|
+
* ```ts
|
|
1137
|
+
* const [, dialog] = await Promise.all([
|
|
1138
|
+
* browser.click('#confirm-btn'),
|
|
1139
|
+
* browser.waitForDialog(),
|
|
1140
|
+
* ]);
|
|
1141
|
+
* await dialog.accept();
|
|
1142
|
+
* ```
|
|
1143
|
+
*/
|
|
1144
|
+
waitForDialog(opts) {
|
|
1145
|
+
const timeout = opts?.timeout ?? this.defaults.timeout;
|
|
1146
|
+
return new Promise((resolve, reject) => {
|
|
1147
|
+
const tid = timeout > 0
|
|
1148
|
+
? setTimeout(() => { off(); reject(new Error(`waitForDialog timed out after ${timeout}ms`)); }, timeout)
|
|
1149
|
+
: undefined;
|
|
1150
|
+
const off = this.onDialog((dialog) => {
|
|
1151
|
+
if (tid !== undefined)
|
|
1152
|
+
clearTimeout(tid);
|
|
1153
|
+
off();
|
|
1154
|
+
resolve(dialog);
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
_buildDialog(params) {
|
|
1159
|
+
const _type = params.type ?? 'alert';
|
|
1160
|
+
const _message = String(params.message ?? '');
|
|
1161
|
+
const _defaultValue = String(params.defaultValue ?? '');
|
|
1162
|
+
return {
|
|
1163
|
+
type: () => _type,
|
|
1164
|
+
message: () => _message,
|
|
1165
|
+
defaultValue: () => _defaultValue,
|
|
1166
|
+
accept: async (text) => { await this.acceptDialog(text); },
|
|
1167
|
+
dismiss: async () => { await this.dismissDialog(); },
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
// ─── Frames ───────────────────────────────────────────────────────────────
|
|
1171
|
+
/**
|
|
1172
|
+
* Return a `Frame` object scoped to the first iframe matching `selector`.
|
|
1173
|
+
* All element methods on the returned `Frame` are automatically targeted
|
|
1174
|
+
* inside that iframe's browsing context.
|
|
1175
|
+
*
|
|
1176
|
+
* @example
|
|
1177
|
+
* const frame = await browser.frame('#stripe-iframe');
|
|
1178
|
+
* await frame.fill('#card-number', '4242 4242 4242 4242');
|
|
1179
|
+
*/
|
|
1180
|
+
async frame(selector, opts) {
|
|
1181
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1182
|
+
const el = await this.driver.wait(until.elementLocated(by), {
|
|
1183
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
1184
|
+
});
|
|
1185
|
+
const frameElementId = el.getId();
|
|
1186
|
+
// Attempt to find the BiDi context id for this iframe
|
|
1187
|
+
let bidiContextId;
|
|
1188
|
+
if (this.bidiSession?.isConnected()) {
|
|
1189
|
+
const conn = this.bidiSession.getConnection();
|
|
1190
|
+
const topContextId = this.bidiSession.getContext();
|
|
1191
|
+
if (topContextId) {
|
|
1192
|
+
try {
|
|
1193
|
+
const tree = await conn.send('browsingContext.getTree', { root: topContextId });
|
|
1194
|
+
// The BiDi context for the iframe has the top-level context as ancestor.
|
|
1195
|
+
// We find the first child context (iframes appear as children).
|
|
1196
|
+
const iframeSrc = await el.getAttribute('src');
|
|
1197
|
+
bidiContextId = findIframeContext(tree.contexts[0], iframeSrc);
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
// BiDi tree not available — fall back to Classic
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return new Frame(this.driver, frameElementId, this.getDefaultTimeout, {
|
|
1204
|
+
bidiContextId,
|
|
1205
|
+
conn,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
return new Frame(this.driver, frameElementId, this.getDefaultTimeout);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Return `Frame` objects for all iframes on the current page.
|
|
1212
|
+
*/
|
|
1213
|
+
async frames(opts) {
|
|
1214
|
+
const iframes = await this.driver.findElements(By.css('iframe'));
|
|
1215
|
+
const result = [];
|
|
1216
|
+
const conn = this.bidiSession?.isConnected() ? this.bidiSession.getConnection() : undefined;
|
|
1217
|
+
const topContextId = conn ? this.bidiSession.getContext() : undefined;
|
|
1218
|
+
let tree;
|
|
1219
|
+
if (conn && topContextId) {
|
|
1220
|
+
try {
|
|
1221
|
+
tree = await conn.send('browsingContext.getTree', { root: topContextId });
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
tree = undefined;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
for (const iframe of iframes) {
|
|
1228
|
+
let bidiContextId;
|
|
1229
|
+
if (tree) {
|
|
1230
|
+
const src = await iframe.getAttribute('src');
|
|
1231
|
+
bidiContextId = findIframeContext(tree.contexts[0], src);
|
|
1232
|
+
}
|
|
1233
|
+
result.push(new Frame(this.driver, iframe.getId(), this.getDefaultTimeout, conn ? { bidiContextId, conn } : undefined));
|
|
1234
|
+
}
|
|
1235
|
+
return result;
|
|
1236
|
+
}
|
|
1237
|
+
// ─── Pages (top-level browsing contexts: tabs & popups) ──────────────────
|
|
1238
|
+
/**
|
|
1239
|
+
* Return `Page` objects for all open top-level browsing contexts
|
|
1240
|
+
* (tabs and popup windows).
|
|
1241
|
+
*/
|
|
1242
|
+
async pages() {
|
|
1243
|
+
if (this.bidiSession?.isConnected()) {
|
|
1244
|
+
const conn = this.bidiSession.getConnection();
|
|
1245
|
+
const tree = await conn.send('browsingContext.getTree', {});
|
|
1246
|
+
return (tree.contexts ?? []).map((ctx) => new Page(this.driver, ctx.context, this.getDefaultTimeout, conn, this._wrapContext(ctx.userContext ?? 'default')));
|
|
1247
|
+
}
|
|
1248
|
+
// Classic fallback: use window handles
|
|
1249
|
+
const handles = await this.driver.getWindowHandles();
|
|
1250
|
+
const pages = [];
|
|
1251
|
+
for (const handle of handles) {
|
|
1252
|
+
pages.push(new Page(this.driver, handle, this.getDefaultTimeout));
|
|
1253
|
+
}
|
|
1254
|
+
return pages;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Open a new top-level browsing context (a tab or a window).
|
|
1258
|
+
*
|
|
1259
|
+
* Maps to BiDi `browsingContext.create`. **BiDi-only** — throws in Classic
|
|
1260
|
+
* mode because WebDriver Classic has no spec-level command for creating
|
|
1261
|
+
* top-level browsing contexts.
|
|
1262
|
+
*
|
|
1263
|
+
* @example
|
|
1264
|
+
* const page = await browser.openPage({ url: 'https://example.com', type: 'tab' });
|
|
1265
|
+
* await page.waitForLoadState();
|
|
1266
|
+
*/
|
|
1267
|
+
async openPage(opts) {
|
|
1268
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1269
|
+
throw new Error('openPage() requires BiDi (enableBiDi: true). ' +
|
|
1270
|
+
'WebDriver Classic cannot create top-level browsing contexts.');
|
|
1271
|
+
}
|
|
1272
|
+
const conn = this.bidiSession.getConnection();
|
|
1273
|
+
const created = await conn.send('browsingContext.create', {
|
|
1274
|
+
type: opts?.type ?? 'tab',
|
|
1275
|
+
});
|
|
1276
|
+
const page = new Page(this.driver, created.context, this.getDefaultTimeout, conn, this.defaultContext);
|
|
1277
|
+
if (opts?.url) {
|
|
1278
|
+
await page.navigateTo(opts.url);
|
|
1279
|
+
}
|
|
1280
|
+
return page;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Run `action` and wait for a new top-level browsing context (tab / popup)
|
|
1284
|
+
* to open. Returns a `Page` bound to the new context.
|
|
1285
|
+
*
|
|
1286
|
+
* @example
|
|
1287
|
+
* const popup = await browser.waitForPage(() => browser.click('#open-popup'));
|
|
1288
|
+
* await popup.waitForLoadState();
|
|
1289
|
+
* const title = await popup.title();
|
|
1290
|
+
*/
|
|
1291
|
+
async waitForPage(action, opts) {
|
|
1292
|
+
const timeout = opts?.timeout ?? this.defaults.navigationTimeout;
|
|
1293
|
+
if (this.bidiSession?.isConnected()) {
|
|
1294
|
+
const conn = this.bidiSession.getConnection();
|
|
1295
|
+
// Subscribe to contextCreated if not already subscribed
|
|
1296
|
+
await conn.subscribe(['browsingContext.contextCreated']).catch(() => { });
|
|
1297
|
+
return new Promise((resolve, reject) => {
|
|
1298
|
+
const timer = setTimeout(() => {
|
|
1299
|
+
off();
|
|
1300
|
+
reject(new Error(`waitForPage() timed out after ${timeout}ms`));
|
|
1301
|
+
}, timeout);
|
|
1302
|
+
const off = conn.on('browsingContext.contextCreated', (params) => {
|
|
1303
|
+
// Only top-level contexts have no parent
|
|
1304
|
+
if (!params.parent) {
|
|
1305
|
+
clearTimeout(timer);
|
|
1306
|
+
off();
|
|
1307
|
+
resolve(new Page(this.driver, params.context, this.getDefaultTimeout, conn, this._wrapContext(params.userContext ?? 'default')));
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
action().catch((err) => {
|
|
1311
|
+
clearTimeout(timer);
|
|
1312
|
+
off();
|
|
1313
|
+
reject(err);
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
// Classic fallback: snapshot existing handles, run action, poll for new one
|
|
1318
|
+
const before = new Set(await this.driver.getWindowHandles());
|
|
1319
|
+
await action();
|
|
1320
|
+
const deadline = Date.now() + timeout;
|
|
1321
|
+
while (Date.now() < deadline) {
|
|
1322
|
+
const after = await this.driver.getWindowHandles();
|
|
1323
|
+
const newHandles = after.filter(h => !before.has(h));
|
|
1324
|
+
if (newHandles.length > 0) {
|
|
1325
|
+
const handle = newHandles[0];
|
|
1326
|
+
return new Page(this.driver, handle, this.getDefaultTimeout);
|
|
1327
|
+
}
|
|
1328
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1329
|
+
}
|
|
1330
|
+
throw new Error(`waitForPage() timed out after ${timeout}ms — no new tab or popup appeared`);
|
|
1331
|
+
}
|
|
1332
|
+
// ─── User contexts (isolated profiles, BiDi-only) ────────────────────────
|
|
1333
|
+
/**
|
|
1334
|
+
* Create a new isolated user context (an "incognito profile"). Each
|
|
1335
|
+
* context has its own cookies, localStorage, and IndexedDB.
|
|
1336
|
+
*
|
|
1337
|
+
* Maps to BiDi `browser.createUserContext`. **BiDi-only** — throws in
|
|
1338
|
+
* Classic mode because WebDriver Classic has no equivalent.
|
|
1339
|
+
*
|
|
1340
|
+
* Optionally pre-seed the context with a previously-saved
|
|
1341
|
+
* `storageState` snapshot (object or path to JSON) — cookies are
|
|
1342
|
+
* applied immediately and localStorage entries land on first
|
|
1343
|
+
* navigation to each captured origin.
|
|
1344
|
+
*
|
|
1345
|
+
* @example
|
|
1346
|
+
* const alice = await browser.newContext();
|
|
1347
|
+
* const bob = await browser.newContext();
|
|
1348
|
+
* const aPage = await alice.newPage({ url: 'https://app.example.com/login' });
|
|
1349
|
+
* const bPage = await bob.newPage({ url: 'https://app.example.com/login' });
|
|
1350
|
+
* // logging in as Alice in aPage does not leak into bPage.
|
|
1351
|
+
*
|
|
1352
|
+
* @example
|
|
1353
|
+
* // Skip the login UI in every test by reusing a saved session.
|
|
1354
|
+
* const ctx = await browser.newContext({ storageState: 'auth/alice.json' });
|
|
1355
|
+
* const page = await ctx.newPage({ url: 'https://app.example.com/dashboard' });
|
|
1356
|
+
*
|
|
1357
|
+
* @example
|
|
1358
|
+
* // Pre-configure a context for a staging tenant: every relative URL
|
|
1359
|
+
* // resolves against staging, every request carries the tenant header.
|
|
1360
|
+
* const ctx = await browser.newContext({
|
|
1361
|
+
* baseURL: 'https://staging.example.com',
|
|
1362
|
+
* extraHTTPHeaders: { 'X-Tenant': 'acme' },
|
|
1363
|
+
* });
|
|
1364
|
+
* const page = await ctx.newPage({ url: '/dashboard' }); // → staging.example.com/dashboard
|
|
1365
|
+
*/
|
|
1366
|
+
/**
|
|
1367
|
+
* Return the {@link BrowserContext} wrapper for a given BiDi user-context
|
|
1368
|
+
* id, constructing it on first access and caching it thereafter. Stable
|
|
1369
|
+
* across {@link defaultContext}, {@link newContext}, and {@link contexts}
|
|
1370
|
+
* so route handlers, event listeners, and init scripts always live on
|
|
1371
|
+
* the same instance the user holds.
|
|
1372
|
+
*
|
|
1373
|
+
* Caller must guarantee BiDi is connected (`bidiSession.isConnected()`).
|
|
1374
|
+
*/
|
|
1375
|
+
_wrapContext(id, config) {
|
|
1376
|
+
const cached = this._contextsById.get(id);
|
|
1377
|
+
if (cached)
|
|
1378
|
+
return cached;
|
|
1379
|
+
const conn = this.bidiSession.getConnection();
|
|
1380
|
+
const ctx = new BrowserContext(this.driver, conn, id, this.getDefaultTimeout, () => this.defaults.navigationTimeout, { getNetwork: () => this.network, getBrowserName: () => this._browserName }, config);
|
|
1381
|
+
this._contextsById.set(id, ctx);
|
|
1382
|
+
// Evict on close so long-running suites that create many contexts don't
|
|
1383
|
+
// accumulate dead wrappers.
|
|
1384
|
+
ctx.on('close', () => { this._contextsById.delete(id); });
|
|
1385
|
+
return ctx;
|
|
1386
|
+
}
|
|
1387
|
+
async newContext(opts) {
|
|
1388
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1389
|
+
throw new Error('newContext() requires BiDi (enableBiDi: true). ' +
|
|
1390
|
+
'WebDriver Classic has no concept of user contexts.');
|
|
1391
|
+
}
|
|
1392
|
+
const conn = this.bidiSession.getConnection();
|
|
1393
|
+
const created = await conn.send('browser.createUserContext', {});
|
|
1394
|
+
const ctx = this._wrapContext(created.userContext, {
|
|
1395
|
+
baseURL: opts?.baseURL,
|
|
1396
|
+
extraHTTPHeaders: opts?.extraHTTPHeaders,
|
|
1397
|
+
});
|
|
1398
|
+
if (opts?.storageState !== undefined) {
|
|
1399
|
+
await ctx.loadStorageState(opts.storageState);
|
|
1400
|
+
}
|
|
1401
|
+
// Apply Milestone-C emulation options. Each setter is `userContexts`-scoped,
|
|
1402
|
+
// so future pages in this context inherit automatically.
|
|
1403
|
+
if (opts?.locale !== undefined)
|
|
1404
|
+
await ctx.setLocale(opts.locale);
|
|
1405
|
+
if (opts?.timezoneId !== undefined)
|
|
1406
|
+
await ctx.setTimezone(opts.timezoneId);
|
|
1407
|
+
if (opts?.geolocation !== undefined)
|
|
1408
|
+
await ctx.setGeolocation(opts.geolocation);
|
|
1409
|
+
return ctx;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Return all open user contexts, including the default one.
|
|
1413
|
+
*
|
|
1414
|
+
* Maps to BiDi `browser.getUserContexts`. **BiDi-only.**
|
|
1415
|
+
*/
|
|
1416
|
+
async contexts() {
|
|
1417
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1418
|
+
throw new Error('contexts() requires BiDi (enableBiDi: true). ' +
|
|
1419
|
+
'WebDriver Classic has no concept of user contexts. ' +
|
|
1420
|
+
'Use browser.pages() to list tabs instead.');
|
|
1421
|
+
}
|
|
1422
|
+
const conn = this.bidiSession.getConnection();
|
|
1423
|
+
const result = await conn.send('browser.getUserContexts', {});
|
|
1424
|
+
return (result.userContexts ?? []).map((uc) => this._wrapContext(uc.userContext));
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* The implicit default user context the browser started in (id `'default'`).
|
|
1428
|
+
* Pages opened via `browser.openPage()` / `browser.waitForPage()` belong
|
|
1429
|
+
* to this context. **BiDi-only.**
|
|
1430
|
+
*/
|
|
1431
|
+
get defaultContext() {
|
|
1432
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1433
|
+
throw new Error('defaultContext requires BiDi (enableBiDi: true). ' +
|
|
1434
|
+
'WebDriver Classic has no concept of user contexts.');
|
|
1435
|
+
}
|
|
1436
|
+
if (this._defaultContext)
|
|
1437
|
+
return this._defaultContext;
|
|
1438
|
+
this._defaultContext = this._wrapContext('default');
|
|
1439
|
+
return this._defaultContext;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Return the currently focused top-level page in `defaultContext`.
|
|
1443
|
+
*
|
|
1444
|
+
* This is the page that `browser.click()`, `browser.find()`,
|
|
1445
|
+
* `browser.evaluate()` and the other `Browser`-level DOM shortcuts
|
|
1446
|
+
* implicitly target. It **never** crosses into pages inside a
|
|
1447
|
+
* non-default `BrowserContext`.
|
|
1448
|
+
*
|
|
1449
|
+
* Use it to make multi-tab tests explicit:
|
|
1450
|
+
*
|
|
1451
|
+
* @example
|
|
1452
|
+
* const page = await browser.activePage();
|
|
1453
|
+
* await page.click('#submit'); // identical to browser.click('#submit')
|
|
1454
|
+
*
|
|
1455
|
+
* @example
|
|
1456
|
+
* const popup = await browser.openPage({ url: '/help' });
|
|
1457
|
+
* // browser.activePage() still returns the original page —
|
|
1458
|
+
* // openPage() does not steal focus.
|
|
1459
|
+
*/
|
|
1460
|
+
async activePage() {
|
|
1461
|
+
if (this.bidiSession?.isConnected()) {
|
|
1462
|
+
const conn = this.bidiSession.getConnection();
|
|
1463
|
+
const tree = await conn.send('browsingContext.getTree', {});
|
|
1464
|
+
const tops = (tree.contexts ?? []).filter((c) => !c.parent && c.userContext === 'default');
|
|
1465
|
+
if (tops.length === 0) {
|
|
1466
|
+
throw new Error('activePage(): no top-level page in the default context.');
|
|
1467
|
+
}
|
|
1468
|
+
// Match the Classic-driver focus to disambiguate when multiple top-level
|
|
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);
|
|
1473
|
+
}
|
|
1474
|
+
// Classic mode: no user-context concept; just wrap the current window.
|
|
1475
|
+
const handle = await this.driver.getCurrentWindowHandle();
|
|
1476
|
+
return new Page(this.driver, handle, this.getDefaultTimeout);
|
|
1477
|
+
}
|
|
1478
|
+
async click(selector, opts) {
|
|
1479
|
+
if (this._tracer?.isRunning)
|
|
1480
|
+
this._tracer.recordAction('click', undefined, selectorToString(selector));
|
|
213
1481
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
214
|
-
const el = await this.driver.wait(until.elementIsVisible(by), { timeout:
|
|
1482
|
+
const el = await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
|
|
215
1483
|
await el.click();
|
|
216
1484
|
}
|
|
217
1485
|
async fill(selector, text, opts) {
|
|
1486
|
+
if (this._tracer?.isRunning)
|
|
1487
|
+
this._tracer.recordAction('fill', [text], selectorToString(selector));
|
|
218
1488
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
219
1489
|
const el = await this.driver.wait(until.elementIsVisible(by), {
|
|
220
|
-
timeout: opts?.timeout ??
|
|
1490
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
221
1491
|
});
|
|
222
1492
|
await el.click();
|
|
223
1493
|
await el.clear();
|
|
224
1494
|
await el.sendKeys(text);
|
|
225
1495
|
}
|
|
226
1496
|
async clear(selector, opts) {
|
|
1497
|
+
if (this._tracer?.isRunning)
|
|
1498
|
+
this._tracer.recordAction('clear', undefined, selectorToString(selector));
|
|
227
1499
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
228
1500
|
const el = await this.driver.wait(until.elementIsVisible(by), {
|
|
229
|
-
timeout: opts?.timeout ??
|
|
1501
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
230
1502
|
});
|
|
231
1503
|
await el.clear();
|
|
232
1504
|
}
|
|
233
1505
|
async getValue(selector, opts) {
|
|
234
1506
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
235
1507
|
const el = await this.driver.wait(until.elementLocated(by), {
|
|
236
|
-
timeout: opts?.timeout ??
|
|
1508
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
237
1509
|
});
|
|
238
1510
|
const val = await el.getProperty('value');
|
|
239
1511
|
return String(val ?? '');
|
|
@@ -241,7 +1513,7 @@ export class Browser {
|
|
|
241
1513
|
async getAttribute(selector, name, opts) {
|
|
242
1514
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
243
1515
|
const el = await this.driver.wait(until.elementLocated(by), {
|
|
244
|
-
timeout: opts?.timeout ??
|
|
1516
|
+
timeout: opts?.timeout ?? this.defaults.timeout,
|
|
245
1517
|
});
|
|
246
1518
|
return await el.getAttribute(name);
|
|
247
1519
|
}
|
|
@@ -253,83 +1525,137 @@ export class Browser {
|
|
|
253
1525
|
await this.driver.performTouchPinch(center, scale, distance, durationMs);
|
|
254
1526
|
},
|
|
255
1527
|
};
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
1528
|
+
/**
|
|
1529
|
+
* Capture a screenshot of the active page (viewport by default), the
|
|
1530
|
+
* full scrollable document (`fullPage: true`), or an element matching
|
|
1531
|
+
* `selector`. Optionally write the PNG to `path`. Returns the raw PNG
|
|
1532
|
+
* buffer.
|
|
1533
|
+
*
|
|
1534
|
+
* Full-page capture uses BiDi `browsingContext.captureScreenshot` with
|
|
1535
|
+
* `origin: 'document'` and therefore requires `enableBiDi: true`
|
|
1536
|
+
* (the default). Element capture composes auto-waiting via the same
|
|
1537
|
+
* resolution as `find()`.
|
|
1538
|
+
*
|
|
1539
|
+
* @example
|
|
1540
|
+
* const buf = await browser.screenshot();
|
|
1541
|
+
* await browser.screenshot({ path: 'viewport.png' });
|
|
1542
|
+
* await browser.screenshot({ fullPage: true, path: 'full.png' });
|
|
1543
|
+
* await browser.screenshot({ selector: '#chart', path: 'chart.png' });
|
|
1544
|
+
*/
|
|
1545
|
+
async screenshot(opts) {
|
|
1546
|
+
if (opts?.fullPage && opts?.selector !== undefined) {
|
|
1547
|
+
throw new Error('screenshot: `fullPage` and `selector` are mutually exclusive. ' +
|
|
1548
|
+
'Use `selector` to capture an element, or `fullPage` to capture the whole document.');
|
|
1549
|
+
}
|
|
1550
|
+
let buf;
|
|
1551
|
+
if (opts?.selector !== undefined) {
|
|
1552
|
+
const by = typeof opts.selector === 'string' ? By.css(opts.selector) : opts.selector;
|
|
1553
|
+
const el = await this.driver.wait(until.elementIsVisible(by), {
|
|
1554
|
+
timeout: opts.timeout ?? this.defaults.timeout,
|
|
1555
|
+
});
|
|
261
1556
|
const b64 = await el.screenshotBase64();
|
|
262
|
-
|
|
1557
|
+
buf = Buffer.from(b64, 'base64');
|
|
263
1558
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
1559
|
+
else if (opts?.fullPage) {
|
|
1560
|
+
if (!this.bidiSession?.isConnected()) {
|
|
1561
|
+
throw new Error('screenshot({ fullPage: true }) requires BiDi (enableBiDi: true). ' +
|
|
1562
|
+
'Full-page screenshots use the W3C BiDi `browsingContext.captureScreenshot` ' +
|
|
1563
|
+
'command with `origin: "document"`, which has no Classic-WebDriver equivalent.');
|
|
1564
|
+
}
|
|
1565
|
+
const conn = this.bidiSession.getConnection();
|
|
1566
|
+
const page = await this.activePage();
|
|
1567
|
+
const result = await conn.send('browsingContext.captureScreenshot', {
|
|
1568
|
+
context: page.id(),
|
|
1569
|
+
origin: 'document',
|
|
1570
|
+
});
|
|
1571
|
+
buf = Buffer.from(result.data, 'base64');
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
const b64 = await this.driver.screenshotBase64();
|
|
1575
|
+
buf = Buffer.from(b64, 'base64');
|
|
1576
|
+
}
|
|
1577
|
+
if (opts?.path)
|
|
1578
|
+
await fs.writeFile(opts.path, buf);
|
|
271
1579
|
return buf;
|
|
272
1580
|
}
|
|
273
1581
|
expect(selector) {
|
|
274
1582
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
275
|
-
return expectSelector(this.driver, by);
|
|
1583
|
+
return expectSelector(this.driver, by, this.getDefaultTimeout);
|
|
276
1584
|
}
|
|
277
1585
|
find(selector) {
|
|
278
1586
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
279
|
-
return new ElementHandle(this.driver, by);
|
|
1587
|
+
return new ElementHandle(this.driver, by, this.getDefaultTimeout);
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Return a lazy, chainable `Locator` for the given selector.
|
|
1591
|
+
* Prefer `locator()` over `find()` when you need composition, filtering, or indexed access.
|
|
1592
|
+
*/
|
|
1593
|
+
locator(selector) {
|
|
1594
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1595
|
+
return new Locator(this.driver, by, this.getDefaultTimeout);
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Return snapshot `ElementHandle`s for all elements matching `selector` at call time.
|
|
1599
|
+
* Use `locator().all()` when you need filtering; use `findAll()` for the simple array case.
|
|
1600
|
+
*/
|
|
1601
|
+
async findAll(selector) {
|
|
1602
|
+
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
1603
|
+
const webElements = await this.driver.findElements(by);
|
|
1604
|
+
return webElements.map((we) => ElementHandle.fromWebElement(this.driver, we, this.getDefaultTimeout));
|
|
280
1605
|
}
|
|
281
1606
|
getByRole(role, opts) {
|
|
282
|
-
return new ElementHandle(this.driver, By.role(role, opts));
|
|
1607
|
+
return new ElementHandle(this.driver, By.role(role, opts), this.getDefaultTimeout);
|
|
283
1608
|
}
|
|
284
1609
|
getByText(text, opts) {
|
|
285
|
-
|
|
286
|
-
return new ElementHandle(this.driver, By.partialText(text));
|
|
287
|
-
}
|
|
288
|
-
return new ElementHandle(this.driver, By.text(text));
|
|
1610
|
+
return new ElementHandle(this.driver, By.text(text, opts), this.getDefaultTimeout);
|
|
289
1611
|
}
|
|
290
1612
|
getByLabel(text, opts) {
|
|
291
|
-
return new ElementHandle(this.driver, By.labelText(text, opts));
|
|
1613
|
+
return new ElementHandle(this.driver, By.labelText(text, opts), this.getDefaultTimeout);
|
|
292
1614
|
}
|
|
293
1615
|
getByPlaceholder(text, opts) {
|
|
294
|
-
return new ElementHandle(this.driver, By.placeholder(text, opts));
|
|
1616
|
+
return new ElementHandle(this.driver, By.placeholder(text, opts), this.getDefaultTimeout);
|
|
295
1617
|
}
|
|
296
1618
|
getByTestId(id) {
|
|
297
|
-
return new ElementHandle(this.driver, By.testId(id));
|
|
1619
|
+
return new ElementHandle(this.driver, By.testId(id), this.getDefaultTimeout);
|
|
298
1620
|
}
|
|
299
1621
|
// Keyboard controller and enum for nicer usage: browser.keyboard.press(Key.Enter)
|
|
300
1622
|
keyboard;
|
|
301
1623
|
// Mouse controller: browser.mouse.click(...), move, down, up, wheel, dragAndDrop
|
|
302
1624
|
mouse;
|
|
303
1625
|
static Key = Key;
|
|
304
|
-
async waitFor(check, options) {
|
|
305
|
-
const cond = async (d) => {
|
|
306
|
-
try {
|
|
307
|
-
const r = await (check.length > 0
|
|
308
|
-
? check(d)
|
|
309
|
-
: check());
|
|
310
|
-
return r;
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
return undefined;
|
|
314
|
-
}
|
|
315
|
-
};
|
|
316
|
-
return this.wait(cond, options);
|
|
317
|
-
}
|
|
318
1626
|
async waitForVisible(selector, opts) {
|
|
319
1627
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
320
|
-
await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ??
|
|
1628
|
+
await this.driver.wait(until.elementIsVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
|
|
321
1629
|
}
|
|
322
1630
|
async waitForHidden(selector, opts) {
|
|
323
1631
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
324
|
-
await this.driver.wait(until.elementIsNotVisible(by), { timeout: opts?.timeout ??
|
|
1632
|
+
await this.driver.wait(until.elementIsNotVisible(by), { timeout: opts?.timeout ?? this.defaults.timeout });
|
|
325
1633
|
}
|
|
326
1634
|
async waitForAttached(selector, opts) {
|
|
327
1635
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
328
|
-
await this.driver.wait(until.elementExists(by), { timeout: opts?.timeout ??
|
|
1636
|
+
await this.driver.wait(until.elementExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
|
|
329
1637
|
}
|
|
330
1638
|
async waitForDetached(selector, opts) {
|
|
331
1639
|
const by = typeof selector === 'string' ? By.css(selector) : selector;
|
|
332
|
-
await this.driver.wait(until.elementNotExists(by), { timeout: opts?.timeout ??
|
|
1640
|
+
await this.driver.wait(until.elementNotExists(by), { timeout: opts?.timeout ?? this.defaults.timeout });
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Wait for an element to reach the given state.
|
|
1644
|
+
*
|
|
1645
|
+
* Canonical wait API; the four `waitForVisible/Hidden/Attached/Detached`
|
|
1646
|
+
* methods are kept as one-line shortcuts.
|
|
1647
|
+
*
|
|
1648
|
+
* @example
|
|
1649
|
+
* await browser.waitFor('#spinner', { state: 'hidden' });
|
|
1650
|
+
* await browser.waitFor('#toast', { state: 'visible', timeout: 2000 });
|
|
1651
|
+
*/
|
|
1652
|
+
async waitFor(selector, opts) {
|
|
1653
|
+
switch (opts.state) {
|
|
1654
|
+
case 'visible': return this.waitForVisible(selector, opts);
|
|
1655
|
+
case 'hidden': return this.waitForHidden(selector, opts);
|
|
1656
|
+
case 'attached': return this.waitForAttached(selector, opts);
|
|
1657
|
+
case 'detached': return this.waitForDetached(selector, opts);
|
|
1658
|
+
}
|
|
333
1659
|
}
|
|
334
1660
|
actions() {
|
|
335
1661
|
return new ActionsBuilder(this.driver);
|