browser-commander 0.20.0 → 0.21.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 (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `browser-commander serve --stdio`: JSON-RPC 2.0 over stdin/stdout, one
3
+ * message per line (issue #104). Requests may be pipelined; each response
4
+ * carries its request id, and server notifications (`events.emit`) have none.
5
+ */
6
+ import { once } from 'node:events';
7
+ import readline from 'node:readline';
8
+
9
+ import { createDispatcher } from './dispatcher.js';
10
+ import { RPC_ERROR, toRpcError } from './rpc-error.js';
11
+
12
+ /** How long pending requests may run after stdin closes. */
13
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 5_000;
14
+
15
+ function errorResponse(id, code, message) {
16
+ return { jsonrpc: '2.0', id, error: { code, message } };
17
+ }
18
+
19
+ function isValidId(id) {
20
+ return id === null || typeof id === 'string' || typeof id === 'number';
21
+ }
22
+
23
+ function isRequest(message) {
24
+ return (
25
+ message !== null &&
26
+ typeof message === 'object' &&
27
+ !Array.isArray(message) &&
28
+ message.jsonrpc === '2.0' &&
29
+ typeof message.method === 'string' &&
30
+ (!('id' in message) || isValidId(message.id))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Build the handler for one JSON-RPC message or batch.
36
+ *
37
+ * @param {{dispatch: Function}} dispatcher
38
+ * @returns {function(unknown): Promise<Object|Array<Object>|null>} The response, or null for notifications
39
+ */
40
+ export function createMessageHandler(dispatcher) {
41
+ async function handleOne(message) {
42
+ if (!isRequest(message)) {
43
+ const id = isValidId(message?.id) ? message.id : null;
44
+ return errorResponse(id, RPC_ERROR.INVALID_REQUEST, 'Invalid request');
45
+ }
46
+ const notification = !('id' in message);
47
+ try {
48
+ const result = await dispatcher.dispatch(
49
+ message.method,
50
+ message.params ?? {}
51
+ );
52
+ return notification
53
+ ? null
54
+ : { jsonrpc: '2.0', id: message.id, result: result ?? null };
55
+ } catch (error) {
56
+ return notification
57
+ ? null
58
+ : { jsonrpc: '2.0', id: message.id, error: toRpcError(error) };
59
+ }
60
+ }
61
+
62
+ return async function handleMessage(message) {
63
+ if (!Array.isArray(message)) {
64
+ return await handleOne(message);
65
+ }
66
+ if (message.length === 0) {
67
+ return errorResponse(null, RPC_ERROR.INVALID_REQUEST, 'Empty batch');
68
+ }
69
+ const responses = (await Promise.all(message.map(handleOne))).filter(
70
+ Boolean
71
+ );
72
+ return responses.length > 0 ? responses : null;
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Parse one line and answer it.
78
+ *
79
+ * @param {string} line
80
+ * @param {Function} handleMessage - From {@link createMessageHandler}
81
+ * @returns {Promise<Object|Object[]|null>}
82
+ */
83
+ export async function handleLine(line, handleMessage) {
84
+ if (line.trim() === '') {
85
+ return null;
86
+ }
87
+ let message;
88
+ try {
89
+ message = JSON.parse(line);
90
+ } catch (error) {
91
+ return errorResponse(
92
+ null,
93
+ RPC_ERROR.PARSE_ERROR,
94
+ `Parse error: ${error.message}`
95
+ );
96
+ }
97
+ return await handleMessage(message);
98
+ }
99
+
100
+ function delay(ms) {
101
+ return new Promise((resolve) => {
102
+ setTimeout(resolve, ms).unref?.();
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Serve JSON-RPC until `input` ends, then close every session.
108
+ *
109
+ * @param {Object} options
110
+ * @param {Object} options.input - Usually process.stdin
111
+ * @param {function(string): void} options.write - Writes one line to stdout
112
+ * @param {Object} [options.dependencies] - Dispatcher dependencies (tests)
113
+ * @param {number} [options.drainTimeoutMs] - Grace for in-flight requests
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function serveStdio({
117
+ input,
118
+ write,
119
+ dependencies,
120
+ drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
121
+ }) {
122
+ const send = (message) => write(`${JSON.stringify(message)}\n`);
123
+ const dispatcher = createDispatcher({
124
+ dependencies,
125
+ notify: (method, params) => send({ jsonrpc: '2.0', method, params }),
126
+ });
127
+ const handleMessage = createMessageHandler(dispatcher);
128
+ const pending = new Set();
129
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
130
+ lines.on('line', (line) => {
131
+ const task = handleLine(line, handleMessage)
132
+ .then((response) => {
133
+ if (response) {
134
+ send(response);
135
+ }
136
+ })
137
+ .catch(() => {});
138
+ pending.add(task);
139
+ task.finally(() => pending.delete(task));
140
+ });
141
+ await once(lines, 'close');
142
+ await Promise.race([Promise.allSettled([...pending]), delay(drainTimeoutMs)]);
143
+ await dispatcher.close();
144
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Browser sessions owned by one `serve --stdio` or `run` dispatcher
3
+ * (issue #104). A session is a launched or attached browser with the engine
4
+ * objects behind it: `browser`, `context` and the `page` commands act on.
5
+ */
6
+ import { invalidParams } from './rpc-error.js';
7
+
8
+ /** `--browser` names mapped to installed-browser channels. */
9
+ export const BROWSER_CHANNELS = Object.freeze({
10
+ chrome: 'chrome',
11
+ edge: 'msedge',
12
+ msedge: 'msedge',
13
+ brave: 'brave',
14
+ chromium: 'chromium',
15
+ });
16
+
17
+ const ENGINES = Object.freeze(['playwright', 'puppeteer']);
18
+
19
+ /**
20
+ * Launch options that are forwarded to `launchBrowser()` as they are, so the
21
+ * bridge can express everything the old Rust bridge's `launch` operation did.
22
+ */
23
+ const LAUNCH_PASSTHROUGH = Object.freeze([
24
+ 'args',
25
+ 'extraArgs',
26
+ 'ignoreDefaultArgs',
27
+ 'slowMo',
28
+ 'colorScheme',
29
+ 'channel',
30
+ 'storageState',
31
+ 'fingerprint',
32
+ 'remoteDebuggingPort',
33
+ 'automationParity',
34
+ 'env',
35
+ 'startupTimeout',
36
+ 'attach',
37
+ 'preferences',
38
+ 'localState',
39
+ 'defaultBrowserCheck',
40
+ 'firstRun',
41
+ ]);
42
+
43
+ /** Validate an engine name, defaulting to Playwright. */
44
+ export function resolveEngine(engine = 'playwright') {
45
+ if (!ENGINES.includes(engine)) {
46
+ throw invalidParams(
47
+ `Unsupported engine "${engine}"; expected ${ENGINES.join(' or ')}`
48
+ );
49
+ }
50
+ return engine;
51
+ }
52
+
53
+ function resolveChannel(browser) {
54
+ if (browser === undefined) {
55
+ return undefined;
56
+ }
57
+ const channel = BROWSER_CHANNELS[browser];
58
+ if (!channel) {
59
+ throw invalidParams(
60
+ `Unsupported browser "${browser}"; expected chrome, edge, brave or chromium`
61
+ );
62
+ }
63
+ return channel;
64
+ }
65
+
66
+ function assertStringList(value, name) {
67
+ if (
68
+ value !== undefined &&
69
+ (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))
70
+ ) {
71
+ throw invalidParams(`${name} must be an array of strings`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Translate `session.launch` params into `launchBrowser()` options.
77
+ *
78
+ * @param {Object} params - JSON-RPC params
79
+ * @returns {Object} launchBrowser options
80
+ */
81
+ export function buildLaunchOptions(params = {}) {
82
+ assertStringList(params.restrictions, 'restrictions');
83
+ assertStringList(params.args, 'args');
84
+ const options = {
85
+ engine: resolveEngine(params.engine),
86
+ headless: params.headless === true,
87
+ };
88
+ const channel = resolveChannel(params.browser);
89
+ const defined = {
90
+ launch: params.launch,
91
+ executablePath: params.executablePath,
92
+ userDataDir: params.userDataDir,
93
+ restrictions: params.restrictions,
94
+ channel,
95
+ };
96
+ for (const name of LAUNCH_PASSTHROUGH) {
97
+ defined[name] ??= params[name];
98
+ }
99
+ for (const [key, value] of Object.entries(defined)) {
100
+ if (value !== undefined) {
101
+ options[key] = value;
102
+ }
103
+ }
104
+ return options;
105
+ }
106
+
107
+ /**
108
+ * Build a session from a `launchBrowser()` result.
109
+ *
110
+ * Playwright's real launch returns the default context as `browser` and the
111
+ * CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
112
+ * persistent context whose `browser()` is null. Puppeteer returns a Browser.
113
+ */
114
+ export function sessionFromLaunch(engine, launched) {
115
+ const playwright = engine === 'playwright';
116
+ const handle = launched.browser;
117
+ return {
118
+ engine,
119
+ browser: playwright
120
+ ? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
121
+ : handle,
122
+ context: playwright ? handle : (handle?.defaultBrowserContext?.() ?? null),
123
+ page: launched.page,
124
+ args: launched.args ?? [],
125
+ close: () => launched.close(),
126
+ connected: false,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Build a session from a `connectBrowser()` result. Closing it only
132
+ * disconnects: the browser keeps running for the next command.
133
+ */
134
+ export function sessionFromConnect(engine, connected) {
135
+ const { browser, page } = connected;
136
+ const playwright = engine === 'playwright';
137
+ return {
138
+ engine,
139
+ browser,
140
+ context: playwright
141
+ ? (page?.context?.() ?? null)
142
+ : (browser?.defaultBrowserContext?.() ?? null),
143
+ page,
144
+ // Playwright's close() on a connectOverCDP browser disconnects;
145
+ // Puppeteer's close() would quit the browser, disconnect() does not.
146
+ close: async () => {
147
+ await connected.downloads?.dispose?.();
148
+ await (playwright ? browser.close() : browser.disconnect());
149
+ },
150
+ connected: true,
151
+ };
152
+ }
153
+
154
+ /** Sessions with deterministic ids `s1`, `s2`, … */
155
+ export class SessionTable {
156
+ constructor() {
157
+ this.nextId = 1;
158
+ this.sessions = new Map();
159
+ this.latest = null;
160
+ }
161
+
162
+ /** Add a session and return its id. */
163
+ add(session) {
164
+ const id = `s${this.nextId++}`;
165
+ this.sessions.set(id, session);
166
+ this.latest = id;
167
+ return id;
168
+ }
169
+
170
+ /** The session behind an id (or the latest one when omitted). */
171
+ get(id) {
172
+ const resolved = id ?? this.latest;
173
+ const session = resolved ? this.sessions.get(resolved) : undefined;
174
+ if (!session) {
175
+ throw invalidParams(
176
+ id ? `Unknown session: ${id}` : 'No session; call session.launch first'
177
+ );
178
+ }
179
+ return session;
180
+ }
181
+
182
+ /** Close one session and forget it. */
183
+ async close(id) {
184
+ const resolved = id ?? this.latest;
185
+ const session = this.get(resolved);
186
+ this.sessions.delete(resolved);
187
+ if (this.latest === resolved) {
188
+ this.latest = [...this.sessions.keys()].at(-1) ?? null;
189
+ }
190
+ await session.trace?.stop?.().catch(() => {});
191
+ await session.close();
192
+ }
193
+
194
+ /** Close every session, ignoring individual failures. */
195
+ async closeAll() {
196
+ const ids = [...this.sessions.keys()];
197
+ await Promise.all(ids.map((id) => this.close(id).catch(() => {})));
198
+ }
199
+ }
@@ -1,5 +1,11 @@
1
1
  /**
2
- * Common Chrome arguments used across both Playwright and Puppeteer
2
+ * The switches Browser Commander added to every launch before issue #103.
3
+ *
4
+ * They are no longer added by default: a launch starts Chrome with only
5
+ * `--user-data-dir` and `--remote-debugging-port`. The list is kept for
6
+ * callers that relied on it and equals the `legacy-defaults` restriction
7
+ * preset, so `launchBrowser({ restrictions: ['legacy-defaults'] })` restores
8
+ * the old command line.
3
9
  */
4
10
  export const CHROME_ARGS = [
5
11
  '--disable-session-crashed-bubble',
@@ -606,9 +606,115 @@ export function createEngineAdapter(page, engine) {
606
606
  return new PlaywrightAdapter(page);
607
607
  } else if (engine === 'puppeteer') {
608
608
  return new PuppeteerAdapter(page);
609
+ } else if (engine === 'selenium') {
610
+ return new SeleniumAdapter(page);
609
611
  } else {
610
612
  throw new Error(
611
- `Unsupported engine: ${engine}. Expected 'playwright' or 'puppeteer'`
613
+ `Unsupported engine: ${engine}. Expected 'playwright', 'puppeteer' or 'selenium'`
612
614
  );
613
615
  }
614
616
  }
617
+
618
+ /**
619
+ * Selenium (WebDriver) adapter implementation (issue #104).
620
+ *
621
+ * The page is a `WebDriverPage` facade, which already speaks the Puppeteer
622
+ * page API, so everything not listed here is the Puppeteer implementation.
623
+ * The overrides use WebDriver's native element commands where they exist:
624
+ * Element Click, Element Send Keys and Element Clear run the browser's own
625
+ * interactability checks and produce trusted input events.
626
+ */
627
+ export class SeleniumAdapter extends PuppeteerAdapter {
628
+ getEngineName() {
629
+ return 'selenium';
630
+ }
631
+
632
+ async click(locatorOrElement, options = {}) {
633
+ if (options.force) {
634
+ // Element Click refuses an obscured element; force means "click where
635
+ // it is anyway", which is a pointer action at the element's centre.
636
+ await this.page.evaluate(
637
+ (el) => el.scrollIntoView({ block: 'center', inline: 'center' }),
638
+ locatorOrElement
639
+ );
640
+ await this.page.driver
641
+ .actions({ async: true })
642
+ .move({ origin: locatorOrElement })
643
+ .click()
644
+ .perform();
645
+ } else {
646
+ await locatorOrElement.click();
647
+ }
648
+ // A click may navigate; page.url() should say where it went.
649
+ await this.page.syncUrl();
650
+ }
651
+
652
+ async type(locatorOrElement, text) {
653
+ await locatorOrElement.sendKeys(text);
654
+ }
655
+
656
+ async fill(locatorOrElement, text) {
657
+ await locatorOrElement.clear();
658
+ await locatorOrElement.sendKeys(text);
659
+ }
660
+
661
+ async focus(locatorOrElement) {
662
+ await this.page.evaluate((el) => el.focus(), locatorOrElement);
663
+ }
664
+
665
+ // ============================================================================
666
+ // WebDriver and BiDi access
667
+ // ============================================================================
668
+
669
+ /** @returns {Object} The selenium-webdriver WebDriver */
670
+ getDriver() {
671
+ return this.page.driver;
672
+ }
673
+
674
+ /** @returns {Promise<Object|null>} The BiDi connection, null for classic */
675
+ getBidi() {
676
+ return this.page.bidi();
677
+ }
678
+
679
+ /**
680
+ * Subscribe to a raw BiDi event, e.g. 'log.entryAdded'.
681
+ * @returns {Promise<{unsubscribe: Function}>}
682
+ */
683
+ onBidiEvent(method, handler) {
684
+ return this.page.subscribeBidi(method, handler);
685
+ }
686
+
687
+ /**
688
+ * Receive console messages (BiDi `log.entryAdded`).
689
+ * @param {Function} handler - Receives a Puppeteer-shaped ConsoleMessage
690
+ * @returns {Promise<Function>} Resolves once subscribed, to an unsubscribe
691
+ */
692
+ onConsoleMessage(handler) {
693
+ return this._listen('console', handler);
694
+ }
695
+
696
+ /**
697
+ * Receive top-level navigations (BiDi browsingContext events).
698
+ * @param {Function} handler - Receives the new URL
699
+ * @returns {Promise<Function>} Resolves once subscribed, to an unsubscribe
700
+ */
701
+ onNavigation(handler) {
702
+ return this._listen('framenavigated', (frame) => handler(frame.url()));
703
+ }
704
+
705
+ async _listen(eventName, handler) {
706
+ await this.page.requireBidi(`page "${eventName}" events`);
707
+ this.page.on(eventName, handler);
708
+ await this.page.eventsReady();
709
+ return () => this.page.off(eventName, handler);
710
+ }
711
+
712
+ /**
713
+ * Navigate with BiDi `browsingContext.navigate`.
714
+ * @param {string} url
715
+ * @param {Object} [options] - {wait: 'none'|'interactive'|'complete'}
716
+ */
717
+ navigate(url, options = {}) {
718
+ return this.page.navigateBidi(url, options);
719
+ }
720
+ }
@@ -2,10 +2,25 @@ import { isVerboseEnabled } from './logger.js';
2
2
 
3
3
  /**
4
4
  * Detect which browser automation engine is being used
5
- * @param {Object} pageOrContext - Page or context object from Playwright or Puppeteer
6
- * @returns {string} - 'playwright' or 'puppeteer'
5
+ * @param {Object} pageOrContext - Page or context object from Playwright or
6
+ * Puppeteer, a selenium-webdriver WebDriver, or a WebDriverPage over one
7
+ * @returns {string} - 'playwright', 'puppeteer' or 'selenium'
7
8
  */
8
9
  export function detectEngine(pageOrContext) {
10
+ // Selenium first: the WebDriverPage facade has $eval/$$eval like Puppeteer,
11
+ // and a bare WebDriver has none of the page methods the checks below use.
12
+ if (
13
+ pageOrContext.isWebDriverPage === true ||
14
+ (typeof pageOrContext.findElements === 'function' &&
15
+ typeof pageOrContext.executeScript === 'function' &&
16
+ typeof pageOrContext.getCurrentUrl === 'function')
17
+ ) {
18
+ if (isVerboseEnabled()) {
19
+ console.log('🔍 [ENGINE DETECTION] Detected: selenium');
20
+ }
21
+ return 'selenium';
22
+ }
23
+
9
24
  const hasEval = !!pageOrContext.$eval;
10
25
  const hasEvalAll = !!pageOrContext.$$eval;
11
26
  const locatorType = typeof pageOrContext.locator;
@@ -44,6 +59,6 @@ export function detectEngine(pageOrContext) {
44
59
  console.log('🔍 [ENGINE DETECTION] Could not detect engine!');
45
60
  }
46
61
  throw new Error(
47
- 'Unknown browser automation engine. Expected Playwright or Puppeteer page object.'
62
+ 'Unknown browser automation engine. Expected a Playwright or Puppeteer page, or a selenium-webdriver WebDriver.'
48
63
  );
49
64
  }
@@ -27,6 +27,10 @@ export function isNavigationError(error) {
27
27
  'Frame was detached',
28
28
  'context was destroyed',
29
29
  'Page crashed',
30
+ // WebDriver (selenium engine): an element or window from the previous
31
+ // document after a navigation.
32
+ 'stale element reference',
33
+ 'no such window',
30
34
  ];
31
35
 
32
36
  return navigationErrorPatterns.some((pattern) =>