browser-commander 0.12.0 → 0.13.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 46d0049: Add CDP attachment through `connectBrowser()` for Playwright and Puppeteer, plus `launchAndConnectRealBrowser()` for starting an installed Chrome-family browser with a dedicated automation profile.
8
+
3
9
  ## 0.12.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -106,6 +106,41 @@ const { browser, page } = await launchBrowser({
106
106
  });
107
107
  ```
108
108
 
109
+ Attach to a Chrome-family browser that is already listening for CDP connections:
110
+
111
+ ```javascript
112
+ import { connectBrowser, makeBrowserCommander } from 'browser-commander';
113
+
114
+ const { browser, page } = await connectBrowser({
115
+ engine: 'playwright', // or 'puppeteer'
116
+ cdpEndpoint: 'http://127.0.0.1:9222',
117
+ });
118
+ const commander = makeBrowserCommander({ page });
119
+ ```
120
+
121
+ `launchAndConnectRealBrowser()` can find and start a genuine installed Chrome,
122
+ Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It
123
+ always uses a dedicated profile; Chrome 136 and newer do not honor remote
124
+ debugging switches for the default profile. See the
125
+ [Chrome remote-debugging security change](https://developer.chrome.com/blog/remote-debugging-port).
126
+
127
+ ```javascript
128
+ import { launchAndConnectRealBrowser } from 'browser-commander';
129
+
130
+ const connection = await launchAndConnectRealBrowser({
131
+ engine: 'puppeteer',
132
+ channel: 'chrome',
133
+ userDataDir: '/tmp/my-automation-profile',
134
+ seedCookies: [
135
+ { name: 'session', value: 'saved', url: 'https://example.com' },
136
+ ],
137
+ });
138
+ await connection.browser.close();
139
+ ```
140
+
141
+ Cookie seeding copies only cookies you explicitly provide; the helper does not
142
+ read, decrypt, or expose cookies from a browser's default profile.
143
+
109
144
  Reuse a saved authenticated session by passing Playwright-compatible storage
110
145
  state as a JSON file path or object. Cookies and localStorage are restored for
111
146
  both engines:
@@ -303,6 +338,38 @@ The `storageState` option accepts a Playwright-compatible JSON path or object.
303
338
  Each engine restores its cookies and origin-specific localStorage before
304
339
  navigation, including when Playwright uses a persistent context.
305
340
 
341
+ ### connectBrowser(options)
342
+
343
+ Connect to an existing Chrome-family browser over an HTTP or WebSocket CDP
344
+ endpoint. Exactly one of `cdpEndpoint` and `wsEndpoint` is required. The raw
345
+ `browser` and `page` work with both the underlying engine API and
346
+ `makeBrowserCommander({ page })`.
347
+
348
+ ```javascript
349
+ const { browser, page } = await connectBrowser({
350
+ engine: 'playwright',
351
+ wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/<id>',
352
+ timeout: 30_000,
353
+ seedCookies: [
354
+ { name: 'session', value: 'saved', url: 'https://example.com' },
355
+ ],
356
+ });
357
+ ```
358
+
359
+ Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`.
360
+ `storageState` can also seed Playwright-compatible cookies and localStorage.
361
+
362
+ ### launchAndConnectRealBrowser(options)
363
+
364
+ Start an installed browser and connect through `connectBrowser()`. Use
365
+ `channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`,
366
+ `msedge-beta`, `msedge-dev`, `msedge-canary`, `brave`, or `chromium`) or an
367
+ explicit `executablePath`. The helper defaults to
368
+ a managed directory under `~/.browser-commander/real-browser/`, rejects known
369
+ default browser-profile paths, protects its remote-debugging arguments, and
370
+ returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path,
371
+ and profile path alongside `{ browser, page }`.
372
+
306
373
  ### saveStorageState(page, filePath)
307
374
 
308
375
  Save the current cookies and localStorage in Playwright's portable storage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,163 @@
