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.
Files changed (186) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +157 -45
  3. package/bin/craftdriver.mjs +26 -0
  4. package/dist/cli/client.d.ts +17 -0
  5. package/dist/cli/client.d.ts.map +1 -0
  6. package/dist/cli/client.js +88 -0
  7. package/dist/cli/client.js.map +1 -0
  8. package/dist/cli/daemon.d.ts +15 -0
  9. package/dist/cli/daemon.d.ts.map +1 -0
  10. package/dist/cli/daemon.js +145 -0
  11. package/dist/cli/daemon.js.map +1 -0
  12. package/dist/cli/defaults.d.ts +10 -0
  13. package/dist/cli/defaults.d.ts.map +1 -0
  14. package/dist/cli/defaults.js +42 -0
  15. package/dist/cli/defaults.js.map +1 -0
  16. package/dist/cli/dispatcher.d.ts +28 -0
  17. package/dist/cli/dispatcher.d.ts.map +1 -0
  18. package/dist/cli/dispatcher.js +319 -0
  19. package/dist/cli/dispatcher.js.map +1 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.d.ts.map +1 -0
  22. package/dist/cli/index.js +404 -0
  23. package/dist/cli/index.js.map +1 -0
  24. package/dist/cli/init.d.ts +24 -0
  25. package/dist/cli/init.d.ts.map +1 -0
  26. package/dist/cli/init.js +192 -0
  27. package/dist/cli/init.js.map +1 -0
  28. package/dist/cli/mcp/artifacts.d.ts +43 -0
  29. package/dist/cli/mcp/artifacts.d.ts.map +1 -0
  30. package/dist/cli/mcp/artifacts.js +104 -0
  31. package/dist/cli/mcp/artifacts.js.map +1 -0
  32. package/dist/cli/mcp/server.d.ts +34 -0
  33. package/dist/cli/mcp/server.d.ts.map +1 -0
  34. package/dist/cli/mcp/server.js +214 -0
  35. package/dist/cli/mcp/server.js.map +1 -0
  36. package/dist/cli/mcp/tools.d.ts +34 -0
  37. package/dist/cli/mcp/tools.d.ts.map +1 -0
  38. package/dist/cli/mcp/tools.js +236 -0
  39. package/dist/cli/mcp/tools.js.map +1 -0
  40. package/dist/cli/parseArgs.d.ts +37 -0
  41. package/dist/cli/parseArgs.d.ts.map +1 -0
  42. package/dist/cli/parseArgs.js +215 -0
  43. package/dist/cli/parseArgs.js.map +1 -0
  44. package/dist/cli/protocol.d.ts +34 -0
  45. package/dist/cli/protocol.d.ts.map +1 -0
  46. package/dist/cli/protocol.js +2 -0
  47. package/dist/cli/protocol.js.map +1 -0
  48. package/dist/cli/selector.d.ts +29 -0
  49. package/dist/cli/selector.d.ts.map +1 -0
  50. package/dist/cli/selector.js +100 -0
  51. package/dist/cli/selector.js.map +1 -0
  52. package/dist/cli/snapshot.d.ts +48 -0
  53. package/dist/cli/snapshot.d.ts.map +1 -0
  54. package/dist/cli/snapshot.js +179 -0
  55. package/dist/cli/snapshot.js.map +1 -0
  56. package/dist/index.d.ts +14 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +15 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/lib/a11y.d.ts +81 -0
  61. package/dist/lib/a11y.d.ts.map +1 -0
  62. package/dist/lib/a11y.js +171 -0
  63. package/dist/lib/a11y.js.map +1 -0
  64. package/dist/lib/bidi/index.d.ts +26 -1
  65. package/dist/lib/bidi/index.d.ts.map +1 -1
  66. package/dist/lib/bidi/index.js +49 -0
  67. package/dist/lib/bidi/index.js.map +1 -1
  68. package/dist/lib/bidi/logs.d.ts +2 -2
  69. package/dist/lib/bidi/logs.d.ts.map +1 -1
  70. package/dist/lib/bidi/logs.js +2 -2
  71. package/dist/lib/bidi/logs.js.map +1 -1
  72. package/dist/lib/bidi/network.d.ts +74 -1
  73. package/dist/lib/bidi/network.d.ts.map +1 -1
  74. package/dist/lib/bidi/network.js +222 -4
  75. package/dist/lib/bidi/network.js.map +1 -1
  76. package/dist/lib/bidi/storage.d.ts +5 -3
  77. package/dist/lib/bidi/storage.d.ts.map +1 -1
  78. package/dist/lib/bidi/storage.js +14 -7
  79. package/dist/lib/bidi/storage.js.map +1 -1
  80. package/dist/lib/bidi/types.d.ts +27 -6
  81. package/dist/lib/bidi/types.d.ts.map +1 -1
  82. package/dist/lib/browser.d.ts +637 -16
  83. package/dist/lib/browser.d.ts.map +1 -1
  84. package/dist/lib/browser.js +1428 -102
  85. package/dist/lib/browser.js.map +1 -1
  86. package/dist/lib/browserContext.d.ts +486 -0
  87. package/dist/lib/browserContext.d.ts.map +1 -0
  88. package/dist/lib/browserContext.js +937 -0
  89. package/dist/lib/browserContext.js.map +1 -0
  90. package/dist/lib/builder.d.ts +3 -0
  91. package/dist/lib/builder.d.ts.map +1 -1
  92. package/dist/lib/builder.js +31 -4
  93. package/dist/lib/builder.js.map +1 -1
  94. package/dist/lib/by.d.ts +18 -2
  95. package/dist/lib/by.d.ts.map +1 -1
  96. package/dist/lib/by.js +20 -2
  97. package/dist/lib/by.js.map +1 -1
  98. package/dist/lib/chrome.d.ts +13 -1
  99. package/dist/lib/chrome.d.ts.map +1 -1
  100. package/dist/lib/chrome.js +13 -17
  101. package/dist/lib/chrome.js.map +1 -1
  102. package/dist/lib/clock.d.ts +115 -0
  103. package/dist/lib/clock.d.ts.map +1 -0
  104. package/dist/lib/clock.js +407 -0
  105. package/dist/lib/clock.js.map +1 -0
  106. package/dist/lib/driver.d.ts +43 -0
  107. package/dist/lib/driver.d.ts.map +1 -1
  108. package/dist/lib/driver.js +120 -0
  109. package/dist/lib/driver.js.map +1 -1
  110. package/dist/lib/driverManager.d.ts +25 -0
  111. package/dist/lib/driverManager.d.ts.map +1 -0
  112. package/dist/lib/driverManager.js +399 -0
  113. package/dist/lib/driverManager.js.map +1 -0
  114. package/dist/lib/elementHandle.d.ts +67 -3
  115. package/dist/lib/elementHandle.d.ts.map +1 -1
  116. package/dist/lib/elementHandle.js +209 -67
  117. package/dist/lib/elementHandle.js.map +1 -1
  118. package/dist/lib/errors.d.ts +71 -0
  119. package/dist/lib/errors.d.ts.map +1 -0
  120. package/dist/lib/errors.js +73 -0
  121. package/dist/lib/errors.js.map +1 -0
  122. package/dist/lib/expect.d.ts +6 -1
  123. package/dist/lib/expect.d.ts.map +1 -1
  124. package/dist/lib/expect.js +55 -37
  125. package/dist/lib/expect.js.map +1 -1
  126. package/dist/lib/firefox.d.ts +18 -0
  127. package/dist/lib/firefox.d.ts.map +1 -0
  128. package/dist/lib/firefox.js +33 -0
  129. package/dist/lib/firefox.js.map +1 -0
  130. package/dist/lib/frame.d.ts +57 -0
  131. package/dist/lib/frame.d.ts.map +1 -0
  132. package/dist/lib/frame.js +277 -0
  133. package/dist/lib/frame.js.map +1 -0
  134. package/dist/lib/keyboard.d.ts +1 -1
  135. package/dist/lib/keyboard.d.ts.map +1 -1
  136. package/dist/lib/keyboard.js +1 -1
  137. package/dist/lib/keyboard.js.map +1 -1
  138. package/dist/lib/locator.d.ts +71 -2
  139. package/dist/lib/locator.d.ts.map +1 -1
  140. package/dist/lib/locator.js +289 -31
  141. package/dist/lib/locator.js.map +1 -1
  142. package/dist/lib/mouse.d.ts +1 -1
  143. package/dist/lib/mouse.d.ts.map +1 -1
  144. package/dist/lib/mouse.js +1 -1
  145. package/dist/lib/mouse.js.map +1 -1
  146. package/dist/lib/page.d.ts +107 -0
  147. package/dist/lib/page.d.ts.map +1 -0
  148. package/dist/lib/page.js +388 -0
  149. package/dist/lib/page.js.map +1 -0
  150. package/dist/lib/tracing.d.ts +128 -0
  151. package/dist/lib/tracing.d.ts.map +1 -0
  152. package/dist/lib/tracing.js +272 -0
  153. package/dist/lib/tracing.js.map +1 -0
  154. package/dist/lib/wait.d.ts.map +1 -1
  155. package/dist/lib/wait.js +14 -1
  156. package/dist/lib/wait.js.map +1 -1
  157. package/dist/lib/webelement.d.ts +2 -0
  158. package/dist/lib/webelement.d.ts.map +1 -1
  159. package/dist/lib/webelement.js +18 -0
  160. package/dist/lib/webelement.js.map +1 -1
  161. package/docs/accessibility.md +177 -0
  162. package/docs/api-reference.md +71 -0
  163. package/docs/assertions.md +182 -0
  164. package/docs/bidi-features.md +462 -0
  165. package/docs/browser-api.md +673 -0
  166. package/docs/browser-context.md +522 -0
  167. package/docs/cli.md +233 -0
  168. package/docs/clock.md +245 -0
  169. package/docs/dialogs.md +143 -0
  170. package/docs/driver-configuration.md +64 -0
  171. package/docs/element-api.md +219 -0
  172. package/docs/emulation.md +155 -0
  173. package/docs/error-codes.md +67 -0
  174. package/docs/getting-started.md +168 -0
  175. package/docs/keyboard-mouse.md +250 -0
  176. package/docs/mcp.md +250 -0
  177. package/docs/mobile-emulation.md +194 -0
  178. package/docs/screenshots.md +146 -0
  179. package/docs/selectors.md +382 -0
  180. package/docs/session-management.md +229 -0
  181. package/docs/tracing.md +282 -0
  182. package/package.json +26 -8
  183. package/skills/craftdriver/SKILL.md +84 -0
  184. package/skills/craftdriver/cheatsheet.md +194 -0
  185. package/skills/craftdriver/cli.md +104 -0
  186. package/skills/craftdriver/patterns.md +139 -0
