browser-commander 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Stand-ins for a selenium-webdriver WebDriver, its WebElements and its BiDi
3
+ * connection (issue #104). Every call is recorded in `calls` so tests can
4
+ * assert on the WebDriver commands an operation produced.
5
+ */
6
+
7
+ /**
8
+ * A mock WebElement.
9
+ *
10
+ * @param {Object} [options]
11
+ * @param {boolean} [options.displayed=true]
12
+ * @param {Array} [calls] - Shared call log
13
+ * @returns {Object}
14
+ */
15
+ export function createMockWebElement(
16
+ { displayed = true, id = 'el' } = {},
17
+ calls = []
18
+ ) {
19
+ return {
20
+ id,
21
+ displayed,
22
+ click: async () => {
23
+ calls.push(['element.click', id]);
24
+ },
25
+ sendKeys: async (...keys) => {
26
+ calls.push(['element.sendKeys', id, ...keys]);
27
+ },
28
+ clear: async () => {
29
+ calls.push(['element.clear', id]);
30
+ },
31
+ async isDisplayed() {
32
+ return this.displayed;
33
+ },
34
+ };
35
+ }
36
+
37
+ function createMockActions(calls) {
38
+ const steps = [];
39
+ const chain = {};
40
+ for (const name of [
41
+ 'move',
42
+ 'press',
43
+ 'release',
44
+ 'keyDown',
45
+ 'keyUp',
46
+ 'sendKeys',
47
+ 'pause',
48
+ 'scroll',
49
+ 'click',
50
+ ]) {
51
+ chain[name] = (...args) => {
52
+ steps.push([name, ...args]);
53
+ return chain;
54
+ };
55
+ }
56
+ chain.perform = async () => {
57
+ calls.push(['actions', steps]);
58
+ };
59
+ return chain;
60
+ }
61
+
62
+ /**
63
+ * A mock WebDriver.
64
+ *
65
+ * @param {Object} [options]
66
+ * @param {string} [options.url='about:blank']
67
+ * @param {Object} [options.capabilities] - e.g. {webSocketUrl: 'ws://...'}
68
+ * @param {Object<string, Object[]>} [options.elements] - CSS selector -> elements
69
+ * @param {Function} [options.script] - (script, args) => value returned by the page function
70
+ * @returns {Object}
71
+ */
72
+ export function createMockDriver({
73
+ url = 'about:blank',
74
+ capabilities = {},
75
+ elements = {},
76
+ script = () => undefined,
77
+ cookies = [],
78
+ } = {}) {
79
+ const calls = [];
80
+ const state = { url, cookies: [...cookies] };
81
+ const driver = {
82
+ calls,
83
+ state,
84
+ getCurrentUrl: async () => state.url,
85
+ get: async (target) => {
86
+ calls.push(['get', target]);
87
+ state.url = target;
88
+ },
89
+ getTitle: async () => 'Mock title',
90
+ getPageSource: async () => '<html></html>',
91
+ getWindowHandle: async () => 'context-1',
92
+ getCapabilities: async () => ({ get: (name) => capabilities[name] }),
93
+ findElements: async (locator) => {
94
+ calls.push(['findElements', locator]);
95
+ return elements[locator.css] ?? [];
96
+ },
97
+ executeScript: async (source, ...args) => {
98
+ calls.push(['executeScript', source, args]);
99
+ return [await script(source, args), state.url];
100
+ },
101
+ manage: () => ({
102
+ getCookies: async () => state.cookies,
103
+ addCookie: async (cookie) => {
104
+ calls.push(['addCookie', cookie]);
105
+ state.cookies.push(cookie);
106
+ },
107
+ deleteCookie: async (name) => {
108
+ calls.push(['deleteCookie', name]);
109
+ state.cookies = state.cookies.filter((c) => c.name !== name);
110
+ },
111
+ setTimeouts: async (timeouts) => {
112
+ calls.push(['setTimeouts', timeouts]);
113
+ },
114
+ }),
115
+ navigate: () => ({
116
+ back: async () => calls.push(['back']),
117
+ forward: async () => calls.push(['forward']),
118
+ refresh: async () => calls.push(['refresh']),
119
+ }),
120
+ switchTo: () => ({
121
+ window: async (handle) => calls.push(['switchTo.window', handle]),
122
+ }),
123
+ actions: (options) => {
124
+ calls.push(['actions.new', options]);
125
+ return createMockActions(calls);
126
+ },
127
+ takeScreenshot: async () => Buffer.from('png').toString('base64'),
128
+ printPage: async (options) => {
129
+ calls.push(['printPage', options]);
130
+ return Buffer.from('%PDF-mock').toString('base64');
131
+ },
132
+ close: async () => calls.push(['close']),
133
+ quit: async () => calls.push(['quit']),
134
+ };
135
+ return driver;
136
+ }
137
+
138
+ /**
139
+ * A mock BiDi connection: `send` answers from `responses`, `addCallback`
140
+ * records handlers that `fire(method, params)` invokes.
141
+ *
142
+ * @param {Object} [options]
143
+ * @param {Object<string, Object|Function>} [options.responses] - method -> result (or params => result)
144
+ * @param {string[]} [options.unsupported] - Event methods that fail to subscribe
145
+ * @returns {Object}
146
+ */
147
+ export function createMockBidi({ responses = {}, unsupported = [] } = {}) {
148
+ const sent = [];
149
+ const callbacks = new Map();
150
+ const unsubscribed = [];
151
+ return {
152
+ sent,
153
+ callbacks,
154
+ unsubscribed,
155
+ send: async ({ method, params }) => {
156
+ sent.push({ method, params });
157
+ const response = responses[method];
158
+ if (response?.error) {
159
+ return response;
160
+ }
161
+ return {
162
+ type: 'success',
163
+ result:
164
+ typeof response === 'function' ? response(params) : (response ?? {}),
165
+ };
166
+ },
167
+ addCallback: async (method, handler) => {
168
+ if (unsupported.includes(method)) {
169
+ throw new Error(`invalid argument: ${method} is not a known event`);
170
+ }
171
+ callbacks.set(method, handler);
172
+ return {
173
+ unsubscribe: async () => {
174
+ unsubscribed.push(method);
175
+ callbacks.delete(method);
176
+ },
177
+ };
178
+ },
179
+ fire(method, params) {
180
+ callbacks.get(method)?.(params);
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,360 @@
1
+ // feature-parity: attach.extension
2
+ import assert from 'node:assert';
3
+ import { once } from 'node:events';
4
+ import { access } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+
8
+ import {
9
+ attachViaExtension,
10
+ checkRelayUpgrade,
11
+ EXTENSION_DIRECTORY,
12
+ extensionIdFromOrigin,
13
+ RELAY_PATH,
14
+ } from '../../../../src/browser/attach/extension-relay.js';
15
+ import { connectWebSocket } from '../../../../src/browser/attach/websocket.js';
16
+ import { createRelayClient } from '../../../../extension/relay-handler.js';
17
+ import {
18
+ createExtensionWebSocket,
19
+ createFakeChrome,
20
+ } from '../../../helpers/fake-chrome.js';
21
+
22
+ const TABS = [
23
+ { id: 1, url: 'https://mail.test/', title: 'Inbox', active: true },
24
+ { id: 2, url: 'https://docs.test/', title: 'Docs', active: false },
25
+ ];
26
+
27
+ /**
28
+ * A fake extension that speaks the protocol by hand, so the test controls
29
+ * every message.
30
+ */
31
+ async function connectFakeExtension(url, origin = 'chrome-extension://abc') {
32
+ const connection = await connectWebSocket(url, {
33
+ headers: { Origin: origin },
34
+ });
35
+ const requests = [];
36
+ connection.on('message', (text) => {
37
+ const request = JSON.parse(text);
38
+ requests.push(request);
39
+ connection.emit('request', request);
40
+ });
41
+ const post = (message) => connection.send(JSON.stringify(message));
42
+ return { connection, requests, post };
43
+ }
44
+
45
+ /** Start the relay on a free port with a hand-driven extension. */
46
+ async function attachWithFakeExtension(options = {}) {
47
+ let extension;
48
+ const relay = await attachViaExtension({
49
+ port: 0,
50
+ timeoutMs: 5_000,
51
+ ...options,
52
+ onListening: async ({ url }) => {
53
+ extension = await connectFakeExtension(url);
54
+ extension.post({
55
+ type: 'hello',
56
+ extensionVersion: '1.2.3',
57
+ userAgent: 'Mozilla/5.0 Test',
58
+ });
59
+ },
60
+ });
61
+ return { relay, extension };
62
+ }
63
+
64
+ /** Answer the next request with `reply(request)`. */
65
+ function answerNext(extension, reply) {
66
+ extension.connection.once('request', (request) =>
67
+ extension.post({ id: request.id, ...reply(request) })
68
+ );
69
+ }
70
+
71
+ async function statusOf(url, headers) {
72
+ try {
73
+ const connection = await connectWebSocket(url, { headers });
74
+ connection.terminate();
75
+ return 101;
76
+ } catch (error) {
77
+ return error.statusCode;
78
+ }
79
+ }
80
+
81
+ describe('checkRelayUpgrade', () => {
82
+ it('accepts one extension origin on the relay path only', () => {
83
+ const accept = { path: RELAY_PATH, origin: 'chrome-extension://abc' };
84
+ assert.deepEqual(checkRelayUpgrade(accept), {
85
+ accept: true,
86
+ extensionId: 'abc',
87
+ });
88
+ assert.equal(
89
+ checkRelayUpgrade({ ...accept, path: `${RELAY_PATH}?x=1` }).accept,
90
+ true
91
+ );
92
+ const status = (request) => checkRelayUpgrade(request).status;
93
+ assert.equal(status({ ...accept, path: '/' }), 404);
94
+ assert.equal(status({ ...accept, origin: 'http://evil.example' }), 403);
95
+ assert.equal(status({ ...accept, origin: undefined }), 403);
96
+ assert.equal(status({ ...accept, origin: 'null' }), 403);
97
+ assert.equal(
98
+ status({ ...accept, origin: 'chrome-extension://abc.evil.example' }),
99
+ 403
100
+ );
101
+ assert.equal(status({ ...accept, allowedExtensionIds: ['xyz'] }), 403);
102
+ assert.equal(status({ ...accept, connected: true }), 409);
103
+ assert.equal(extensionIdFromOrigin('chrome-extension://abc'), 'abc');
104
+ assert.equal(extensionIdFromOrigin('moz-extension://abc'), null);
105
+ });
106
+
107
+ it('points at the unpacked extension', async () => {
108
+ await access(path.join(EXTENSION_DIRECTORY, 'manifest.json'));
109
+ await access(path.join(EXTENSION_DIRECTORY, 'background.js'));
110
+ });
111
+ });
112
+
113
+ describe('attachViaExtension', () => {
114
+ it('lists tabs, drives a debugger session and forwards its events', async () => {
115
+ const { relay, extension } = await attachWithFakeExtension();
116
+ try {
117
+ assert.equal(relay.mode, 'extension');
118
+ assert.deepEqual(relay.extension, {
119
+ id: 'abc',
120
+ version: '1.2.3',
121
+ userAgent: 'Mozilla/5.0 Test',
122
+ });
123
+ assert.equal(relay.connected(), true);
124
+ assert.ok(
125
+ relay.differences.some(({ aspect }) => aspect === 'debugger-infobar')
126
+ );
127
+
128
+ answerNext(extension, () => ({ result: [{ tabId: 1, url: 'u' }] }));
129
+ assert.deepEqual(await relay.tabs(), [{ tabId: 1, url: 'u' }]);
130
+
131
+ answerNext(extension, () => ({ result: { attached: true } }));
132
+ const session = await relay.session(1);
133
+ // Attaching again is idempotent and hands back the same session.
134
+ answerNext(extension, () => ({ result: { attached: true } }));
135
+ assert.equal(await relay.session(1), session);
136
+
137
+ answerNext(extension, ({ params }) => ({
138
+ result: { echoed: params },
139
+ }));
140
+ assert.deepEqual(
141
+ await session.send('Runtime.evaluate', { expression: '1+1' }),
142
+ {
143
+ echoed: {
144
+ tabId: 1,
145
+ method: 'Runtime.evaluate',
146
+ params: { expression: '1+1' },
147
+ },
148
+ }
149
+ );
150
+
151
+ answerNext(extension, () => ({ error: { message: 'no such method' } }));
152
+ await assert.rejects(session.send('Bad.method'), /no such method/u);
153
+
154
+ const event = once(session, 'Page.loadEventFired');
155
+ extension.post({
156
+ type: 'event',
157
+ tabId: 1,
158
+ method: 'Page.loadEventFired',
159
+ params: { timestamp: 7 },
160
+ });
161
+ assert.deepEqual(await event, [{ timestamp: 7 }]);
162
+
163
+ const detached = new Promise((resolve) =>
164
+ session.once('detached', resolve)
165
+ );
166
+ extension.post({
167
+ type: 'detached',
168
+ tabId: 1,
169
+ reason: 'canceled_by_user',
170
+ });
171
+ assert.deepEqual(await detached, {
172
+ tabId: 1,
173
+ reason: 'canceled_by_user',
174
+ });
175
+ await assert.rejects(session.send('Page.reload'), /detached/u);
176
+
177
+ assert.deepEqual(
178
+ extension.requests.map(({ method }) => method),
179
+ [
180
+ 'tabs.list',
181
+ 'debugger.attach',
182
+ 'debugger.attach',
183
+ 'cdp.send',
184
+ 'cdp.send',
185
+ ]
186
+ );
187
+ } finally {
188
+ await relay.close();
189
+ }
190
+ });
191
+
192
+ it('refuses web origins with 403 and a second extension with 409', async () => {
193
+ const { relay } = await attachWithFakeExtension();
194
+ try {
195
+ const url = `ws://127.0.0.1:${relay.port}${RELAY_PATH}`;
196
+ assert.equal(await statusOf(url, { Origin: 'http://evil.example' }), 403);
197
+ assert.equal(await statusOf(url, {}), 403);
198
+ assert.equal(
199
+ await statusOf(url, { Origin: 'chrome-extension://other' }),
200
+ 409
201
+ );
202
+ assert.equal(
203
+ await statusOf(`ws://127.0.0.1:${relay.port}/`, {
204
+ Origin: 'chrome-extension://abc',
205
+ }),
206
+ 404
207
+ );
208
+ assert.equal(relay.connected(), true);
209
+ } finally {
210
+ await relay.close();
211
+ }
212
+ await assert.rejects(
213
+ connectWebSocket(relay.url, {
214
+ headers: { Origin: 'chrome-extension://abc' },
215
+ }),
216
+ { code: 'ECONNREFUSED' }
217
+ );
218
+ });
219
+
220
+ it('rejects pending requests and detaches sessions when the extension leaves', async () => {
221
+ const { relay, extension } = await attachWithFakeExtension();
222
+ try {
223
+ answerNext(extension, () => ({ result: { attached: true } }));
224
+ const session = await relay.session(2);
225
+ const detached = new Promise((resolve) =>
226
+ session.once('detached', resolve)
227
+ );
228
+ const pending = relay.tabs();
229
+ extension.connection.terminate();
230
+
231
+ await assert.rejects(pending, /disconnected/u);
232
+ assert.equal((await detached).tabId, 2);
233
+ assert.equal(relay.connected(), false);
234
+ await assert.rejects(relay.tabs(), /not connected/u);
235
+ } finally {
236
+ await relay.close();
237
+ }
238
+ });
239
+
240
+ it('closes the extension connection with 1001 on close()', async () => {
241
+ const { relay, extension } = await attachWithFakeExtension();
242
+ const closed = once(extension.connection, 'close');
243
+ await relay.close();
244
+ await relay.close();
245
+ assert.equal((await closed)[0].code, 1001);
246
+ });
247
+
248
+ it('accepts only allowedExtensionIds', async () => {
249
+ let refused;
250
+ await assert.rejects(
251
+ attachViaExtension({
252
+ port: 0,
253
+ timeoutMs: 300,
254
+ allowedExtensionIds: ['xyz'],
255
+ onListening: ({ url }) => {
256
+ refused = statusOf(url, { Origin: 'chrome-extension://abc' });
257
+ },
258
+ }),
259
+ /Load unpacked/u
260
+ );
261
+ assert.equal(await refused, 403);
262
+ });
263
+
264
+ it('times out with install instructions and frees the port', async () => {
265
+ let url;
266
+ await assert.rejects(
267
+ attachViaExtension({
268
+ port: 0,
269
+ timeoutMs: 100,
270
+ onListening: (listening) => {
271
+ url = listening.url;
272
+ },
273
+ }),
274
+ (error) =>
275
+ error.message.includes(EXTENSION_DIRECTORY) &&
276
+ error.message.includes('Load unpacked')
277
+ );
278
+ await assert.rejects(
279
+ connectWebSocket(url, { headers: { Origin: 'chrome-extension://abc' } }),
280
+ { code: 'ECONNREFUSED' }
281
+ );
282
+ });
283
+
284
+ it('listens on loopback only and validates its options', async () => {
285
+ for (const options of [
286
+ { host: '0.0.0.0' },
287
+ { host: '192.168.1.2' },
288
+ { port: 70_000 },
289
+ { timeoutMs: 0 },
290
+ { allowedExtensionIds: 'abc' },
291
+ ]) {
292
+ await assert.rejects(attachViaExtension(options), TypeError);
293
+ }
294
+ });
295
+
296
+ it('drives the real extension service worker code end to end', async () => {
297
+ const chrome = createFakeChrome({ tabs: TABS.map((tab) => ({ ...tab })) });
298
+ let client;
299
+ const relay = await attachViaExtension({
300
+ port: 0,
301
+ timeoutMs: 5_000,
302
+ onListening: ({ port }) => {
303
+ chrome.storage.local.get = async () => ({ port });
304
+ client = createRelayClient({
305
+ chrome,
306
+ WebSocket: createExtensionWebSocket(),
307
+ userAgent: 'Mozilla/5.0 Extension',
308
+ });
309
+ client.start();
310
+ },
311
+ });
312
+ try {
313
+ assert.deepEqual(relay.extension, {
314
+ id: 'abc',
315
+ version: '1.2.3',
316
+ userAgent: 'Mozilla/5.0 Extension',
317
+ });
318
+ assert.deepEqual(await relay.tabs(), [
319
+ { tabId: 1, url: 'https://mail.test/', title: 'Inbox', active: true },
320
+ { tabId: 2, url: 'https://docs.test/', title: 'Docs', active: false },
321
+ ]);
322
+ assert.deepEqual(await relay.newTab('https://new.test/'), {
323
+ tabId: 100,
324
+ });
325
+
326
+ const session = await relay.session(1);
327
+ assert.deepEqual(
328
+ await session.send('Runtime.evaluate', { expression: '1+1' }),
329
+ { result: { type: 'number', value: 2 } }
330
+ );
331
+ await assert.rejects(session.send('Bad.method'), /wasn't found/u);
332
+
333
+ const event = once(session, 'Network.requestWillBeSent');
334
+ chrome.debugger.onEvent.fire({ tabId: 2 }, 'Ignored.event', {});
335
+ chrome.debugger.onEvent.fire({ tabId: 1 }, 'Network.requestWillBeSent', {
336
+ requestId: 'r1',
337
+ });
338
+ assert.deepEqual(await event, [{ requestId: 'r1' }]);
339
+
340
+ await session.detach();
341
+ assert.deepEqual(
342
+ chrome.calls.filter(([name]) => name.startsWith('debugger.')),
343
+ [
344
+ ['debugger.attach', 1, '1.3'],
345
+ [
346
+ 'debugger.sendCommand',
347
+ 1,
348
+ 'Runtime.evaluate',
349
+ { expression: '1+1' },
350
+ ],
351
+ ['debugger.sendCommand', 1, 'Bad.method', {}],
352
+ ['debugger.detach', 1],
353
+ ]
354
+ );
355
+ } finally {
356
+ client?.stop();
357
+ await relay.close();
358
+ }
359
+ });
360
+ });