1
+ import {
2
+ loadStorageState,
3
+ restorePlaywrightStorageState,
4
+ restorePuppeteerStorageState,
5
+ } from './storage-state.js';
6
+
7
+ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
8
+ if (!['playwright', 'puppeteer'].includes(engine)) {
9
+ throw new Error(
10
+ `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
11
+ );
12
+ }
13
+
14
+ if (Boolean(cdpEndpoint) === Boolean(wsEndpoint)) {
15
+ throw new Error(
16
+ 'connectBrowser requires exactly one of cdpEndpoint or wsEndpoint'
17
+ );
18
+ }
19
+ }
20
+
21
+ function addDefinedOptions(options, values) {
22
+ for (const [key, value] of Object.entries(values)) {
23
+ if (value !== undefined) {
24
+ options[key] = value;
25
+ }
26
+ }
27
+ return options;
28
+ }
29
+
30
+ function buildPlaywrightConnectOptions({ slowMo, timeout, headers }) {
31
+ return addDefinedOptions({}, { slowMo, timeout, headers });
32
+ }
33
+
34
+ function buildPuppeteerConnectOptions({
35
+ cdpEndpoint,
36
+ wsEndpoint,
37
+ slowMo,
38
+ protocolTimeout,
39
+ headers,
40
+ }) {
41
+ return addDefinedOptions(
42
+ {
43
+ ...(cdpEndpoint
44
+ ? { browserURL: cdpEndpoint }
45
+ : { browserWSEndpoint: wsEndpoint }),
46
+ defaultViewport: null,
47
+ },
48
+ { slowMo, protocolTimeout, headers }
49
+ );
50
+ }
51
+
52
+ async function prepareStorageState({ storageState, seedCookies }) {
53
+ if (seedCookies !== undefined && !Array.isArray(seedCookies)) {
54
+ throw new TypeError('seedCookies must be an array');
55
+ }
56
+
57
+ const resolvedStorageState = await loadStorageState(storageState);
58
+ if (!resolvedStorageState && seedCookies === undefined) {
59
+ return undefined;
60
+ }
61
+
62
+ return {
63
+ ...(resolvedStorageState ?? {}),
64
+ cookies: [...(resolvedStorageState?.cookies ?? []), ...(seedCookies ?? [])],
65
+ };
66
+ }
67
+
68
+ async function connectPlaywright({ options, loadPlaywright, storageState }) {
69
+ const { chromium } = await loadPlaywright();
70
+ const endpoint = options.cdpEndpoint ?? options.wsEndpoint;
71
+ const browser = await chromium.connectOverCDP(
72
+ endpoint,
73
+ buildPlaywrightConnectOptions(options)
74
+ );
75
+ const context = browser.contexts()[0];
76
+ if (!context) {
77
+ throw new Error('Connected Playwright browser has no default context');
78
+ }
79
+ const page = context.pages()[0] ?? (await context.newPage());
80
+
81
+ await restorePlaywrightStorageState({ context, storageState });
82
+ return { browser, page };
83
+ }
84
+
85
+ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
86
+ const puppeteerModule = await loadPuppeteer();
87
+ const puppeteer = puppeteerModule.default ?? puppeteerModule;
88
+ const browser = await puppeteer.connect(
89
+ buildPuppeteerConnectOptions(options)
90
+ );
91
+ const pages = await browser.pages();
92
+ const page = pages[0] ?? (await browser.newPage());
93
+
94
+ await restorePuppeteerStorageState({ page, storageState });
95
+ return { browser, page };
96
+ }
97
+
98
+ /**
99
+ * Attach to an already-running Chromium-family browser over CDP.
100
+ *
101
+ * @param {Object} options - Connection options
102
+ * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
103
+ * @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222
104
+ * @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint
105
+ * @param {number} [options.slowMo] - Delay engine operations by this many milliseconds
106
+ * @param {number} [options.timeout] - Playwright connection timeout in milliseconds
107
+ * @param {number} [options.protocolTimeout] - Puppeteer CDP call timeout in milliseconds
108
+ * @param {Object<string,string>} [options.headers] - Additional connection headers
109
+ * @param {Object[]|string|Object} [options.storageState] - Playwright-compatible state path or object
110
+ * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
111
+ * @param {boolean} [options.verbose=false] - Enable connection logging
112
+ * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
113
+ */
114
+ export async function connectBrowser(options = {}) {
115
+ return await connectBrowserWithDependencies(options);
116
+ }
117
+
118
+ /**
119
+ * Dependency-injected implementation used by the public connector and tests.
120
+ *
121
+ * @param {Object} options - See {@link connectBrowser}
122
+ * @param {Object} dependencies - Optional engine module loaders
123
+ * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
124
+ */
125
+ export async function connectBrowserWithDependencies(
126
+ options = {},
127
+ dependencies = {}
128
+ ) {
129
+ const normalizedOptions = {
130
+ engine: 'playwright',
131
+ verbose: false,
132
+ ...options,
133
+ };
134
+ validateConnectionOptions(normalizedOptions);
135
+
136
+ const storageState = await prepareStorageState(normalizedOptions);
137
+ const { engine, verbose } = normalizedOptions;
138
+ if (verbose) {
139
+ console.log(`Connecting to browser with ${engine} engine...`);
140
+ }
141
+
142
+ const result =
143
+ engine === 'playwright'
144
+ ? await connectPlaywright({
145
+ options: normalizedOptions,
146
+ loadPlaywright:
147
+ dependencies.loadPlaywright ?? (() => import('playwright')),
148
+ storageState,
149
+ })
150
+ : await connectPuppeteer({
151
+ options: normalizedOptions,
152
+ loadPuppeteer:
153
+ dependencies.loadPuppeteer ?? (() => import('puppeteer')),
154
+ storageState,
155
+ });
156
+
157
+ if (verbose) {
158
+ console.log(`Connected to browser with ${engine} engine`);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ export { buildPlaywrightConnectOptions, buildPuppeteerConnectOptions };
@@ -0,0 +1,439 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, mkdir, readFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { connectBrowser } from './connector.js';
8
+
9
+ const MANAGED_ARGUMENTS = [
10
+ '--remote-debugging-address',
11
+ '--remote-debugging-port',
12
+ '--user-data-dir',
13
+ ];
14
+
15
+ const CHANNEL_EXECUTABLE_NAMES = {
16
+ brave: ['brave-browser', 'brave-browser-stable', 'brave'],
17
+ chrome: ['google-chrome', 'google-chrome-stable', 'chrome'],
18
+ 'chrome-beta': ['google-chrome-beta'],
19
+ 'chrome-canary': ['google-chrome-canary'],
20
+ 'chrome-dev': ['google-chrome-unstable'],
21
+ chromium: ['chromium', 'chromium-browser'],
22
+ msedge: ['microsoft-edge', 'microsoft-edge-stable', 'msedge'],
23
+ 'msedge-beta': ['microsoft-edge-beta'],
24
+ 'msedge-canary': ['microsoft-edge-canary'],
25
+ 'msedge-dev': ['microsoft-edge-dev'],
26
+ };
27
+
28
+ function platformPath(platform) {
29
+ return platform === 'win32' ? path.win32 : path;
30
+ }
31
+
32
+ function defaultUserDataDir(channel, homeDir = os.homedir()) {
33
+ const directoryName = channel.replace(/[^a-z0-9_.-]/gi, '-');
34
+ return path.join(
35
+ homeDir,
36
+ '.browser-commander',
37
+ 'real-browser',
38
+ directoryName
39
+ );
40
+ }
41
+
42
+ function knownDefaultUserDataDirs({
43
+ platform = process.platform,
44
+ homeDir = os.homedir(),
45
+ environment = process.env,
46
+ } = {}) {
47
+ const pathApi = platformPath(platform);
48
+ if (platform === 'darwin') {
49
+ const applicationSupport = pathApi.join(
50
+ homeDir,
51
+ 'Library',
52
+ 'Application Support'
53
+ );
54
+ return [
55
+ pathApi.join(applicationSupport, 'Google', 'Chrome'),
56
+ pathApi.join(applicationSupport, 'Google', 'Chrome Beta'),
57
+ pathApi.join(applicationSupport, 'Google', 'Chrome Canary'),
58
+ pathApi.join(applicationSupport, 'Google', 'Chrome Dev'),
59
+ pathApi.join(applicationSupport, 'Chromium'),
60
+ pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser'),
61
+ pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser-Beta'),
62
+ pathApi.join(
63
+ applicationSupport,
64
+ 'BraveSoftware',
65
+ 'Brave-Browser-Nightly'
66
+ ),
67
+ pathApi.join(applicationSupport, 'Microsoft Edge'),
68
+ pathApi.join(applicationSupport, 'Microsoft Edge Beta'),
69
+ pathApi.join(applicationSupport, 'Microsoft Edge Canary'),
70
+ pathApi.join(applicationSupport, 'Microsoft Edge Dev'),
71
+ ];
72
+ }
73
+ if (platform === 'win32') {
74
+ const localAppData =
75
+ environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local');
76
+ return [
77
+ pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'),
78
+ pathApi.join(localAppData, 'Google', 'Chrome Beta', 'User Data'),
79
+ pathApi.join(localAppData, 'Google', 'Chrome Dev', 'User Data'),
80
+ pathApi.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
81
+ pathApi.join(localAppData, 'Chromium', 'User Data'),
82
+ pathApi.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'),
83
+ pathApi.join(
84
+ localAppData,
85
+ 'BraveSoftware',
86
+ 'Brave-Browser-Beta',
87
+ 'User Data'
88
+ ),
89
+ pathApi.join(
90
+ localAppData,
91
+ 'BraveSoftware',
92
+ 'Brave-Browser-Nightly',
93
+ 'User Data'
94
+ ),
95
+ pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'),
96
+ pathApi.join(localAppData, 'Microsoft', 'Edge Beta', 'User Data'),
97
+ pathApi.join(localAppData, 'Microsoft', 'Edge Dev', 'User Data'),
98
+ pathApi.join(localAppData, 'Microsoft', 'Edge SxS', 'User Data'),
99
+ ];
100
+ }
101
+ return [
102
+ pathApi.join(homeDir, '.config', 'google-chrome'),
103
+ pathApi.join(homeDir, '.config', 'google-chrome-beta'),
104
+ pathApi.join(homeDir, '.config', 'google-chrome-unstable'),
105
+ pathApi.join(homeDir, '.config', 'chromium'),
106
+ pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'),
107
+ pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Beta'),
108
+ pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Nightly'),
109
+ pathApi.join(homeDir, '.config', 'microsoft-edge'),
110
+ pathApi.join(homeDir, '.config', 'microsoft-edge-beta'),
111
+ pathApi.join(homeDir, '.config', 'microsoft-edge-dev'),
112
+ ];
113
+ }
114
+
115
+ /** Ensure Chrome is not asked to expose the user's default profile over CDP. */
116
+ export function assertDedicatedUserDataDir(userDataDir, platformOptions = {}) {
117
+ if (!userDataDir) {
118
+ throw new Error('launchAndConnectRealBrowser requires a userDataDir');
119
+ }
120
+ const platform = platformOptions.platform ?? process.platform;
121
+ const pathApi = platformPath(platform);
122
+ const normalize = (value) => {
123
+ const resolved = pathApi.resolve(value).replace(/[\\/]+$/, '');
124
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
125
+ };
126
+ const requested = normalize(userDataDir);
127
+ const isDefault = knownDefaultUserDataDirs(platformOptions).some(
128
+ (directory) => normalize(directory) === requested
129
+ );
130
+ if (isDefault) {
131
+ throw new Error(
132
+ 'launchAndConnectRealBrowser requires a dedicated userDataDir, not a browser default profile'
133
+ );
134
+ }
135
+ }
136
+
137
+ function browserInstallCandidates({
138
+ channel,
139
+ platform = process.platform,
140
+ environment = process.env,
141
+ }) {
142
+ const candidates = [];
143
+ const names = CHANNEL_EXECUTABLE_NAMES[channel];
144
+ if (!names) {
145
+ throw new Error(
146
+ `Unknown browser channel: ${channel}. Expected one of ${Object.keys(CHANNEL_EXECUTABLE_NAMES).join(', ')}`
147
+ );
148
+ }
149
+
150
+ if (platform === 'darwin') {
151
+ const applications = {
152
+ brave: 'Brave Browser.app/Contents/MacOS/Brave Browser',
153
+ chrome: 'Google Chrome.app/Contents/MacOS/Google Chrome',
154
+ 'chrome-beta': 'Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
155
+ 'chrome-canary':
156
+ 'Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
157
+ 'chrome-dev': 'Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev',
158
+ chromium: 'Chromium.app/Contents/MacOS/Chromium',
159
+ msedge: 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
160
+ 'msedge-beta':
161
+ 'Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
162
+ 'msedge-canary':
163
+ 'Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary',
164
+ 'msedge-dev': 'Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev',
165
+ };
166
+ candidates.push(path.join('/Applications', applications[channel]));
167
+ } else if (platform === 'win32') {
168
+ const roots = [
169
+ environment.PROGRAMFILES,
170
+ environment['PROGRAMFILES(X86)'],
171
+ environment.LOCALAPPDATA,
172
+ ].filter(Boolean);
173
+ const relativePaths = {
174
+ brave: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
175
+ chrome: ['Google', 'Chrome', 'Application', 'chrome.exe'],
176
+ 'chrome-beta': ['Google', 'Chrome Beta', 'Application', 'chrome.exe'],
177
+ 'chrome-canary': ['Google', 'Chrome SxS', 'Application', 'chrome.exe'],
178
+ 'chrome-dev': ['Google', 'Chrome Dev', 'Application', 'chrome.exe'],
179
+ chromium: ['Chromium', 'Application', 'chrome.exe'],
180
+ msedge: ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
181
+ 'msedge-beta': ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'],
182
+ 'msedge-canary': ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'],
183
+ 'msedge-dev': ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'],
184
+ };
185
+ for (const root of roots) {
186
+ candidates.push(path.win32.join(root, ...relativePaths[channel]));
187
+ }
188
+ } else {
189
+ for (const name of names) {
190
+ candidates.push(`/usr/bin/${name}`, `/usr/local/bin/${name}`);
191
+ }
192
+ if (channel === 'chrome') {
193
+ candidates.push('/opt/google/chrome/google-chrome');
194
+ }
195
+ }
196
+
197
+ const pathApi = platformPath(platform);
198
+ const delimiter = platform === 'win32' ? ';' : path.delimiter;
199
+ for (const directory of (environment.PATH ?? '').split(delimiter)) {
200
+ if (!directory) {
201
+ continue;
202
+ }
203
+ for (const name of names) {
204
+ candidates.push(
205
+ pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name)
206
+ );
207
+ }
208
+ }
209
+ return [...new Set(candidates)];
210
+ }
211
+
212
+ /** Resolve a genuine installed Chrome-family browser executable. */
213
+ export async function resolveSystemBrowserExecutable({
214
+ channel = 'chrome',
215
+ executablePath,
216
+ platform = process.platform,
217
+ environment = process.env,
218
+ } = {}) {
219
+ const candidates = executablePath
220
+ ? [path.resolve(executablePath)]
221
+ : browserInstallCandidates({ channel, platform, environment });
222
+ for (const candidate of candidates) {
223
+ try {
224
+ await access(candidate, constants.X_OK);
225
+ return candidate;
226
+ } catch {
227
+ // Continue through known locations and PATH entries.
228
+ }
229
+ }
230
+ throw new Error(
231
+ executablePath
232
+ ? `Browser executable is not accessible: ${executablePath}`
233
+ : `Could not find an installed ${channel} browser; provide executablePath`
234
+ );
235
+ }
236
+
237
+ /** Build the protected command line used for a real browser CDP process. */
238
+ export function buildRealBrowserArgs({
239
+ userDataDir,
240
+ remoteDebuggingPort = 0,
241
+ headless = false,
242
+ args = [],
243
+ }) {
244
+ if (
245
+ !Number.isInteger(remoteDebuggingPort) ||
246
+ remoteDebuggingPort < 0 ||
247
+ remoteDebuggingPort > 65_535
248
+ ) {
249
+ throw new RangeError(
250
+ 'remoteDebuggingPort must be an integer from 0 to 65535'
251
+ );
252
+ }
253
+ const conflictingArgument = args.find((argument) =>
254
+ MANAGED_ARGUMENTS.some(
255
+ (managed) => argument === managed || argument.startsWith(`${managed}=`)
256
+ )
257
+ );
258
+ if (conflictingArgument) {
259
+ throw new Error(
260
+ `${conflictingArgument} is managed by launchAndConnectRealBrowser`
261
+ );
262
+ }
263
+
264
+ return [
265
+ '--remote-debugging-address=127.0.0.1',
266
+ `--remote-debugging-port=${remoteDebuggingPort}`,
267
+ `--user-data-dir=${userDataDir}`,
268
+ '--no-first-run',
269
+ '--no-default-browser-check',
270
+ ...(headless ? ['--headless=new'] : []),
271
+ ...args,
272
+ ];
273
+ }
274
+
275
+ async function fetchCdpVersion(endpoint, fetchImplementation, timeout) {
276
+ const controller = new AbortController();
277
+ const timer = setTimeout(() => controller.abort(), Math.min(timeout, 500));
278
+ try {
279
+ const response = await fetchImplementation(`${endpoint}/json/version`, {
280
+ signal: controller.signal,
281
+ });
282
+ if (!response.ok) {
283
+ return false;
284
+ }
285
+ const version = await response.json();
286
+ return Boolean(version.webSocketDebuggerUrl);
287
+ } finally {
288
+ clearTimeout(timer);
289
+ }
290
+ }
291
+
292
+ /** Wait until Chrome publishes a usable DevTools endpoint. */
293
+ export async function waitForCdpEndpoint({
294
+ remoteDebuggingPort,
295
+ userDataDir,
296
+ browserProcess,
297
+ timeout = 30_000,
298
+ fetchImplementation = globalThis.fetch,
299
+ }) {
300
+ const deadline = Date.now() + timeout;
301
+ while (Date.now() < deadline) {
302
+ if (browserProcess.exitCode !== null) {
303
+ throw new Error(
304
+ `Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
305
+ );
306
+ }
307
+
308
+ let port = remoteDebuggingPort;
309
+ if (port === 0) {
310
+ try {
311
+ const activePort = await readFile(
312
+ path.join(userDataDir, 'DevToolsActivePort'),
313
+ 'utf8'
314
+ );
315
+ port = Number.parseInt(activePort.split(/\r?\n/, 1)[0], 10);
316
+ } catch {
317
+ port = 0;
318
+ }
319
+ }
320
+
321
+ if (port > 0) {
322
+ const endpoint = `http://127.0.0.1:${port}`;
323
+ try {
324
+ if (
325
+ await fetchCdpVersion(
326
+ endpoint,
327
+ fetchImplementation,
328
+ Math.max(1, deadline - Date.now())
329
+ )
330
+ ) {
331
+ return endpoint;
332
+ }
333
+ } catch {
334
+ // Chrome may have allocated the port before /json/version is ready.
335
+ }
336
+ }
337
+ await new Promise((resolve) => setTimeout(resolve, 100));
338
+ }
339
+ throw new Error(
340
+ `Timed out after ${timeout}ms waiting for the DevTools endpoint`
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Spawn a genuine installed Chrome-family browser with an isolated profile,
346
+ * wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
347
+ *
348
+ * @param {Object} options - Launch and connection options
349
+ * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
350
+ * @param {string} [options.channel='chrome'] - Installed Chrome-family channel
351
+ * @param {string} [options.executablePath] - Explicit installed browser executable
352
+ * @param {string} [options.userDataDir] - Dedicated non-default browser profile
353
+ * @param {number} [options.remoteDebuggingPort=0] - Loopback CDP port; zero lets Chrome choose
354
+ * @param {boolean} [options.headless=false] - Run the installed browser headlessly
355
+ * @param {string[]} [options.args] - Additional browser arguments
356
+ * @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds
357
+ * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
358
+ * @param {boolean} [options.verbose=false] - Show browser and connection logs
359
+ * @returns {Promise<{browser: Object, page: Object, browserProcess: Object, cdpEndpoint: string, executablePath: string, userDataDir: string}>} Connected handles and process metadata
360
+ */
361
+ export async function launchAndConnectRealBrowser(options = {}) {
362
+ return await launchAndConnectRealBrowserWithDependencies(options);
363
+ }
364
+
365
+ /** Dependency-injected implementation used by the public helper and tests. */
366
+ export async function launchAndConnectRealBrowserWithDependencies(
367
+ options = {},
368
+ dependencies = {}
369
+ ) {
370
+ const {
371
+ engine = 'playwright',
372
+ channel = 'chrome',
373
+ executablePath: requestedExecutablePath,
374
+ userDataDir = defaultUserDataDir(channel),
375
+ remoteDebuggingPort = 0,
376
+ headless = false,
377
+ args = [],
378
+ startupTimeout = 30_000,
379
+ verbose = false,
380
+ cdpEndpoint,
381
+ wsEndpoint,
382
+ ...connectionOptions
383
+ } = options;
384
+ if (cdpEndpoint || wsEndpoint) {
385
+ throw new Error(
386
+ 'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
387
+ );
388
+ }
389
+
390
+ assertDedicatedUserDataDir(userDataDir);
391
+ await mkdir(userDataDir, { recursive: true });
392
+ const resolveExecutable =
393
+ dependencies.resolveExecutable ?? resolveSystemBrowserExecutable;
394
+ const resolvedExecutablePath = await resolveExecutable({
395
+ channel,
396
+ executablePath: requestedExecutablePath,
397
+ });
398
+ const browserArgs = buildRealBrowserArgs({
399
+ userDataDir,
400
+ remoteDebuggingPort,
401
+ headless,
402
+ args,
403
+ });
404
+ const spawnBrowser = dependencies.spawnBrowser ?? spawn;
405
+ const browserProcess = spawnBrowser(resolvedExecutablePath, browserArgs, {
406
+ stdio: verbose ? 'inherit' : 'ignore',
407
+ });
408
+
409
+ try {
410
+ const waitForEndpoint = dependencies.waitForEndpoint ?? waitForCdpEndpoint;
411
+ const resolvedCdpEndpoint = await waitForEndpoint({
412
+ remoteDebuggingPort,
413
+ userDataDir,
414
+ browserProcess,
415
+ timeout: startupTimeout,
416
+ });
417
+ const connect = dependencies.connect ?? connectBrowser;
418
+ const connection = await connect({
419
+ engine,
420
+ cdpEndpoint: resolvedCdpEndpoint,
421
+ ...connectionOptions,
422
+ verbose,
423
+ });
424
+ return {
425
+ ...connection,
426
+ browserProcess,
427
+ cdpEndpoint: resolvedCdpEndpoint,
428
+ executablePath: resolvedExecutablePath,
429
+ userDataDir,
430
+ };
431
+ } catch (error) {
432
+ if (browserProcess.exitCode === null) {
433
+ browserProcess.kill();
434
+ }
435
+ throw error;
436
+ }
437
+ }
438
+
439
+ export { defaultUserDataDir, knownDefaultUserDataDirs };
package/src/exports.js CHANGED
@@ -42,6 +42,8 @@ export {
42
42
  } from './core/page-trigger-manager.js';
43
43
 
44
44
  // Re-export browser management
45
+ export { connectBrowser } from './browser/connector.js';
46
+ export { launchAndConnectRealBrowser } from './browser/real-browser.js';
45
47
  export { launchBrowser } from './browser/launcher.js';
46
48
  export { saveStorageState } from './browser/storage-state.js';
47
49
  export { emulateMedia } from './browser/media.js';
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import {
5
+ connectBrowser,
6
+ connectBrowserWithDependencies,
7
+ } from '../../../src/browser/connector.js';
8
+ import { connectBrowser as publicConnectBrowser } from '../../../src/index.js';
9
+
10
+ describe('connectBrowser', () => {
11
+ it('is exported from the package API', () => {
12
+ assert.equal(publicConnectBrowser, connectBrowser);
13
+ });
14
+
15
+ it('attaches Playwright over an HTTP CDP endpoint and reuses its first page', async () => {
16
+ const calls = [];
17
+ const page = { id: 'playwright-page' };
18
+ const context = {
19
+ pages: () => [page],
20
+ addCookies: async (cookies) => calls.push(['cookies', cookies]),
21
+ };
22
+ const browser = { contexts: () => [context] };
23
+ const chromium = {
24
+ connectOverCDP: async (endpoint, options) => {
25
+ calls.push(['connect', endpoint, options]);
26
+ return browser;
27
+ },
28
+ };
29
+ const seedCookies = [
30
+ { name: 'SID', value: 'saved', domain: '.example.com', path: '/' },
31
+ ];
32
+
33
+ const result = await connectBrowserWithDependencies(
34
+ {
35
+ engine: 'playwright',
36
+ cdpEndpoint: 'http://127.0.0.1:9222',
37
+ slowMo: 25,
38
+ timeout: 5_000,
39
+ seedCookies,
40
+ },
41
+ { loadPlaywright: async () => ({ chromium }) }
42
+ );
43
+
44
+ assert.equal(result.browser, browser);
45
+ assert.equal(result.page, page);
46
+ assert.deepEqual(calls, [
47
+ ['connect', 'http://127.0.0.1:9222', { slowMo: 25, timeout: 5_000 }],
48
+ ['cookies', seedCookies],
49
+ ]);
50
+ });
51
+
52
+ it('maps a WebSocket endpoint to Puppeteer and creates a page when needed', async () => {
53
+ const calls = [];
54
+ const page = {
55
+ id: 'new-puppeteer-page',
56
+ setCookie: async (...cookies) => calls.push(['cookies', cookies]),
57
+ };
58
+ const browser = {
59
+ pages: async () => [],
60
+ newPage: async () => {
61
+ calls.push(['newPage']);
62
+ return page;
63
+ },
64
+ };
65
+ const puppeteer = {
66
+ connect: async (options) => {
67
+ calls.push(['connect', options]);
68
+ return browser;
69
+ },
70
+ };
71
+ const seedCookies = [
72
+ { name: 'session', value: 'saved', domain: '.example.com', path: '/' },
73
+ ];
74
+
75
+ const result = await connectBrowserWithDependencies(
76
+ {
77
+ engine: 'puppeteer',
78
+ wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id',
79
+ slowMo: 10,
80
+ protocolTimeout: 30_000,
81
+ seedCookies,
82
+ },
83
+ { loadPuppeteer: async () => ({ default: puppeteer }) }
84
+ );
85
+
86
+ assert.equal(result.browser, browser);
87
+ assert.equal(result.page, page);
88
+ assert.deepEqual(calls, [
89
+ [
90
+ 'connect',
91
+ {
92
+ browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id',
93
+ defaultViewport: null,
94
+ slowMo: 10,
95
+ protocolTimeout: 30_000,
96
+ },
97
+ ],
98
+ ['newPage'],
99
+ ['cookies', seedCookies],
100
+ ]);
101
+ });
102
+
103
+ it('requires exactly one endpoint and a supported engine', async () => {
104
+ await assert.rejects(
105
+ () => connectBrowserWithDependencies({ engine: 'playwright' }, {}),
106
+ /exactly one of cdpEndpoint or wsEndpoint/
107
+ );
108
+ await assert.rejects(
109
+ () =>
110
+ connectBrowserWithDependencies(
111
+ {
112
+ engine: 'playwright',
113
+ cdpEndpoint: 'http://127.0.0.1:9222',
114
+ wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id',
115
+ },
116
+ {}
117
+ ),
118
+ /exactly one of cdpEndpoint or wsEndpoint/
119
+ );
120
+ await assert.rejects(
121
+ () =>
122
+ connectBrowserWithDependencies(
123
+ { engine: 'invalid', cdpEndpoint: 'http://127.0.0.1:9222' },
124
+ {}
125
+ ),
126
+ /Invalid engine: invalid/
127
+ );
128
+ });
129
+ });
@@ -0,0 +1,165 @@
1
+ import assert from 'node:assert';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, it } from 'node:test';
6
+
7
+ import {
8
+ assertDedicatedUserDataDir,
9
+ buildRealBrowserArgs,
10
+ launchAndConnectRealBrowser,
11
+ launchAndConnectRealBrowserWithDependencies,
12
+ } from '../../../src/browser/real-browser.js';
13
+ import { launchAndConnectRealBrowser as publicHelper } from '../../../src/index.js';
14
+
15
+ describe('launchAndConnectRealBrowser', () => {
16
+ let temporaryDirectory;
17
+
18
+ afterEach(async () => {
19
+ if (temporaryDirectory) {
20
+ await rm(temporaryDirectory, { recursive: true, force: true });
21
+ temporaryDirectory = undefined;
22
+ }
23
+ });
24
+
25
+ it('is exported from the package API', () => {
26
+ assert.equal(publicHelper, launchAndConnectRealBrowser);
27
+ });
28
+
29
+ it('builds a loopback-only CDP command with a dedicated profile', () => {
30
+ const args = buildRealBrowserArgs({
31
+ userDataDir: '/tmp/browser-commander-dedicated',
32
+ remoteDebuggingPort: 9333,
33
+ headless: true,
34
+ args: ['--lang=en-US'],
35
+ });
36
+
37
+ assert.deepEqual(args, [
38
+ '--remote-debugging-address=127.0.0.1',
39
+ '--remote-debugging-port=9333',
40
+ '--user-data-dir=/tmp/browser-commander-dedicated',
41
+ '--no-first-run',
42
+ '--no-default-browser-check',
43
+ '--headless=new',
44
+ '--lang=en-US',
45
+ ]);
46
+ });
47
+
48
+ it('rejects custom arguments that could bypass protected CDP settings', () => {
49
+ for (const argument of [
50
+ '--remote-debugging-address=0.0.0.0',
51
+ '--remote-debugging-port=9222',
52
+ '--user-data-dir=/tmp/other',
53
+ ]) {
54
+ assert.throws(
55
+ () =>
56
+ buildRealBrowserArgs({
57
+ userDataDir: '/tmp/browser-commander-dedicated',
58
+ remoteDebuggingPort: 0,
59
+ args: [argument],
60
+ }),
61
+ /managed by launchAndConnectRealBrowser/
62
+ );
63
+ }
64
+ });
65
+
66
+ it('rejects Chrome default user-data directories', () => {
67
+ for (const profile of ['google-chrome', 'google-chrome-beta']) {
68
+ assert.throws(
69
+ () =>
70
+ assertDedicatedUserDataDir(
71
+ path.join(os.homedir(), '.config', profile),
72
+ {
73
+ platform: 'linux',
74
+ homeDir: os.homedir(),
75
+ environment: {},
76
+ }
77
+ ),
78
+ /dedicated userDataDir/
79
+ );
80
+ }
81
+ });
82
+
83
+ it('spawns, waits, connects, and returns process metadata', async () => {
84
+ temporaryDirectory = await mkdtemp(
85
+ path.join(os.tmpdir(), 'browser-commander-real-browser-test-')
86
+ );
87
+ const calls = [];
88
+ const browserProcess = { exitCode: null, kill: () => calls.push(['kill']) };
89
+ const browser = { id: 'browser' };
90
+ const page = { id: 'page' };
91
+
92
+ const result = await launchAndConnectRealBrowserWithDependencies(
93
+ {
94
+ engine: 'puppeteer',
95
+ channel: 'chrome',
96
+ userDataDir: temporaryDirectory,
97
+ remoteDebuggingPort: 0,
98
+ seedCookies: [{ name: 'SID', value: 'saved' }],
99
+ },
100
+ {
101
+ resolveExecutable: async () => '/opt/google/chrome',
102
+ spawnBrowser: (executablePath, args) => {
103
+ calls.push(['spawn', executablePath, args]);
104
+ return browserProcess;
105
+ },
106
+ waitForEndpoint: async (options) => {
107
+ calls.push(['wait', options.remoteDebuggingPort]);
108
+ return 'http://127.0.0.1:9444';
109
+ },
110
+ connect: async (options) => {
111
+ calls.push(['connect', options]);
112
+ return { browser, page };
113
+ },
114
+ }
115
+ );
116
+
117
+ assert.equal(result.browser, browser);
118
+ assert.equal(result.page, page);
119
+ assert.equal(result.browserProcess, browserProcess);
120
+ assert.equal(result.cdpEndpoint, 'http://127.0.0.1:9444');
121
+ assert.equal(result.executablePath, '/opt/google/chrome');
122
+ assert.equal(result.userDataDir, temporaryDirectory);
123
+ assert.deepEqual(calls[1], ['wait', 0]);
124
+ assert.deepEqual(calls[2], [
125
+ 'connect',
126
+ {
127
+ engine: 'puppeteer',
128
+ cdpEndpoint: 'http://127.0.0.1:9444',
129
+ seedCookies: [{ name: 'SID', value: 'saved' }],
130
+ verbose: false,
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it('terminates the spawned browser when connection fails', async () => {
136
+ temporaryDirectory = await mkdtemp(
137
+ path.join(os.tmpdir(), 'browser-commander-real-browser-test-')
138
+ );
139
+ let killed = false;
140
+ const browserProcess = {
141
+ exitCode: null,
142
+ kill: () => {
143
+ killed = true;
144
+ },
145
+ };
146
+
147
+ await assert.rejects(
148
+ () =>
149
+ launchAndConnectRealBrowserWithDependencies(
150
+ { userDataDir: temporaryDirectory },
151
+ {
152
+ resolveExecutable: async () => '/opt/google/chrome',
153
+ spawnBrowser: () => browserProcess,
154
+ waitForEndpoint: async () => 'http://127.0.0.1:9222',
155
+ connect: async () => {
156
+ throw new Error('connection failed');
157
+ },
158
+ }
159
+ ),
160
+ /connection failed/
161
+ );
162
+
163
+ assert.equal(killed, true);
164
+ });
165
+ });