@@ -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 { KeyboardController } from './keyboard.js';
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 { MouseController } from './mouse.js';
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 KeyboardController(this.driver);
57
- this.mouse = new MouseController(this.driver);
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
- let chromeService;
62
- if (options.chromeService) {
63
- chromeService = options.chromeService;
64
- }
65
- else {
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
- // Base capabilities
72
- const chromeOptions = {
73
- args: isHeadless ? ['--headless=new'] : [],
74
- };
75
- // Add mobile emulation if specified
76
- if (options.mobileEmulation) {
77
- const emulation = typeof options.mobileEmulation === 'string'
78
- ? devices[options.mobileEmulation]
79
- : options.mobileEmulation;
80
- if ('deviceName' in emulation && emulation.deviceName) {
81
- // Use Chrome's built-in device name
82
- chromeOptions.mobileEmulation = { deviceName: emulation.deviceName };
83
- }
84
- else {
85
- // Build custom mobile emulation config
86
- const mobileConfig = {};
87
- if (emulation.deviceMetrics) {
88
- mobileConfig.deviceMetrics = {
89
- width: emulation.deviceMetrics.width,
90
- height: emulation.deviceMetrics.height,
91
- pixelRatio: emulation.deviceMetrics.pixelRatio,
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
- if (emulation.userAgent) {
97
- mobileConfig.userAgent = emulation.userAgent;
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
- this.bidiSession = new BiDiSession(this.driver);
130
- try {
131
- await this.bidiSession.connect(wsUrl);
132
- }
133
- catch (err) {
134
- // BiDi connection failed, continue with Classic-only mode
135
- console.warn('BiDi connection failed, using Classic WebDriver only:', err);
136
- this.bidiSession = undefined;
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. Launch with enableBiDi: true');
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. Launch with enableBiDi: true');
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
- return this.driver.navigateTo(url);
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
- await this.driver.quit();
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
- async click(selector) {
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: 5000 });
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 ?? 5000,
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 ?? 5000,
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 ?? 5000,
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 ?? 5000,
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
- // Capture a screenshot. If selectorOrBy provided, capture element; else full page. Returns raw buffer.
257
- async screenshot(selectorOrBy) {
258
- if (selectorOrBy) {
259
- const by = typeof selectorOrBy === 'string' ? By.css(selectorOrBy) : selectorOrBy;
260
- const el = await this.driver.wait(until.elementIsVisible(by), { timeout: 5000 });
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
- return Buffer.from(b64, 'base64');
1557
+ buf = Buffer.from(b64, 'base64');
263
1558
  }
264
- const b64 = await this.driver.screenshotBase64();
265
- return Buffer.from(b64, 'base64');
266
- }
267
- // Convenience: capture (optionally element) screenshot and save to file, returning buffer.
268
- async saveScreenshot(path, selectorOrBy) {
269
- const buf = await this.screenshot(selectorOrBy);
270
- await fs.writeFile(path, buf);
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
- if (opts?.exact === false) {
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 ?? 5000 });
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 ?? 5000 });
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 ?? 5000 });
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 ?? 5000 });
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);