browser-commander 0.19.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.
- package/CHANGELOG.md +156 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +25 -6
- package/scripts/run-tests.mjs +4 -2
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +11 -4
- package/tests/unit/traces/recorder.test.js +4 -0
- package/tests/unit/traces/viewer.test.js +4 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { describeAttachDifferences } from './differences.js';
|
|
6
|
+
import { acceptUpgrade, CLOSE_CODES, rejectUpgrade } from './websocket.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Relay server for the companion extension (issue #102, addendum, mode 2).
|
|
10
|
+
*
|
|
11
|
+
* The user's running, signed-in browser cannot be reached over CDP (Chrome 136
|
|
12
|
+
* and later ignore the debugging switches for the default profile, and
|
|
13
|
+
* restarting it with one would change its command line). Instead, the
|
|
14
|
+
* extension in js/extension, installed once with "Load unpacked", connects
|
|
15
|
+
* from its service worker to this relay on loopback and forwards requests to
|
|
16
|
+
* `chrome.tabs` and `chrome.debugger`. The protocol is documented in
|
|
17
|
+
* js/extension/relay-handler.js.
|
|
18
|
+
*
|
|
19
|
+
* Security: the server listens on a loopback address only; an upgrade whose
|
|
20
|
+
* `Origin` is not `chrome-extension://<id>` (or whose id is not in
|
|
21
|
+
* `allowedExtensionIds`) is refused with HTTP 403, so a web page cannot
|
|
22
|
+
* connect; and only one extension connection is accepted at a time (a second
|
|
23
|
+
* one gets HTTP 409).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Relay port the extension connects to unless configured otherwise. */
|
|
27
|
+
export const DEFAULT_RELAY_PORT = 9333;
|
|
28
|
+
|
|
29
|
+
/** Path of the relay's WebSocket endpoint. */
|
|
30
|
+
export const RELAY_PATH = '/browser-commander';
|
|
31
|
+
|
|
32
|
+
/** The unpacked extension to load in chrome://extensions. */
|
|
33
|
+
export const EXTENSION_DIRECTORY = fileURLToPath(
|
|
34
|
+
new URL('../../../extension/', import.meta.url)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
38
|
+
|
|
39
|
+
const EXTENSION_ORIGIN = /^chrome-extension:\/\/([a-z0-9]+)$/u;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The extension id of a `chrome-extension://<id>` origin, or null.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|undefined} origin
|
|
45
|
+
* @returns {string|null}
|
|
46
|
+
*/
|
|
47
|
+
export function extensionIdFromOrigin(origin) {
|
|
48
|
+
return EXTENSION_ORIGIN.exec(origin ?? '')?.[1] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decide whether to accept a WebSocket upgrade.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} request
|
|
55
|
+
* @param {string} request.path - Request path, possibly with a query
|
|
56
|
+
* @param {string} [request.origin] - The Origin header
|
|
57
|
+
* @param {string[]} [request.allowedExtensionIds] - Accept only these ids
|
|
58
|
+
* @param {boolean} [request.connected=false] - An extension is already connected
|
|
59
|
+
* @returns {{accept: true, extensionId: string}|{accept: false, status: number, message: string}}
|
|
60
|
+
*/
|
|
61
|
+
export function checkRelayUpgrade({
|
|
62
|
+
path,
|
|
63
|
+
origin,
|
|
64
|
+
allowedExtensionIds,
|
|
65
|
+
connected = false,
|
|
66
|
+
}) {
|
|
67
|
+
if (new URL(path, 'http://relay').pathname !== RELAY_PATH) {
|
|
68
|
+
return { accept: false, status: 404, message: 'Not found' };
|
|
69
|
+
}
|
|
70
|
+
const extensionId = extensionIdFromOrigin(origin);
|
|
71
|
+
if (!extensionId) {
|
|
72
|
+
return {
|
|
73
|
+
accept: false,
|
|
74
|
+
status: 403,
|
|
75
|
+
message: 'Only the Browser Commander Relay extension may connect',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (allowedExtensionIds && !allowedExtensionIds.includes(extensionId)) {
|
|
79
|
+
return {
|
|
80
|
+
accept: false,
|
|
81
|
+
status: 403,
|
|
82
|
+
message: `Extension ${extensionId} is not allowed`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (connected) {
|
|
86
|
+
return {
|
|
87
|
+
accept: false,
|
|
88
|
+
status: 409,
|
|
89
|
+
message: 'Another extension is already connected',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { accept: true, extensionId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertRelayOptions({ port, host, timeoutMs, allowedExtensionIds }) {
|
|
96
|
+
if (!LOOPBACK_HOSTS.has(host)) {
|
|
97
|
+
throw new TypeError(
|
|
98
|
+
`The relay only listens on loopback (127.0.0.1, ::1 or localhost), got ${host}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
102
|
+
throw new TypeError(`port must be an integer 0-65535, got ${port}`);
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
`timeoutMs must be a positive number, got ${timeoutMs}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
allowedExtensionIds !== undefined &&
|
|
111
|
+
(!Array.isArray(allowedExtensionIds) ||
|
|
112
|
+
allowedExtensionIds.some((id) => typeof id !== 'string'))
|
|
113
|
+
) {
|
|
114
|
+
throw new TypeError('allowedExtensionIds must be an array of strings');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listen(server, port, host) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const onError = (error) => {
|
|
121
|
+
reject(
|
|
122
|
+
error.code === 'EADDRINUSE'
|
|
123
|
+
? new Error(
|
|
124
|
+
`The relay port ${host}:${port} is in use; pass another port and set it as "port" in the extension's storage`,
|
|
125
|
+
{ cause: error }
|
|
126
|
+
)
|
|
127
|
+
: error
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
server.once('error', onError);
|
|
131
|
+
server.listen(port, host, () => {
|
|
132
|
+
server.off('error', onError);
|
|
133
|
+
resolve(server.address().port);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatHost(host) {
|
|
139
|
+
return host.includes(':') ? `[${host}]` : host;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One extension connection and its requests, tabs and events. */
|
|
143
|
+
class RelayState {
|
|
144
|
+
constructor() {
|
|
145
|
+
this.connection = null;
|
|
146
|
+
this.extension = null;
|
|
147
|
+
this.nextId = 1;
|
|
148
|
+
this.pending = new Map();
|
|
149
|
+
this.sessions = new Map();
|
|
150
|
+
this.helloWaiters = new Set();
|
|
151
|
+
this.helloTimer = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
request(method, params = {}) {
|
|
155
|
+
const { connection } = this;
|
|
156
|
+
if (!connection || !this.extension) {
|
|
157
|
+
return Promise.reject(
|
|
158
|
+
new Error('The Browser Commander Relay extension is not connected')
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const id = this.nextId++;
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
this.pending.set(id, { resolve, reject, method });
|
|
164
|
+
connection.send(JSON.stringify({ id, method, params }));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
handleMessage(extensionId, text) {
|
|
169
|
+
let message;
|
|
170
|
+
try {
|
|
171
|
+
message = JSON.parse(text);
|
|
172
|
+
} catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!message || typeof message !== 'object') {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (message.type === 'hello') {
|
|
179
|
+
this.extension = {
|
|
180
|
+
id: extensionId,
|
|
181
|
+
version: String(message.extensionVersion ?? ''),
|
|
182
|
+
userAgent: String(message.userAgent ?? ''),
|
|
183
|
+
};
|
|
184
|
+
for (const waiter of this.helloWaiters) {
|
|
185
|
+
waiter(this.extension);
|
|
186
|
+
}
|
|
187
|
+
this.helloWaiters.clear();
|
|
188
|
+
} else if (message.type === 'event') {
|
|
189
|
+
this.sessions.get(message.tabId)?.emit(message.method, message.params);
|
|
190
|
+
} else if (message.type === 'detached') {
|
|
191
|
+
this.markDetached(message.tabId, message.reason);
|
|
192
|
+
} else if (Number.isInteger(message.id)) {
|
|
193
|
+
this.settle(message);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
settle({ id, result, error }) {
|
|
198
|
+
const entry = this.pending.get(id);
|
|
199
|
+
if (!entry) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.pending.delete(id);
|
|
203
|
+
if (error) {
|
|
204
|
+
entry.reject(
|
|
205
|
+
new Error(`${entry.method} failed: ${error.message ?? 'unknown error'}`)
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
entry.resolve(result);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
markDetached(tabId, reason) {
|
|
213
|
+
const emitter = this.sessions.get(tabId);
|
|
214
|
+
if (emitter) {
|
|
215
|
+
this.sessions.delete(tabId);
|
|
216
|
+
emitter.detached = true;
|
|
217
|
+
emitter.emit('detached', { tabId, reason });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
disconnected(reason) {
|
|
222
|
+
this.connection = null;
|
|
223
|
+
this.extension = null;
|
|
224
|
+
for (const entry of this.pending.values()) {
|
|
225
|
+
entry.reject(new Error(`${entry.method} failed: ${reason}`));
|
|
226
|
+
}
|
|
227
|
+
this.pending.clear();
|
|
228
|
+
for (const tabId of [...this.sessions.keys()]) {
|
|
229
|
+
this.markDetached(tabId, reason);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createSession(state, tabId, emitter) {
|
|
235
|
+
const session = {
|
|
236
|
+
tabId,
|
|
237
|
+
/**
|
|
238
|
+
* Send a CDP command to the tab.
|
|
239
|
+
* @param {string} method
|
|
240
|
+
* @param {Object} [params]
|
|
241
|
+
* @returns {Promise<Object>}
|
|
242
|
+
*/
|
|
243
|
+
send(method, params = {}) {
|
|
244
|
+
if (emitter.detached) {
|
|
245
|
+
return Promise.reject(
|
|
246
|
+
new Error(`The debugger session of tab ${tabId} is detached`)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return state.request('cdp.send', { tabId, method, params });
|
|
250
|
+
},
|
|
251
|
+
on(event, listener) {
|
|
252
|
+
emitter.on(event, listener);
|
|
253
|
+
return session;
|
|
254
|
+
},
|
|
255
|
+
once(event, listener) {
|
|
256
|
+
emitter.once(event, listener);
|
|
257
|
+
return session;
|
|
258
|
+
},
|
|
259
|
+
off(event, listener) {
|
|
260
|
+
emitter.off(event, listener);
|
|
261
|
+
return session;
|
|
262
|
+
},
|
|
263
|
+
async detach() {
|
|
264
|
+
if (emitter.detached) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await state.request('debugger.detach', { tabId });
|
|
268
|
+
state.markDetached(tabId, 'detached_by_client');
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
return session;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function waitForHello(state, timeoutMs, url) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
state.helloTimer = setTimeout(() => {
|
|
277
|
+
state.helloWaiters.delete(resolve);
|
|
278
|
+
reject(
|
|
279
|
+
new Error(
|
|
280
|
+
`No Browser Commander Relay extension connected to ${url} within ${timeoutMs} ms. Install it once from ${EXTENSION_DIRECTORY} with chrome://extensions > Developer mode > "Load unpacked"; it connects to port ${DEFAULT_RELAY_PORT} unless "port" is set in its storage.`
|
|
281
|
+
)
|
|
282
|
+
);
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
state.helloWaiters.add((extension) => {
|
|
285
|
+
clearTimeout(state.helloTimer);
|
|
286
|
+
resolve(extension);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Start the relay and wait for the companion extension to connect.
|
|
293
|
+
*
|
|
294
|
+
* @param {Object} [options]
|
|
295
|
+
* @param {number} [options.port=9333] - Loopback port; 0 picks a free one (see `onListening`)
|
|
296
|
+
* @param {string} [options.host='127.0.0.1'] - Loopback address
|
|
297
|
+
* @param {number} [options.timeoutMs=60000] - How long to wait for the extension's hello
|
|
298
|
+
* @param {string[]} [options.allowedExtensionIds] - Accept only these extension ids
|
|
299
|
+
* @param {function({port: number, url: string}): void} [options.onListening] - Called once the server listens, before the extension connects
|
|
300
|
+
* @returns {Promise<{mode: 'extension', extension: {id: string, version: string, userAgent: string}, url: string, port: number, differences: Array<{aspect: string, description: string}>, connected: function(): boolean, tabs: function(): Promise<Array<{tabId: number, url: string, title: string, active: boolean}>>, newTab: function(string=): Promise<{tabId: number}>, session: function(number): Promise<Object>, close: function(): Promise<void>}>}
|
|
301
|
+
*/
|
|
302
|
+
export async function attachViaExtension({
|
|
303
|
+
port = DEFAULT_RELAY_PORT,
|
|
304
|
+
host = '127.0.0.1',
|
|
305
|
+
timeoutMs = 60_000,
|
|
306
|
+
allowedExtensionIds,
|
|
307
|
+
onListening,
|
|
308
|
+
} = {}) {
|
|
309
|
+
assertRelayOptions({ port, host, timeoutMs, allowedExtensionIds });
|
|
310
|
+
const state = new RelayState();
|
|
311
|
+
const sockets = new Set();
|
|
312
|
+
const server = http.createServer((request, response) => {
|
|
313
|
+
response.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
314
|
+
response.end('Browser Commander relay: WebSocket only');
|
|
315
|
+
});
|
|
316
|
+
server.on('connection', (socket) => {
|
|
317
|
+
sockets.add(socket);
|
|
318
|
+
socket.once('close', () => sockets.delete(socket));
|
|
319
|
+
});
|
|
320
|
+
server.on('upgrade', (request, socket, head) => {
|
|
321
|
+
const decision = checkRelayUpgrade({
|
|
322
|
+
path: request.url,
|
|
323
|
+
origin: request.headers.origin,
|
|
324
|
+
allowedExtensionIds,
|
|
325
|
+
connected: state.connection !== null,
|
|
326
|
+
});
|
|
327
|
+
if (!decision.accept) {
|
|
328
|
+
rejectUpgrade(socket, decision.status, decision.message);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const connection = acceptUpgrade(request, socket, head);
|
|
332
|
+
if (!connection) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
state.connection = connection;
|
|
336
|
+
connection.on('message', (text) =>
|
|
337
|
+
state.handleMessage(decision.extensionId, text)
|
|
338
|
+
);
|
|
339
|
+
connection.on('error', () => {});
|
|
340
|
+
connection.once('close', ({ code }) =>
|
|
341
|
+
state.disconnected(`the extension disconnected (${code})`)
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const actualPort = await listen(server, port, host);
|
|
346
|
+
const url = `ws://${formatHost(host)}:${actualPort}${RELAY_PATH}`;
|
|
347
|
+
let closing;
|
|
348
|
+
const close = () => {
|
|
349
|
+
closing ??= new Promise((resolve) => {
|
|
350
|
+
clearTimeout(state.helloTimer);
|
|
351
|
+
state.connection?.close(CLOSE_CODES.goingAway, 'relay closed');
|
|
352
|
+
state.disconnected('the relay was closed');
|
|
353
|
+
server.close(() => resolve());
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
for (const socket of sockets) {
|
|
356
|
+
socket.destroy();
|
|
357
|
+
}
|
|
358
|
+
}, 500);
|
|
359
|
+
timer.unref?.();
|
|
360
|
+
});
|
|
361
|
+
return closing;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const hello = waitForHello(state, timeoutMs, url);
|
|
365
|
+
try {
|
|
366
|
+
onListening?.({ port: actualPort, url });
|
|
367
|
+
await hello;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await close();
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const handle = {
|
|
374
|
+
mode: 'extension',
|
|
375
|
+
get extension() {
|
|
376
|
+
return state.extension ? { ...state.extension } : null;
|
|
377
|
+
},
|
|
378
|
+
url,
|
|
379
|
+
port: actualPort,
|
|
380
|
+
differences: describeAttachDifferences('extension'),
|
|
381
|
+
connected: () => state.extension !== null,
|
|
382
|
+
tabs: () => state.request('tabs.list'),
|
|
383
|
+
newTab: (tabUrl) =>
|
|
384
|
+
state.request('tabs.create', tabUrl === undefined ? {} : { url: tabUrl }),
|
|
385
|
+
async session(tabId) {
|
|
386
|
+
if (!Number.isInteger(tabId)) {
|
|
387
|
+
throw new TypeError(`tabId must be an integer, got ${tabId}`);
|
|
388
|
+
}
|
|
389
|
+
await state.request('debugger.attach', { tabId });
|
|
390
|
+
let emitter = state.sessions.get(tabId);
|
|
391
|
+
if (!emitter) {
|
|
392
|
+
emitter = new EventEmitter();
|
|
393
|
+
emitter.detached = false;
|
|
394
|
+
state.sessions.set(tabId, emitter);
|
|
395
|
+
}
|
|
396
|
+
emitter.session ??= createSession(state, tabId, emitter);
|
|
397
|
+
return emitter.session;
|
|
398
|
+
},
|
|
399
|
+
close,
|
|
400
|
+
};
|
|
401
|
+
return handle;
|
|
402
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { openInUserBrowser } from '../open-in-user-browser.js';
|
|
2
|
+
import { launchRealBrowser } from '../real-browser.js';
|
|
3
|
+
import { ATTACH_MODES, describeAttachDifferences } from './differences.js';
|
|
4
|
+
import { attachViaExtension } from './extension-relay.js';
|
|
5
|
+
|
|
6
|
+
export { ATTACH_MODES, describeAttachDifferences } from './differences.js';
|
|
7
|
+
export {
|
|
8
|
+
attachViaExtension,
|
|
9
|
+
checkRelayUpgrade,
|
|
10
|
+
DEFAULT_RELAY_PORT,
|
|
11
|
+
EXTENSION_DIRECTORY,
|
|
12
|
+
extensionIdFromOrigin,
|
|
13
|
+
RELAY_PATH,
|
|
14
|
+
} from './extension-relay.js';
|
|
15
|
+
export {
|
|
16
|
+
isSqliteFile,
|
|
17
|
+
snapshotSkipReason,
|
|
18
|
+
snapshotUserDataDir,
|
|
19
|
+
} from './snapshot.js';
|
|
20
|
+
export { snapshotBrowserForChannel } from './snapshot-launch.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reach the user's real browser in one of the issue #102 addendum modes. Each
|
|
24
|
+
* result carries `differences`: the documented ways the mode differs from the
|
|
25
|
+
* real browser, see {@link describeAttachDifferences}.
|
|
26
|
+
*
|
|
27
|
+
* - `snapshot`: {@link launchRealBrowser} on a read-only copy of a real
|
|
28
|
+
* profile. Options are launch options plus `browser`, `profile` and
|
|
29
|
+
* `userDataDir` (the source), which become `attach`. Returns the launched
|
|
30
|
+
* session with `mode`, `differences` and the `attach` report.
|
|
31
|
+
* - `extension`: {@link attachViaExtension} - drive tabs of the running
|
|
32
|
+
* browser through the companion extension. Options: `port`, `host`,
|
|
33
|
+
* `timeoutMs`, `allowedExtensionIds`, `onListening`.
|
|
34
|
+
* - `open`: {@link openInUserBrowser} - open `url` in the user's default
|
|
35
|
+
* browser without automation. Returns `{mode, opened, command,
|
|
36
|
+
* differences}`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} options
|
|
39
|
+
* @param {'snapshot'|'extension'|'open'} options.mode
|
|
40
|
+
* @param {Object} [dependencies] - Test seams: launchRealBrowser, attachViaExtension, openInUserBrowser
|
|
41
|
+
* @returns {Promise<Object>}
|
|
42
|
+
*/
|
|
43
|
+
export async function attachUserBrowser(options = {}, dependencies = {}) {
|
|
44
|
+
const { mode, ...rest } = options;
|
|
45
|
+
switch (mode) {
|
|
46
|
+
case 'snapshot': {
|
|
47
|
+
const { browser, profile, userDataDir, ...launchOptions } = rest;
|
|
48
|
+
const launch = dependencies.launchRealBrowser ?? launchRealBrowser;
|
|
49
|
+
const session = await launch({
|
|
50
|
+
...launchOptions,
|
|
51
|
+
attach: { mode: 'snapshot', browser, profile, userDataDir },
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
...session,
|
|
55
|
+
mode: 'snapshot',
|
|
56
|
+
differences: describeAttachDifferences('snapshot'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case 'extension': {
|
|
60
|
+
const attach = dependencies.attachViaExtension ?? attachViaExtension;
|
|
61
|
+
return await attach(rest);
|
|
62
|
+
}
|
|
63
|
+
case 'open': {
|
|
64
|
+
const { url, ...openOptions } = rest;
|
|
65
|
+
if (typeof url !== 'string' || url === '') {
|
|
66
|
+
throw new TypeError('attachUserBrowser open mode requires a url');
|
|
67
|
+
}
|
|
68
|
+
const open = dependencies.openInUserBrowser ?? openInUserBrowser;
|
|
69
|
+
const result = await open(url, openOptions);
|
|
70
|
+
return {
|
|
71
|
+
mode: 'open',
|
|
72
|
+
...result,
|
|
73
|
+
differences: describeAttachDifferences('open'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
default:
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
`Unknown attach mode "${mode}"; expected snapshot, extension or open (${ATTACH_MODES.join(', ')} are described by describeAttachDifferences)`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolveRestrictions } from '../restrictions.js';
|
|
2
|
+
import { describeAttachDifferences } from './differences.js';
|
|
3
|
+
import { snapshotUserDataDir } from './snapshot.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `attach: { mode: 'snapshot' }` option of `launchRealBrowser()` (issue
|
|
7
|
+
* #102, addendum): validation, the snapshot itself, and the extra switch.
|
|
8
|
+
* Kept apart from real-browser.js so the launcher stays one flow.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Switches that make the OS-keystore-encrypted data of a copy unreadable. */
|
|
12
|
+
const KEYSTORE_BYPASS_SWITCHES = ['--use-mock-keychain', '--password-store='];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The browser whose profile is snapshotted when `attach.browser` is omitted:
|
|
16
|
+
* the one the launch channel belongs to.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} channel - Launch channel such as chrome, msedge-beta or brave
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function snapshotBrowserForChannel(channel) {
|
|
22
|
+
if (channel.startsWith('msedge')) {
|
|
23
|
+
return 'edge';
|
|
24
|
+
}
|
|
25
|
+
if (channel === 'brave' || channel === 'chromium') {
|
|
26
|
+
return channel;
|
|
27
|
+
}
|
|
28
|
+
return 'chrome';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reject an `attach` option that the launcher cannot honour, before anything
|
|
33
|
+
* touches the disk.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
* @param {*} options.attach - The `attach` launch option
|
|
37
|
+
* @param {Object} [options.migrateFrom]
|
|
38
|
+
* @param {string} [options.userDataDir]
|
|
39
|
+
* @param {string[]} [options.customArgs] - args and extraArgs
|
|
40
|
+
* @returns {void}
|
|
41
|
+
* @throws {TypeError}
|
|
42
|
+
*/
|
|
43
|
+
export function validateAttachOption({
|
|
44
|
+
attach,
|
|
45
|
+
migrateFrom,
|
|
46
|
+
userDataDir,
|
|
47
|
+
customArgs = [],
|
|
48
|
+
}) {
|
|
49
|
+
if (attach === undefined) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!attach || typeof attach !== 'object' || Array.isArray(attach)) {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
"attach must be an object such as { mode: 'snapshot', browser: 'chrome' }"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (attach.mode !== 'snapshot') {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`launchRealBrowser only launches attach mode "snapshot", got ${JSON.stringify(attach.mode)}; use attachUserBrowser({ mode: 'extension' }) or openInUserBrowser(url) for the other modes`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (migrateFrom) {
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
'attach and migrateFrom are mutually exclusive: a snapshot already copies the whole profile'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (userDataDir) {
|
|
68
|
+
throw new TypeError(
|
|
69
|
+
'attach and userDataDir are mutually exclusive: a snapshot is launched from its own temporary user data directory'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
customArgs.some(
|
|
74
|
+
(argument) =>
|
|
75
|
+
argument === '--profile-directory' ||
|
|
76
|
+
argument.startsWith('--profile-directory=')
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
throw new TypeError(
|
|
80
|
+
'--profile-directory is managed by the snapshot attach mode; pass attach.profile instead'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function keystoreWarnings({ restrictions, customArgs }) {
|
|
86
|
+
const switches = [...resolveRestrictions(restrictions).args, ...customArgs];
|
|
87
|
+
return switches
|
|
88
|
+
.filter((argument) =>
|
|
89
|
+
KEYSTORE_BYPASS_SWITCHES.some((bypass) => argument.startsWith(bypass))
|
|
90
|
+
)
|
|
91
|
+
.filter((argument) => argument !== '--password-store=detect')
|
|
92
|
+
.map((argument) => ({
|
|
93
|
+
item: argument,
|
|
94
|
+
reason: 'encrypted-data-unreadable',
|
|
95
|
+
detail: `${argument} bypasses the OS keystore, so the copied cookies and saved passwords cannot be decrypted and the snapshot starts signed out`,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Snapshot the real profile into a new temporary user data directory for a
|
|
101
|
+
* launch.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} options
|
|
104
|
+
* @param {{mode: 'snapshot', browser: (string|undefined), profile: (string|undefined), userDataDir: (string|undefined)}} options.attach
|
|
105
|
+
* @param {string} options.channel - Launch channel
|
|
106
|
+
* @param {string[]} options.restrictions
|
|
107
|
+
* @param {string[]} options.customArgs - args and extraArgs
|
|
108
|
+
* @param {function(Object): Promise<Object>} [options.snapshot=snapshotUserDataDir]
|
|
109
|
+
* @returns {Promise<{userDataDir: string, args: string[], attach: {mode: 'snapshot', snapshot: Object, differences: Array<Object>}}>}
|
|
110
|
+
* The snapshot directory, the switches to put before the custom args, and the `attach` result entry
|
|
111
|
+
*/
|
|
112
|
+
export async function prepareSnapshotLaunch({
|
|
113
|
+
attach,
|
|
114
|
+
channel,
|
|
115
|
+
restrictions,
|
|
116
|
+
customArgs,
|
|
117
|
+
snapshot = snapshotUserDataDir,
|
|
118
|
+
}) {
|
|
119
|
+
const browser = attach.browser ?? snapshotBrowserForChannel(channel);
|
|
120
|
+
const profile = attach.profile ?? 'Default';
|
|
121
|
+
const report = await snapshot({
|
|
122
|
+
browser,
|
|
123
|
+
profile,
|
|
124
|
+
userDataDir: attach.userDataDir,
|
|
125
|
+
});
|
|
126
|
+
const warnings = [
|
|
127
|
+
...(report.warnings ?? []),
|
|
128
|
+
...keystoreWarnings({ restrictions, customArgs }),
|
|
129
|
+
];
|
|
130
|
+
if (report.source.browser !== snapshotBrowserForChannel(channel)) {
|
|
131
|
+
warnings.push({
|
|
132
|
+
item: channel,
|
|
133
|
+
reason: 'browser-mismatch',
|
|
134
|
+
detail: `A ${report.source.browser} profile launched in ${channel} cannot decrypt its cookies and saved passwords`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
userDataDir: report.target,
|
|
139
|
+
args: profile === 'Default' ? [] : [`--profile-directory=${profile}`],
|
|
140
|
+
attach: {
|
|
141
|
+
mode: 'snapshot',
|
|
142
|
+
snapshot: { ...report, warnings },
|
|
143
|
+
differences: describeAttachDifferences('snapshot'),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|