browser-commander 0.31.2 → 0.32.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 +6 -0
- package/README.md +26 -0
- package/package.json +1 -1
- package/src/browser/browser-handoff.js +304 -0
- package/src/browser/cdp-browser-endpoint.js +55 -0
- package/src/browser/launcher.js +8 -0
- package/src/browser/persistent-session.js +5 -56
- package/src/browser/real-browser.js +56 -5
- package/src/browser/session-persistence.js +9 -5
- package/src/browser/webdriver.js +8 -1
- package/src/exports.js +6 -0
- package/src/utilities/subprocess.js +199 -45
- package/tests/e2e/browser-handoff.e2e.test.js +94 -0
- package/tests/helpers/fake-cdp-endpoint.js +64 -0
- package/tests/helpers/temp-directory.js +25 -0
- package/tests/unit/browser/browser-handoff.test.js +406 -0
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +6 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dd4b3d5: Let an embedding supervisor own Ctrl+C and keep a launched browser across its own restarts (#174). `interruption: 'caller'` on `launchBrowser`, `launchRealBrowser`, `launchWebDriver` and `startProcess` stops SIGINT from being forwarded to the browser and its ChromeDriver and turns off the engines' `handleSIGINT`. A real launch's `detach()` and `handoff()` return a JSON hand-off naming the exact browser instance and tab, and `reconnectBrowser(handoff)` reattaches to that tab without launching or navigating, refusing a replaced browser or a closed tab with `BrowserHandoffError`. `close()` on either session closes the browser, waits for its process to exit and removes a temporary profile.
|
|
8
|
+
|
|
3
9
|
## 0.31.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -399,6 +399,7 @@ const { browser, page, close } = await launchBrowser({
|
|
|
399
399
|
verbose: false, // Enable debug logging
|
|
400
400
|
args: ['--no-sandbox'], // Custom Chrome args to append
|
|
401
401
|
storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
|
|
402
|
+
interruption: 'managed', // 'caller' leaves Ctrl+C (SIGINT) to your own handler
|
|
402
403
|
});
|
|
403
404
|
```
|
|
404
405
|
|
|
@@ -472,6 +473,31 @@ const session = await launchRealBrowser({
|
|
|
472
473
|
console.log(session.migration.migrated, session.migration.skipped);
|
|
473
474
|
```
|
|
474
475
|
|
|
476
|
+
### detach() / reconnectBrowser(handoff)
|
|
477
|
+
|
|
478
|
+
For an application that supervises the browser itself, `interruption:
|
|
479
|
+
'caller'` keeps Ctrl+C from reaching the launched browser (and its
|
|
480
|
+
ChromeDriver), so the application's own SIGINT handler decides what happens.
|
|
481
|
+
A real launch can then hand the exact browser and tab to the next run:
|
|
482
|
+
|
|
483
|
+
```javascript
|
|
484
|
+
const session = await launchBrowser({ interruption: 'caller' });
|
|
485
|
+
process.on('SIGINT', async () => {
|
|
486
|
+
// The browser keeps running; the hand-off names its instance and tab.
|
|
487
|
+
writeFileSync('handoff.json', JSON.stringify(await session.detach()));
|
|
488
|
+
process.exit(130);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Next run: attach to that exact tab - no launch, no new page, no navigation.
|
|
492
|
+
const again = await reconnectBrowser('handoff.json');
|
|
493
|
+
await again.close(); // closes the browser and waits for its process to exit
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`session.handoff()` returns the same JSON-serializable description without
|
|
497
|
+
detaching. `reconnectBrowser` throws a `BrowserHandoffError` when the endpoint
|
|
498
|
+
now belongs to another browser instance or the tab is gone. See
|
|
499
|
+
[embedding: Ctrl+C ownership and browser hand-off](../docs/embedding-and-handoff.md).
|
|
500
|
+
|
|
475
501
|
### launchWebDriver(options) / connectWebDriver(options)
|
|
476
502
|
|
|
477
503
|
The same engine is available through `launchBrowser({engine: 'selenium'})` and
|
package/package.json
CHANGED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a launched browser from one controller to the next (issue #174).
|
|
3
|
+
*
|
|
4
|
+
* An embedding supervisor that keeps a human-facing browser open across its
|
|
5
|
+
* own restarts needs three things from a launch:
|
|
6
|
+
*
|
|
7
|
+
* - interruption ownership: `launchBrowser({ interruption: 'caller' })`
|
|
8
|
+
* leaves Ctrl+C to the supervisor's own SIGINT handler (see
|
|
9
|
+
* `startProcess` in `utilities/subprocess.js`);
|
|
10
|
+
* - a `detach()` that stops this controller without touching the browser and
|
|
11
|
+
* returns a JSON-serializable {@link BrowserHandoff} naming the exact
|
|
12
|
+
* browser instance and tab;
|
|
13
|
+
* - {@link reconnectBrowser}, which attaches to that exact tab again - it
|
|
14
|
+
* never launches a browser and never creates or navigates a page, so a page
|
|
15
|
+
* waiting for a human (a login, a captcha) is found exactly as it was left.
|
|
16
|
+
*
|
|
17
|
+
* Whoever holds the hand-off owns the browser: `close()` on the launch result
|
|
18
|
+
* or on a reconnected session closes the browser, waits for the actual
|
|
19
|
+
* process to exit, and removes a temporary profile.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { connectBrowser } from './connector.js';
|
|
24
|
+
import { removeUserDataDir } from './profile-directory.js';
|
|
25
|
+
import { closeRemoteBrowser, probeSession } from './cdp-browser-endpoint.js';
|
|
26
|
+
import { signalProcessTree } from '../utilities/subprocess.js';
|
|
27
|
+
import { withDeadline } from '../traces/deadline.js';
|
|
28
|
+
|
|
29
|
+
/** Version of the {@link BrowserHandoff} shape. */
|
|
30
|
+
export const BROWSER_HANDOFF_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} BrowserHandoff
|
|
34
|
+
* @property {number} version - {@link BROWSER_HANDOFF_VERSION}
|
|
35
|
+
* @property {'playwright'|'puppeteer'|'selenium'} engine - Engine of the controller that handed off
|
|
36
|
+
* @property {string} cdpEndpoint - HTTP DevTools endpoint, such as http://127.0.0.1:9222
|
|
37
|
+
* @property {string} webSocketDebuggerUrl - Identity of the exact browser instance
|
|
38
|
+
* @property {string} targetId - DevTools target id of the exact tab
|
|
39
|
+
* @property {number|null} pid - Browser process id
|
|
40
|
+
* @property {string|null} userDataDir - Profile directory
|
|
41
|
+
* @property {boolean} temporaryProfile - Whether close() removes the profile
|
|
42
|
+
* @property {number|null} remoteDebuggingPort - Loopback CDP port
|
|
43
|
+
* @property {string|null} executablePath - Browser executable
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Raised when a hand-off no longer names a running browser or tab. */
|
|
47
|
+
export class BrowserHandoffError extends Error {
|
|
48
|
+
constructor(message, { cause } = {}) {
|
|
49
|
+
super(message, cause ? { cause } : undefined);
|
|
50
|
+
this.name = 'BrowserHandoffError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** DevTools target id of an engine page: the exact tab, across controllers. */
|
|
55
|
+
export async function pageTargetId({ engine, page, browser }) {
|
|
56
|
+
if (engine === 'selenium') {
|
|
57
|
+
// ChromeDriver window handles are target ids (older builds prefix them).
|
|
58
|
+
const handle = await browser.getWindowHandle();
|
|
59
|
+
return String(handle).replace(/^CDwindow-/, '');
|
|
60
|
+
}
|
|
61
|
+
const cdp =
|
|
62
|
+
engine === 'playwright'
|
|
63
|
+
? await page.context().newCDPSession(page)
|
|
64
|
+
: await page.target().createCDPSession();
|
|
65
|
+
try {
|
|
66
|
+
return (await cdp.send('Target.getTargetInfo')).targetInfo.targetId;
|
|
67
|
+
} finally {
|
|
68
|
+
await cdp.detach().catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Describe the browser and tab a controller holds, for another controller. */
|
|
73
|
+
async function describe(owner, page) {
|
|
74
|
+
const version = await probeSession(owner.cdpEndpoint);
|
|
75
|
+
if (!version?.webSocketDebuggerUrl) {
|
|
76
|
+
throw new BrowserHandoffError(
|
|
77
|
+
`No browser answers at ${owner.cdpEndpoint}; nothing to hand off`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
version: BROWSER_HANDOFF_VERSION,
|
|
82
|
+
engine: owner.engine,
|
|
83
|
+
cdpEndpoint: owner.cdpEndpoint,
|
|
84
|
+
webSocketDebuggerUrl: version.webSocketDebuggerUrl,
|
|
85
|
+
targetId: await pageTargetId({
|
|
86
|
+
engine: owner.engine,
|
|
87
|
+
page,
|
|
88
|
+
browser: owner.connectedBrowser(),
|
|
89
|
+
}),
|
|
90
|
+
pid: owner.pid() ?? null,
|
|
91
|
+
userDataDir: owner.userDataDir ?? null,
|
|
92
|
+
temporaryProfile: Boolean(owner.temporaryProfile),
|
|
93
|
+
remoteDebuggingPort: owner.remoteDebuggingPort ?? null,
|
|
94
|
+
executablePath: owner.executablePath ?? null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add `handoff()` and a hand-off returning `detach()` to a session.
|
|
100
|
+
*
|
|
101
|
+
* `state` is shared with the session's closer so that `close()` after
|
|
102
|
+
* `detach()` closes the browser over its WebSocket instead of the dropped
|
|
103
|
+
* engine connection.
|
|
104
|
+
*/
|
|
105
|
+
export function installHandoff(session, owner, state) {
|
|
106
|
+
const engineDetach = session.detach;
|
|
107
|
+
let detaching;
|
|
108
|
+
// Read by session persistence; not enumerable, so a launch result that
|
|
109
|
+
// spreads the session does not copy a value that goes stale.
|
|
110
|
+
Object.defineProperty(session, 'detached', {
|
|
111
|
+
configurable: true,
|
|
112
|
+
get: () => Boolean(state.detached),
|
|
113
|
+
});
|
|
114
|
+
session.handoff = async ({ page } = {}) => {
|
|
115
|
+
if (state.detached) {
|
|
116
|
+
return state.handoff;
|
|
117
|
+
}
|
|
118
|
+
return await describe(owner, page ?? session.page);
|
|
119
|
+
};
|
|
120
|
+
session.detach = ({ page } = {}) => {
|
|
121
|
+
detaching ??= (async () => {
|
|
122
|
+
const handoff = await session.handoff({ page });
|
|
123
|
+
state.handoff = handoff;
|
|
124
|
+
state.webSocketDebuggerUrl = handoff.webSocketDebuggerUrl;
|
|
125
|
+
state.detached = true;
|
|
126
|
+
await engineDetach?.();
|
|
127
|
+
owner.release?.();
|
|
128
|
+
return handoff;
|
|
129
|
+
})();
|
|
130
|
+
return detaching;
|
|
131
|
+
};
|
|
132
|
+
return session;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readHandoff(handoff) {
|
|
136
|
+
let value = handoff;
|
|
137
|
+
if (typeof value === 'string') {
|
|
138
|
+
value = JSON.parse(
|
|
139
|
+
value.trimStart().startsWith('{') ? value : readFileSync(value, 'utf8')
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const field of ['cdpEndpoint', 'webSocketDebuggerUrl', 'targetId']) {
|
|
143
|
+
if (typeof value?.[field] !== 'string' || !value[field]) {
|
|
144
|
+
throw new TypeError(`Browser hand-off is missing ${field}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (value.version !== BROWSER_HANDOFF_VERSION) {
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
`Unsupported browser hand-off version ${String(value.version)}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function processRunning(pid) {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, 0);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return error.code === 'EPERM';
|
|
160
|
+
}
|
|
161
|
+
if (process.platform === 'linux') {
|
|
162
|
+
// A zombie still answers signal 0; its parent has not reaped it yet.
|
|
163
|
+
try {
|
|
164
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
165
|
+
return stat.slice(stat.lastIndexOf(')') + 2)[0] !== 'Z';
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Wait until a process that is not necessarily our child has exited. */
|
|
174
|
+
export async function waitForPidExit(pid, timeout) {
|
|
175
|
+
const deadline = Date.now() + timeout;
|
|
176
|
+
while (processRunning(pid)) {
|
|
177
|
+
if (Date.now() >= deadline) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Close a handed-off browser and wait for its process to exit: `Browser.close`
|
|
187
|
+
* first, SIGKILL to its process group if it is still running after
|
|
188
|
+
* `closeTimeout`, then remove a temporary profile.
|
|
189
|
+
*/
|
|
190
|
+
export async function closeHandedOffBrowser(handoff, { closeTimeout = 5000 }) {
|
|
191
|
+
const version = await probeSession(handoff.cdpEndpoint);
|
|
192
|
+
if (version?.webSocketDebuggerUrl === handoff.webSocketDebuggerUrl) {
|
|
193
|
+
await withDeadline(
|
|
194
|
+
closeRemoteBrowser(handoff.webSocketDebuggerUrl),
|
|
195
|
+
closeTimeout,
|
|
196
|
+
'browser close'
|
|
197
|
+
).catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
if (handoff.pid && !(await waitForPidExit(handoff.pid, closeTimeout))) {
|
|
200
|
+
signalProcessTree(handoff.pid, 'SIGKILL');
|
|
201
|
+
await waitForPidExit(handoff.pid, closeTimeout);
|
|
202
|
+
}
|
|
203
|
+
if (handoff.temporaryProfile && handoff.userDataDir) {
|
|
204
|
+
await removeUserDataDir(handoff.userDataDir);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Attach a new controller to the exact browser and tab a previous controller
|
|
210
|
+
* handed off with `detach()`.
|
|
211
|
+
*
|
|
212
|
+
* The endpoint must still belong to the same browser instance (its
|
|
213
|
+
* `webSocketDebuggerUrl` is compared) and the tab must still exist; otherwise
|
|
214
|
+
* this throws instead of launching a browser or opening a page. The result
|
|
215
|
+
* owns the browser like the launch did: `close()` closes it and waits for its
|
|
216
|
+
* process to exit, `detach()` hands it off again.
|
|
217
|
+
*
|
|
218
|
+
* @param {BrowserHandoff|string} handoff - The hand-off object, its JSON, or a path to a JSON file
|
|
219
|
+
* @param {Object} [options] - {@link connectBrowser} options; `engine` defaults to the hand-off's
|
|
220
|
+
* @param {number} [options.closeTimeout=5000] - How long close() waits before killing the process
|
|
221
|
+
* @returns {Promise<Object>} {@link connectBrowser} handles plus `close`, `detach`, `handoff`, `pid`, `cdpEndpoint`, `userDataDir`, `temporaryProfile`
|
|
222
|
+
*/
|
|
223
|
+
export async function reconnectBrowser(handoff, options = {}) {
|
|
224
|
+
return await reconnectBrowserWithDependencies(handoff, options);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function reconnectBrowserWithDependencies(
|
|
228
|
+
handoff,
|
|
229
|
+
options = {},
|
|
230
|
+
dependencies = {}
|
|
231
|
+
) {
|
|
232
|
+
const descriptor = readHandoff(handoff);
|
|
233
|
+
const { closeTimeout = 5000, ...connectionOptions } = options;
|
|
234
|
+
const engine = options.engine ?? descriptor.engine ?? 'playwright';
|
|
235
|
+
const version = await probeSession(descriptor.cdpEndpoint);
|
|
236
|
+
if (version?.webSocketDebuggerUrl !== descriptor.webSocketDebuggerUrl) {
|
|
237
|
+
throw new BrowserHandoffError(
|
|
238
|
+
version
|
|
239
|
+
? `${descriptor.cdpEndpoint} now belongs to another browser instance`
|
|
240
|
+
: `The handed-off browser no longer answers at ${descriptor.cdpEndpoint}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const connect = dependencies.connect ?? connectBrowser;
|
|
244
|
+
let connection;
|
|
245
|
+
try {
|
|
246
|
+
connection = await connect({
|
|
247
|
+
...connectionOptions,
|
|
248
|
+
engine,
|
|
249
|
+
cdpEndpoint: descriptor.cdpEndpoint,
|
|
250
|
+
targetId: descriptor.targetId,
|
|
251
|
+
fallback: false,
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
throw new BrowserHandoffError(
|
|
255
|
+
`Could not reattach to tab ${descriptor.targetId}: ${error.message}`,
|
|
256
|
+
{ cause: error }
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const state = { detached: false };
|
|
260
|
+
const owner = {
|
|
261
|
+
...descriptor,
|
|
262
|
+
engine,
|
|
263
|
+
pid: () => descriptor.pid,
|
|
264
|
+
connectedBrowser: () => connection.browser,
|
|
265
|
+
};
|
|
266
|
+
const originalClose = connection.browser?.close;
|
|
267
|
+
let closing;
|
|
268
|
+
const close = () => {
|
|
269
|
+
closing ??= (async () => {
|
|
270
|
+
if (!state.detached) {
|
|
271
|
+
await connection.downloads?.dispose?.().catch(() => {});
|
|
272
|
+
if (engine === 'selenium') {
|
|
273
|
+
await connection.detach?.().catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
await closeHandedOffBrowser(descriptor, { closeTimeout });
|
|
277
|
+
if (engine !== 'selenium') {
|
|
278
|
+
await Promise.resolve()
|
|
279
|
+
.then(() => originalClose?.call(connection.browser))
|
|
280
|
+
.catch(() => {});
|
|
281
|
+
}
|
|
282
|
+
})();
|
|
283
|
+
return closing;
|
|
284
|
+
};
|
|
285
|
+
if (connection.browser && engine !== 'selenium') {
|
|
286
|
+
// As for a launch, browser.close() closes the browser this session owns.
|
|
287
|
+
connection.browser.close = close;
|
|
288
|
+
}
|
|
289
|
+
const session = installHandoff(
|
|
290
|
+
{
|
|
291
|
+
...connection,
|
|
292
|
+
close,
|
|
293
|
+
pid: descriptor.pid,
|
|
294
|
+
cdpEndpoint: descriptor.cdpEndpoint,
|
|
295
|
+
remoteDebuggingPort: descriptor.remoteDebuggingPort,
|
|
296
|
+
userDataDir: descriptor.userDataDir,
|
|
297
|
+
temporaryProfile: descriptor.temporaryProfile,
|
|
298
|
+
executablePath: descriptor.executablePath,
|
|
299
|
+
},
|
|
300
|
+
owner,
|
|
301
|
+
state
|
|
302
|
+
);
|
|
303
|
+
return session;
|
|
304
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A running Chromium-family browser addressed only by its DevTools endpoint:
|
|
3
|
+
* identify it, and ask it to shut down. Used by persistent sessions, their
|
|
4
|
+
* watchdog, and hand-off reconnection (issue #174), none of which hold an
|
|
5
|
+
* engine connection when they need to.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** `/json/version` of a CDP HTTP endpoint, or null when nothing answers. */
|
|
9
|
+
export function probeSession(endpoint) {
|
|
10
|
+
return fetch(`${endpoint}/json/version`, {
|
|
11
|
+
signal: AbortSignal.timeout(1000),
|
|
12
|
+
})
|
|
13
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
14
|
+
.catch(() => null);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Send `Browser.close` over a browser WebSocket and wait for the socket to close. */
|
|
18
|
+
export async function closeRemoteBrowser(endpoint) {
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
const socket = new globalThis.WebSocket(endpoint);
|
|
21
|
+
let acknowledged = false;
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
socket.close();
|
|
24
|
+
reject(new Error('Browser.close timed out'));
|
|
25
|
+
}, 3000);
|
|
26
|
+
socket.onopen = () =>
|
|
27
|
+
socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
28
|
+
socket.onclose = () => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
resolve();
|
|
31
|
+
};
|
|
32
|
+
socket.onerror = () => {
|
|
33
|
+
// Chrome can close the TCP connection without a WebSocket close frame
|
|
34
|
+
// after acknowledging Browser.close. Its ensuing close event completes
|
|
35
|
+
// shutdown; a pre-acknowledgement connection error remains a failure.
|
|
36
|
+
if (!acknowledged) {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
reject(new Error('Browser.close connection failed'));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
socket.onmessage = ({ data }) => {
|
|
42
|
+
const response = JSON.parse(data);
|
|
43
|
+
if (response.id !== 1) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (response.error) {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
reject(new Error(response.error.message ?? 'Browser.close failed'));
|
|
49
|
+
socket.close();
|
|
50
|
+
} else {
|
|
51
|
+
acknowledged = true;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
restorePuppeteerStorageState,
|
|
35
35
|
restoreWebDriverStorageState,
|
|
36
36
|
} from './storage-state.js';
|
|
37
|
+
import { interruptionMode } from '../utilities/subprocess.js';
|
|
37
38
|
|
|
38
39
|
/** How `launchBrowser` starts the browser (issue #103). */
|
|
39
40
|
export const LAUNCH_MODES = Object.freeze(['real', 'engine']);
|
|
@@ -166,7 +167,12 @@ async function launchWithEngine(options, dependencies) {
|
|
|
166
167
|
executablePath,
|
|
167
168
|
automationParity,
|
|
168
169
|
storageState,
|
|
170
|
+
interruption,
|
|
169
171
|
} = options;
|
|
172
|
+
// The engines install their own SIGINT handler that closes the browser;
|
|
173
|
+
// with caller-owned interruption the embedder's handler decides (#174).
|
|
174
|
+
const signals =
|
|
175
|
+
interruptionMode(interruption) === 'caller' ? { handleSIGINT: false } : {};
|
|
170
176
|
const chrome = resolveChromeArgs({
|
|
171
177
|
args,
|
|
172
178
|
extraArgs,
|
|
@@ -208,6 +214,7 @@ async function launchWithEngine(options, dependencies) {
|
|
|
208
214
|
ignoreDefaultArgs: chrome.ignoreDefaultArgs,
|
|
209
215
|
automationParity,
|
|
210
216
|
}),
|
|
217
|
+
...signals,
|
|
211
218
|
...(childEnv ? { env: childEnv } : {}),
|
|
212
219
|
});
|
|
213
220
|
page = browser.pages()[0] ?? (await browser.newPage());
|
|
@@ -223,6 +230,7 @@ async function launchWithEngine(options, dependencies) {
|
|
|
223
230
|
automationParity,
|
|
224
231
|
}),
|
|
225
232
|
...(slowMo ? { slowMo } : {}),
|
|
233
|
+
...signals,
|
|
226
234
|
...(childEnv ? { env: childEnv } : {}),
|
|
227
235
|
});
|
|
228
236
|
page = (await browser.pages())[0] ?? (await browser.newPage());
|
|
@@ -7,6 +7,10 @@ import { launchAndConnectRealBrowserWithDependencies } from './real-browser.js';
|
|
|
7
7
|
import { assertDedicatedUserDataDir } from './system-browser.js';
|
|
8
8
|
import { findSessionProcess } from './session-adoption.js';
|
|
9
9
|
import { startDetachedProcess } from '../utilities/subprocess.js';
|
|
10
|
+
import { closeRemoteBrowser, probeSession } from './cdp-browser-endpoint.js';
|
|
11
|
+
import { pageTargetId } from './browser-handoff.js';
|
|
12
|
+
|
|
13
|
+
export { closeRemoteBrowser, probeSession };
|
|
10
14
|
|
|
11
15
|
export const SESSION_METADATA = '.browser-commander-session.json';
|
|
12
16
|
const activity = new WeakMap();
|
|
@@ -27,13 +31,6 @@ export async function writeSessionMetadata(file, value) {
|
|
|
27
31
|
await fs.rm(temporary, { force: true });
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
|
-
export function probeSession(endpoint) {
|
|
31
|
-
return fetch(`${endpoint}/json/version`, {
|
|
32
|
-
signal: AbortSignal.timeout(1000),
|
|
33
|
-
})
|
|
34
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
35
|
-
.catch(() => null);
|
|
36
|
-
}
|
|
37
34
|
export async function acquireLease(file, retries = 50) {
|
|
38
35
|
let lease;
|
|
39
36
|
try {
|
|
@@ -168,17 +165,7 @@ async function open(options, file, endpoint) {
|
|
|
168
165
|
}
|
|
169
166
|
}
|
|
170
167
|
async function remember(page, engine, file, metadata, idleTimeoutMs) {
|
|
171
|
-
|
|
172
|
-
engine === 'playwright'
|
|
173
|
-
? await page.context().newCDPSession(page)
|
|
174
|
-
: await page.target().createCDPSession();
|
|
175
|
-
try {
|
|
176
|
-
metadata.targetId = (
|
|
177
|
-
await cdp.send('Target.getTargetInfo')
|
|
178
|
-
).targetInfo.targetId;
|
|
179
|
-
} finally {
|
|
180
|
-
await cdp.detach();
|
|
181
|
-
}
|
|
168
|
+
metadata.targetId = await pageTargetId({ engine, page });
|
|
182
169
|
metadata.lastActive = Date.now();
|
|
183
170
|
metadata.idleTimeoutMs = idleTimeoutMs;
|
|
184
171
|
await writeSessionMetadata(file, metadata);
|
|
@@ -365,44 +352,6 @@ async function lifecycle(
|
|
|
365
352
|
};
|
|
366
353
|
return result;
|
|
367
354
|
}
|
|
368
|
-
export async function closeRemoteBrowser(endpoint) {
|
|
369
|
-
await new Promise((resolve, reject) => {
|
|
370
|
-
const socket = new globalThis.WebSocket(endpoint);
|
|
371
|
-
let acknowledged = false;
|
|
372
|
-
const timer = setTimeout(() => {
|
|
373
|
-
socket.close();
|
|
374
|
-
reject(new Error('Browser.close timed out'));
|
|
375
|
-
}, 3000);
|
|
376
|
-
socket.onopen = () =>
|
|
377
|
-
socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
378
|
-
socket.onclose = () => {
|
|
379
|
-
clearTimeout(timer);
|
|
380
|
-
resolve();
|
|
381
|
-
};
|
|
382
|
-
socket.onerror = () => {
|
|
383
|
-
// Chrome can close the TCP connection without a WebSocket close frame
|
|
384
|
-
// after acknowledging Browser.close. Its ensuing close event completes
|
|
385
|
-
// shutdown; a pre-acknowledgement connection error remains a failure.
|
|
386
|
-
if (!acknowledged) {
|
|
387
|
-
clearTimeout(timer);
|
|
388
|
-
reject(new Error('Browser.close connection failed'));
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
socket.onmessage = ({ data }) => {
|
|
392
|
-
const response = JSON.parse(data);
|
|
393
|
-
if (response.id !== 1) {
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
if (response.error) {
|
|
397
|
-
clearTimeout(timer);
|
|
398
|
-
reject(new Error(response.error.message ?? 'Browser.close failed'));
|
|
399
|
-
socket.close();
|
|
400
|
-
} else {
|
|
401
|
-
acknowledged = true;
|
|
402
|
-
}
|
|
403
|
-
};
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
355
|
/** Reuse an owned profile/port; detach survives the controller; close terminates it. */
|
|
407
356
|
export async function connectOrLaunch(settings = {}) {
|
|
408
357
|
const options = {
|
|
@@ -9,7 +9,9 @@ import { findBrowserSource } from './browser-sources.js';
|
|
|
9
9
|
import { launchSafari } from './safari-launch.js';
|
|
10
10
|
import { isSafariChannel } from './safari-support.js';
|
|
11
11
|
|
|
12
|
-
import { startProcess } from '../utilities/subprocess.js';
|
|
12
|
+
import { interruptionMode, startProcess } from '../utilities/subprocess.js';
|
|
13
|
+
import { installHandoff } from './browser-handoff.js';
|
|
14
|
+
import { closeRemoteBrowser } from './cdp-browser-endpoint.js';
|
|
13
15
|
import { withDeadline } from '../traces/deadline.js';
|
|
14
16
|
import {
|
|
15
17
|
applyAutomationParityArgs,
|
|
@@ -256,8 +258,16 @@ export async function waitForCdpEndpoint({
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
|
|
259
|
-
function spawnWithStartProcess(
|
|
260
|
-
|
|
261
|
+
function spawnWithStartProcess(
|
|
262
|
+
executablePath,
|
|
263
|
+
args,
|
|
264
|
+
{ env, verbose, interruption }
|
|
265
|
+
) {
|
|
266
|
+
return startProcess(executablePath, args, {
|
|
267
|
+
env,
|
|
268
|
+
forwardOutput: verbose,
|
|
269
|
+
interruption,
|
|
270
|
+
});
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
async function waitForExit(browserProcess, timeout) {
|
|
@@ -310,12 +320,20 @@ function createCloser({
|
|
|
310
320
|
closeTimeout,
|
|
311
321
|
closeConnection,
|
|
312
322
|
verbose,
|
|
323
|
+
handoffState = {},
|
|
313
324
|
}) {
|
|
314
325
|
const originalClose = closeConnection ?? browser?.close;
|
|
315
326
|
let closing;
|
|
316
327
|
const close = () => {
|
|
317
328
|
closing ??= (async () => {
|
|
318
|
-
if (browserProcess.exitCode === null &&
|
|
329
|
+
if (browserProcess.exitCode === null && handoffState.detached) {
|
|
330
|
+
// The engine connection was handed off; ask the browser directly.
|
|
331
|
+
await withDeadline(
|
|
332
|
+
closeRemoteBrowser(handoffState.webSocketDebuggerUrl),
|
|
333
|
+
closeTimeout,
|
|
334
|
+
'browser close'
|
|
335
|
+
).catch(() => {});
|
|
336
|
+
} else if (browserProcess.exitCode === null && originalClose) {
|
|
319
337
|
// A signal can sever the engine transport while its close RPC is pending.
|
|
320
338
|
// Keep the existing close budget active until owned-process cleanup runs.
|
|
321
339
|
await withDeadline(
|
|
@@ -348,6 +366,14 @@ function createCloser({
|
|
|
348
366
|
return close;
|
|
349
367
|
}
|
|
350
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Selenium attaches through a chromedriver the launch starts; the driver
|
|
371
|
+
* follows the browser's interruption ownership (issue #174).
|
|
372
|
+
*/
|
|
373
|
+
function driverInterruption(engine, interruption) {
|
|
374
|
+
return engine === 'selenium' ? { interruption } : {};
|
|
375
|
+
}
|
|
376
|
+
|
|
351
377
|
/**
|
|
352
378
|
* Spawn a genuine installed Chrome-family browser with a dedicated profile,
|
|
353
379
|
* wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
|
|
@@ -400,6 +426,7 @@ async function spawnOnFreePort({
|
|
|
400
426
|
argOptions,
|
|
401
427
|
env,
|
|
402
428
|
verbose,
|
|
429
|
+
interruption,
|
|
403
430
|
userDataDir,
|
|
404
431
|
startupTimeout,
|
|
405
432
|
dependencies,
|
|
@@ -421,6 +448,7 @@ async function spawnOnFreePort({
|
|
|
421
448
|
browserProcess = await spawnBrowser(executablePath, browserArgs, {
|
|
422
449
|
env,
|
|
423
450
|
verbose: false,
|
|
451
|
+
interruption,
|
|
424
452
|
});
|
|
425
453
|
} catch (error) {
|
|
426
454
|
throw launchFailure(error, {
|
|
@@ -650,8 +678,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
650
678
|
preferences,
|
|
651
679
|
localState,
|
|
652
680
|
firstRun,
|
|
681
|
+
interruption: requestedInterruption,
|
|
653
682
|
...connectionOptions
|
|
654
683
|
} = options;
|
|
684
|
+
const interruption = interruptionMode(requestedInterruption);
|
|
655
685
|
const argOptions = {
|
|
656
686
|
headless,
|
|
657
687
|
restrictions,
|
|
@@ -731,6 +761,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
731
761
|
argOptions,
|
|
732
762
|
env: childEnv,
|
|
733
763
|
verbose,
|
|
764
|
+
interruption,
|
|
734
765
|
userDataDir,
|
|
735
766
|
startupTimeout,
|
|
736
767
|
dependencies,
|
|
@@ -756,9 +787,11 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
756
787
|
engine,
|
|
757
788
|
cdpEndpoint: resolvedCdpEndpoint,
|
|
758
789
|
...connectionOptions,
|
|
790
|
+
...driverInterruption(engine, interruption),
|
|
759
791
|
seedCookies,
|
|
760
792
|
verbose,
|
|
761
793
|
});
|
|
794
|
+
const handoffState = { detached: false };
|
|
762
795
|
const close = createCloser({
|
|
763
796
|
engine,
|
|
764
797
|
browser: connection.browser,
|
|
@@ -768,8 +801,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
768
801
|
closeTimeout,
|
|
769
802
|
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
770
803
|
verbose,
|
|
804
|
+
handoffState,
|
|
771
805
|
});
|
|
772
|
-
|
|
806
|
+
const session = await installSessionPersistence(
|
|
773
807
|
{
|
|
774
808
|
...connection,
|
|
775
809
|
close,
|
|
@@ -785,6 +819,23 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
785
819
|
},
|
|
786
820
|
options
|
|
787
821
|
);
|
|
822
|
+
return installHandoff(
|
|
823
|
+
session,
|
|
824
|
+
{
|
|
825
|
+
engine,
|
|
826
|
+
cdpEndpoint: resolvedCdpEndpoint,
|
|
827
|
+
userDataDir,
|
|
828
|
+
temporaryProfile,
|
|
829
|
+
remoteDebuggingPort: launched.port,
|
|
830
|
+
executablePath,
|
|
831
|
+
pid: () => browserProcess.pid,
|
|
832
|
+
connectedBrowser: () => connection.browser,
|
|
833
|
+
// The browser outlives this controller: no SIGINT forwarding, and
|
|
834
|
+
// its handle no longer keeps this program running.
|
|
835
|
+
release: () => browserProcess.detach?.(),
|
|
836
|
+
},
|
|
837
|
+
handoffState
|
|
838
|
+
);
|
|
788
839
|
} catch (error) {
|
|
789
840
|
if (browserProcess.exitCode === null) {
|
|
790
841
|
browserProcess.kill();
|