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,347 @@
1
+ /**
2
+ * Translations between the Puppeteer-shaped page API the rest of Browser
3
+ * Commander speaks and the WebDriver wire protocol (issue #104).
4
+ *
5
+ * Everything here is a pure function of its arguments, so the facade in
6
+ * `webdriver-page.js` stays about sequencing commands and these stay testable
7
+ * without a browser.
8
+ */
9
+
10
+ /**
11
+ * W3C WebDriver key codes for the named keys the Playwright and Puppeteer
12
+ * keyboards accept (WebDriver specification, "Keyboard actions", table of
13
+ * normalized key values). A single character is sent as itself.
14
+ */
15
+ export const WEBDRIVER_KEYS = Object.freeze({
16
+ Cancel: '\uE001',
17
+ Help: '\uE002',
18
+ Backspace: '\uE003',
19
+ Tab: '\uE004',
20
+ Clear: '\uE005',
21
+ Enter: '\uE007',
22
+ Shift: '\uE008',
23
+ ShiftLeft: '\uE008',
24
+ Control: '\uE009',
25
+ ControlLeft: '\uE009',
26
+ Alt: '\uE00A',
27
+ AltLeft: '\uE00A',
28
+ Pause: '\uE00B',
29
+ Escape: '\uE00C',
30
+ Space: '\uE00D',
31
+ PageUp: '\uE00E',
32
+ PageDown: '\uE00F',
33
+ End: '\uE010',
34
+ Home: '\uE011',
35
+ ArrowLeft: '\uE012',
36
+ ArrowUp: '\uE013',
37
+ ArrowRight: '\uE014',
38
+ ArrowDown: '\uE015',
39
+ Insert: '\uE016',
40
+ Delete: '\uE017',
41
+ F1: '\uE031',
42
+ F2: '\uE032',
43
+ F3: '\uE033',
44
+ F4: '\uE034',
45
+ F5: '\uE035',
46
+ F6: '\uE036',
47
+ F7: '\uE037',
48
+ F8: '\uE038',
49
+ F9: '\uE039',
50
+ F10: '\uE03A',
51
+ F11: '\uE03B',
52
+ F12: '\uE03C',
53
+ Meta: '\uE03D',
54
+ MetaLeft: '\uE03D',
55
+ Command: '\uE03D',
56
+ });
57
+
58
+ /**
59
+ * Translate one key name into the value a WebDriver key action carries.
60
+ *
61
+ * @param {string} key - A single character or a name such as 'Enter'
62
+ * @returns {string} The WebDriver key value
63
+ */
64
+ export function toWebDriverKey(key) {
65
+ if (typeof key !== 'string' || key.length === 0) {
66
+ throw new TypeError(`A key must be a non-empty string, got ${key}`);
67
+ }
68
+ if ([...key].length === 1) {
69
+ return key;
70
+ }
71
+ const value = WEBDRIVER_KEYS[key];
72
+ if (value === undefined) {
73
+ throw new Error(
74
+ `Unknown key "${key}" for WebDriver. Use a single character or one of: ${Object.keys(WEBDRIVER_KEYS).join(', ')}`
75
+ );
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * Split a Playwright-style chord such as 'Control+A' into the keys held
82
+ * together. A lone '+' is the plus key, not a separator.
83
+ *
84
+ * @param {string} key
85
+ * @returns {string[]} WebDriver key values, modifiers first
86
+ */
87
+ export function toWebDriverChord(key) {
88
+ const parts =
89
+ key.length > 1 && key.includes('+')
90
+ ? key.split('+').map((part, index, all) =>
91
+ // 'Control++' holds Control and presses '+'.
92
+ part === '' && index === all.length - 1 ? '+' : part
93
+ )
94
+ : [key];
95
+ return parts.filter((part) => part !== '').map(toWebDriverKey);
96
+ }
97
+
98
+ /** Paper sizes Puppeteer and Playwright accept, in inches (width, height). */
99
+ const PAPER_FORMATS_IN = Object.freeze({
100
+ letter: [8.5, 11],
101
+ legal: [8.5, 14],
102
+ tabloid: [11, 17],
103
+ ledger: [17, 11],
104
+ a0: [33.1102, 46.811],
105
+ a1: [23.3858, 33.1102],
106
+ a2: [16.5354, 23.3858],
107
+ a3: [11.6929, 16.5354],
108
+ a4: [8.2677, 11.6929],
109
+ a5: [5.8268, 8.2677],
110
+ a6: [4.1339, 5.8268],
111
+ });
112
+
113
+ const CM_PER_UNIT = Object.freeze({
114
+ px: 2.54 / 96,
115
+ in: 2.54,
116
+ cm: 1,
117
+ mm: 0.1,
118
+ });
119
+
120
+ /**
121
+ * Convert a Puppeteer length (a number of CSS pixels, or a string with a
122
+ * px/in/cm/mm unit) into the centimetres WebDriver's Print Page expects.
123
+ *
124
+ * @param {number|string} value
125
+ * @returns {number}
126
+ */
127
+ export function toCentimetres(value) {
128
+ if (typeof value === 'number') {
129
+ return value * CM_PER_UNIT.px;
130
+ }
131
+ const match = /^(-?\d+(?:\.\d+)?)\s*(px|in|cm|mm)?$/i.exec(
132
+ String(value).trim()
133
+ );
134
+ if (!match) {
135
+ throw new Error(`Cannot convert "${value}" to a print length`);
136
+ }
137
+ const unit = (match[2] ?? 'px').toLowerCase();
138
+ return Number(match[1]) * CM_PER_UNIT[unit];
139
+ }
140
+
141
+ /**
142
+ * PDF options that have no WebDriver Print Page equivalent. They are refused
143
+ * rather than dropped, because a PDF without the header a caller asked for is
144
+ * a silently wrong result (limitation `webdriver-print-options`).
145
+ */
146
+ export const UNSUPPORTED_PRINT_OPTIONS = Object.freeze([
147
+ 'displayHeaderFooter',
148
+ 'headerTemplate',
149
+ 'footerTemplate',
150
+ 'preferCSSPageSize',
151
+ 'tagged',
152
+ 'outline',
153
+ 'omitBackground',
154
+ ]);
155
+
156
+ /**
157
+ * Translate Puppeteer/Playwright `page.pdf()` options into the parameters of
158
+ * selenium-webdriver's `driver.printPage()`.
159
+ *
160
+ * @param {Object} [options]
161
+ * @returns {Object} printPage options (lengths in centimetres)
162
+ */
163
+ export function toPrintOptions(options = {}) {
164
+ const unsupported = UNSUPPORTED_PRINT_OPTIONS.filter((name) => options[name]);
165
+ if (unsupported.length > 0) {
166
+ throw new Error(
167
+ `WebDriver Print Page does not support ${unsupported.join(', ')} ` +
168
+ '(see the webdriver-print-options limitation)'
169
+ );
170
+ }
171
+
172
+ const print = {};
173
+ if (options.landscape) {
174
+ print.orientation = 'landscape';
175
+ }
176
+ if (options.printBackground !== undefined) {
177
+ print.background = Boolean(options.printBackground);
178
+ }
179
+ if (options.scale !== undefined) {
180
+ print.scale = options.scale;
181
+ }
182
+ if (options.pageRanges) {
183
+ print.pageRanges = String(options.pageRanges)
184
+ .split(',')
185
+ .map((range) => range.trim())
186
+ .filter(Boolean);
187
+ }
188
+
189
+ if (options.format) {
190
+ const size = PAPER_FORMATS_IN[String(options.format).toLowerCase()];
191
+ if (!size) {
192
+ throw new Error(`Unknown paper format: ${options.format}`);
193
+ }
194
+ print.width = size[0] * CM_PER_UNIT.in;
195
+ print.height = size[1] * CM_PER_UNIT.in;
196
+ }
197
+ if (options.width !== undefined) {
198
+ print.width = toCentimetres(options.width);
199
+ }
200
+ if (options.height !== undefined) {
201
+ print.height = toCentimetres(options.height);
202
+ }
203
+
204
+ for (const side of ['top', 'right', 'bottom', 'left']) {
205
+ if (options.margin?.[side] !== undefined) {
206
+ print[side] = toCentimetres(options.margin[side]);
207
+ }
208
+ }
209
+ return print;
210
+ }
211
+
212
+ /**
213
+ * A WebDriver cookie in the shape `page.cookies()` returns in Puppeteer.
214
+ *
215
+ * @param {Object} cookie - WebDriver cookie
216
+ * @returns {Object}
217
+ */
218
+ export function fromWebDriverCookie(cookie) {
219
+ const session = cookie.expiry === undefined || cookie.expiry === null;
220
+ return {
221
+ name: cookie.name,
222
+ value: cookie.value,
223
+ domain: cookie.domain,
224
+ path: cookie.path ?? '/',
225
+ expires: session ? -1 : Number(cookie.expiry),
226
+ size: `${cookie.name}${cookie.value}`.length,
227
+ httpOnly: Boolean(cookie.httpOnly),
228
+ secure: Boolean(cookie.secure),
229
+ session,
230
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * A Puppeteer `setCookie()` argument as a WebDriver cookie.
236
+ *
237
+ * WebDriver can only add a cookie for the document that is loaded, so a
238
+ * cookie's `url` is not a way to target another site here; the domain of the
239
+ * current document applies unless `domain` names a parent of it.
240
+ *
241
+ * @param {Object} cookie - Puppeteer cookie parameter
242
+ * @returns {Object}
243
+ */
244
+ export function toWebDriverCookie(cookie) {
245
+ const result = { name: cookie.name, value: cookie.value };
246
+ for (const field of ['path', 'domain', 'secure', 'httpOnly', 'sameSite']) {
247
+ if (cookie[field] !== undefined) {
248
+ result[field] = cookie[field];
249
+ }
250
+ }
251
+ if (typeof cookie.expires === 'number' && cookie.expires > 0) {
252
+ result.expiry = Math.floor(cookie.expires);
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * The page-side script that runs a function (or evaluates an expression) and
259
+ * reports the document URL in the same round trip.
260
+ *
261
+ * The function source is part of the script body instead of being passed as an
262
+ * argument and compiled in the page: WebDriver compiles the body outside the
263
+ * page's Content-Security-Policy, whereas `new Function()` in the page is
264
+ * refused on any site that forbids `unsafe-eval`.
265
+ *
266
+ * @param {Function|string} pageFunction
267
+ * @returns {string} Script for `driver.executeScript`
268
+ */
269
+ export function buildEvaluateScript(pageFunction) {
270
+ const call =
271
+ typeof pageFunction === 'function'
272
+ ? `(${pageFunction.toString()}).apply(null, args)`
273
+ : `(${pageFunction})`;
274
+ return [
275
+ 'const args = Array.prototype.slice.call(arguments);',
276
+ `return (async () => [await ${call}, location.href])();`,
277
+ ].join('\n');
278
+ }
279
+
280
+ const SCRIPT_UNSAFE_CHARACTERS = {
281
+ '<': '\\u003C',
282
+ '>': '\\u003E',
283
+ '/': '\\u002F',
284
+ '\u2028': '\\u2028',
285
+ '\u2029': '\\u2029',
286
+ };
287
+
288
+ /**
289
+ * A JSON value as a JavaScript literal that is safe to splice into script
290
+ * source: `JSON.stringify` alone leaves `</script>`, U+2028 and U+2029, which
291
+ * end or break the surrounding code.
292
+ *
293
+ * @param {*} value - JSON-serialisable value
294
+ * @returns {string}
295
+ */
296
+ export function toScriptLiteral(value) {
297
+ return JSON.stringify(value).replace(
298
+ /[<>/\u2028\u2029]/gu,
299
+ (character) => SCRIPT_UNSAFE_CHARACTERS[character]
300
+ );
301
+ }
302
+
303
+ /**
304
+ * A BiDi `log.entryAdded` entry as a Puppeteer ConsoleMessage.
305
+ *
306
+ * @param {Object} params
307
+ * @returns {Object}
308
+ */
309
+ export function toConsoleMessage(params) {
310
+ const type = params.method ?? params.level ?? 'log';
311
+ return {
312
+ type: () => (type === 'warn' ? 'warning' : type),
313
+ text: () => params.text ?? '',
314
+ args: () => params.args ?? [],
315
+ location: () => ({
316
+ url: params.source?.url ?? params.stackTrace?.callFrames?.[0]?.url,
317
+ }),
318
+ level: params.level,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * A BiDi network event as a Puppeteer HTTPRequest.
324
+ *
325
+ * @param {Object} params - BiDi network event parameters
326
+ * @returns {Object}
327
+ */
328
+ export function toNetworkRequest(params) {
329
+ const request = params.request ?? {};
330
+ const headers = Object.fromEntries(
331
+ (request.headers ?? []).map(({ name, value }) => [
332
+ name.toLowerCase(),
333
+ value?.value ?? value,
334
+ ])
335
+ );
336
+ return {
337
+ id: request.request,
338
+ url: () => request.url,
339
+ method: () => request.method,
340
+ headers: () => headers,
341
+ isNavigationRequest: () => Boolean(params.navigation),
342
+ failure: () =>
343
+ params.errorText ? { errorText: params.errorText } : undefined,
344
+ response: () =>
345
+ params.response ? { status: () => params.response.status } : null,
346
+ };
347
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * WebDriver BiDi events as the page events the rest of Browser Commander
3
+ * listens to (issue #104).
4
+ *
5
+ * Classic WebDriver is request/response only: nothing tells the client that a
6
+ * console message was logged, a dialog opened or a request went out. BiDi
7
+ * does, so when the session has a BiDi connection the page facade subscribes
8
+ * to the matching BiDi event the first time something listens for a page
9
+ * event, and re-emits it in the Puppeteer shape:
10
+ *
11
+ * | page event | BiDi event |
12
+ * | ----------------- | ------------------------------------------------- |
13
+ * | `console` | `log.entryAdded` (console entries) |
14
+ * | `pageerror` | `log.entryAdded` (type `javascript`) |
15
+ * | `framenavigated` | `browsingContext.navigationCommitted` (or |
16
+ * | | `domContentLoaded`), `fragmentNavigated`, |
17
+ * | | `historyUpdated` |
18
+ * | `domcontentloaded`| `browsingContext.domContentLoaded` |
19
+ * | `load` | `browsingContext.load` |
20
+ * | `dialog` | `browsingContext.userPromptOpened` |
21
+ * | `request` | `network.beforeRequestSent` |
22
+ * | `requestfinished` | `network.responseCompleted` |
23
+ * | `response` | `network.responseCompleted` |
24
+ * | `requestfailed` | `network.fetchError` |
25
+ *
26
+ * Without BiDi the events stay silent, which is limitation
27
+ * `webdriver-classic-has-no-events`.
28
+ */
29
+
30
+ import { toConsoleMessage, toNetworkRequest } from './webdriver-conversions.js';
31
+
32
+ /**
33
+ * The BiDi events each page event needs. Where a list has alternatives
34
+ * (`oneOf`), the first one the driver accepts is used: older drivers do not
35
+ * know `browsingContext.navigationCommitted`, and `domContentLoaded` is the
36
+ * closest event every driver has.
37
+ */
38
+ export const PAGE_EVENT_SOURCES = Object.freeze({
39
+ console: [['log.entryAdded']],
40
+ pageerror: [['log.entryAdded']],
41
+ framenavigated: [
42
+ ['browsingContext.navigationCommitted', 'browsingContext.domContentLoaded'],
43
+ ['browsingContext.fragmentNavigated'],
44
+ ['browsingContext.historyUpdated'],
45
+ ],
46
+ domcontentloaded: [['browsingContext.domContentLoaded']],
47
+ load: [['browsingContext.load']],
48
+ dialog: [['browsingContext.userPromptOpened']],
49
+ request: [['network.beforeRequestSent']],
50
+ requestfinished: [['network.responseCompleted']],
51
+ response: [['network.responseCompleted']],
52
+ requestfailed: [['network.fetchError']],
53
+ });
54
+
55
+ const NAVIGATION_EVENTS = new Set([
56
+ 'browsingContext.navigationCommitted',
57
+ 'browsingContext.fragmentNavigated',
58
+ 'browsingContext.historyUpdated',
59
+ ]);
60
+
61
+ /**
62
+ * A BiDi user prompt as a Puppeteer Dialog.
63
+ *
64
+ * @param {Object} page - The WebDriverPage the prompt belongs to
65
+ * @param {Object} params - `browsingContext.userPromptOpened` parameters
66
+ * @returns {Object}
67
+ */
68
+ function toDialog(page, params) {
69
+ const handle = (accept, userText) =>
70
+ page.bidiCommand('browsingContext.handleUserPrompt', {
71
+ context: params.context,
72
+ accept,
73
+ ...(userText !== undefined ? { userText: String(userText) } : {}),
74
+ });
75
+ return {
76
+ type: () => params.type,
77
+ message: () => params.message ?? '',
78
+ defaultValue: () => params.defaultValue ?? '',
79
+ accept: (promptText) => handle(true, promptText),
80
+ dismiss: () => handle(false),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Bridge BiDi events onto a WebDriverPage.
86
+ *
87
+ * @param {Object} page - The WebDriverPage (an EventEmitter with `bidi()`)
88
+ * @returns {{subscribe: function(string): Promise<void>, ready: function(): Promise<void>, dispose: function(): Promise<void>}}
89
+ */
90
+ export function createWebDriverEventBridge(page) {
91
+ const subscribed = new Map(); // page event -> Promise
92
+ const handles = [];
93
+ // Which BiDi method was chosen for the navigation alternative; the
94
+ // domContentLoaded fallback also counts as a navigation then.
95
+ let navigationSource = null;
96
+
97
+ const isTop = (context) =>
98
+ context === undefined || context === page.contextIdSync();
99
+
100
+ function dispatch(method, params) {
101
+ switch (method) {
102
+ case 'log.entryAdded': {
103
+ if (!isTop(params.source?.context)) {
104
+ return;
105
+ }
106
+ if (params.type === 'javascript') {
107
+ const error = new Error(params.text ?? 'Uncaught error');
108
+ error.stackTrace = params.stackTrace;
109
+ page.emit('pageerror', error);
110
+ } else {
111
+ page.emit('console', toConsoleMessage(params));
112
+ }
113
+ return;
114
+ }
115
+ case 'browsingContext.userPromptOpened':
116
+ if (isTop(params.context)) {
117
+ page.emit('dialog', toDialog(page, params));
118
+ }
119
+ return;
120
+ case 'network.beforeRequestSent':
121
+ page.emit('request', toNetworkRequest(params));
122
+ return;
123
+ case 'network.responseCompleted': {
124
+ const request = toNetworkRequest(params);
125
+ page.emit('response', request.response());
126
+ page.emit('requestfinished', request);
127
+ return;
128
+ }
129
+ case 'network.fetchError':
130
+ page.emit('requestfailed', toNetworkRequest(params));
131
+ return;
132
+ default:
133
+ break;
134
+ }
135
+
136
+ if (!isTop(params.context)) {
137
+ return;
138
+ }
139
+ if (NAVIGATION_EVENTS.has(method) || method === navigationSource) {
140
+ page.recordNavigation(params.url);
141
+ }
142
+ if (method === 'browsingContext.domContentLoaded') {
143
+ page.emit('domcontentloaded');
144
+ } else if (method === 'browsingContext.load') {
145
+ page.emit('load');
146
+ }
147
+ }
148
+
149
+ const methodHandles = new Map(); // BiDi method -> Promise<handle>
150
+
151
+ function listen(method) {
152
+ if (!methodHandles.has(method)) {
153
+ methodHandles.set(
154
+ method,
155
+ page.subscribeBidi(method, (params) => dispatch(method, params))
156
+ );
157
+ }
158
+ return methodHandles.get(method);
159
+ }
160
+
161
+ async function listenToFirstSupported(alternatives) {
162
+ let lastError = null;
163
+ for (const method of alternatives) {
164
+ try {
165
+ const handle = await listen(method);
166
+ handles.push(handle);
167
+ if (alternatives.length > 1) {
168
+ navigationSource = method;
169
+ }
170
+ return;
171
+ } catch (error) {
172
+ methodHandles.delete(method);
173
+ lastError = error;
174
+ }
175
+ }
176
+ throw lastError;
177
+ }
178
+
179
+ async function subscribeSources(eventName) {
180
+ const bidi = await page.bidi();
181
+ if (!bidi) {
182
+ return;
183
+ }
184
+ // Events are filtered to the page's own browsing context, so its id has
185
+ // to be known before the first one arrives.
186
+ await page.contextId();
187
+ for (const alternatives of PAGE_EVENT_SOURCES[eventName]) {
188
+ await listenToFirstSupported(alternatives);
189
+ }
190
+ }
191
+
192
+ return {
193
+ /**
194
+ * Make sure the BiDi events behind a page event are subscribed.
195
+ * Unknown event names and classic sessions are a no-op.
196
+ *
197
+ * @param {string} eventName
198
+ * @returns {Promise<void>}
199
+ */
200
+ subscribe(eventName) {
201
+ if (!PAGE_EVENT_SOURCES[eventName]) {
202
+ return Promise.resolve();
203
+ }
204
+ if (!subscribed.has(eventName)) {
205
+ const pending = subscribeSources(eventName).catch((error) => {
206
+ page.recordBridgeError(eventName, error);
207
+ });
208
+ subscribed.set(eventName, pending);
209
+ }
210
+ return subscribed.get(eventName);
211
+ },
212
+
213
+ /** Resolves once every subscription requested so far is in place. */
214
+ async ready() {
215
+ await Promise.all(subscribed.values());
216
+ },
217
+
218
+ /** Remove every BiDi subscription the bridge made. */
219
+ async dispose() {
220
+ const all = [...new Set(handles)];
221
+ handles.length = 0;
222
+ subscribed.clear();
223
+ methodHandles.clear();
224
+ await Promise.all(
225
+ all.map((handle) => Promise.resolve(handle?.unsubscribe?.()))
226
+ ).catch(() => {});
227
+ },
228
+ };
229
+ }