browser-commander 0.31.1 → 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 +14 -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/connection-stage.js +23 -0
- package/src/browser/connector.js +29 -15
- package/src/browser/launch-diagnostics.js +4 -1
- 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/core/engine-adapter.js +0 -7
- package/src/elements/locators.js +1 -10
- package/src/elements/selectors.js +10 -155
- package/src/elements/text-selector.js +23 -0
- package/src/exports.js +6 -0
- package/src/utilities/subprocess.js +227 -38
- package/tests/e2e/browser-handoff.e2e.test.js +94 -0
- package/tests/e2e/selector-identity.e2e.test.js +104 -0
- package/tests/e2e/startup-readiness.e2e.test.js +95 -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/connection-stage.test.js +74 -0
- package/tests/unit/browser/launch-diagnostics.test.js +23 -1
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/startup-readiness.test.js +129 -0
- package/tests/unit/elements/selector-identity.test.js +50 -0
- package/tests/unit/elements/selectors.test.js +12 -26
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +39 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.31.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 9f486af: Preserve native Playwright text selectors and CSS sibling positions. Resolve nonnative text matches to unique ancestor paths so interactions keep their intended target across Playwright, Puppeteer, and Selenium.
|
|
14
|
+
|
|
15
|
+
Retain sanitized engine failures alongside Chrome stderr in launch error messages. Identify attachment stages and optionally log their timing with verbose mode; wait for actual child exit before profile cleanup, and verify readiness, cleanup ownership, and fresh-profile startup on macOS in CI.
|
|
16
|
+
|
|
3
17
|
## 0.31.1
|
|
4
18
|
|
|
5
19
|
### 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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Identify attachment failures; optional logs contain stage names and timings. */
|
|
2
|
+
export async function connectionStage(options, stage, work) {
|
|
3
|
+
const start = performance.now();
|
|
4
|
+
const report = (state) => {
|
|
5
|
+
if (options.verbose) {
|
|
6
|
+
console.log(
|
|
7
|
+
`Browser attachment ${stage} ${state} after ${Math.round(performance.now() - start)}ms`
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
report('started');
|
|
12
|
+
try {
|
|
13
|
+
const result = await work();
|
|
14
|
+
report('completed');
|
|
15
|
+
return result;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error && typeof error === 'object' && Object.isExtensible(error)) {
|
|
18
|
+
error.attachmentStage ??= stage;
|
|
19
|
+
}
|
|
20
|
+
report('failed');
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/browser/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { attachDownloads } from '../downloads/attach.js';
|
|
2
|
+
import { connectionStage } from './connection-stage.js';
|
|
2
3
|
import { connectWebDriver, launchWebDriver } from './webdriver.js';
|
|
3
4
|
import {
|
|
4
5
|
loadStorageState,
|
|
@@ -282,7 +283,9 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
282
283
|
const settings = buildPlaywrightConnectOptions(options);
|
|
283
284
|
let browser;
|
|
284
285
|
try {
|
|
285
|
-
browser = await
|
|
286
|
+
browser = await connectionStage(options, 'engine-connect', () =>
|
|
287
|
+
chromium.connectOverCDP(endpoint, settings)
|
|
288
|
+
);
|
|
286
289
|
} catch (error) {
|
|
287
290
|
if (
|
|
288
291
|
settings.noDefaults !== undefined ||
|
|
@@ -291,21 +294,26 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
291
294
|
) {
|
|
292
295
|
throw error;
|
|
293
296
|
}
|
|
294
|
-
browser = await
|
|
295
|
-
...settings,
|
|
296
|
-
|
|
297
|
-
});
|
|
297
|
+
browser = await connectionStage(options, 'engine-connect', () =>
|
|
298
|
+
chromium.connectOverCDP(endpoint, { ...settings, noDefaults: true })
|
|
299
|
+
);
|
|
298
300
|
}
|
|
299
301
|
try {
|
|
300
302
|
const context = browser.contexts()[0];
|
|
301
303
|
if (!context) {
|
|
302
304
|
throw new Error('Connected Playwright browser has no default context');
|
|
303
305
|
}
|
|
304
|
-
const page =
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
const page = await connectionStage(
|
|
307
|
+
options,
|
|
308
|
+
'page-selection',
|
|
309
|
+
async () =>
|
|
310
|
+
(await pickForegroundPage(context.pages(), options)) ??
|
|
311
|
+
(await context.newPage())
|
|
312
|
+
);
|
|
307
313
|
|
|
308
|
-
await
|
|
314
|
+
await connectionStage(options, 'storage-restore', () =>
|
|
315
|
+
restorePlaywrightStorageState({ context, storageState })
|
|
316
|
+
);
|
|
309
317
|
return { browser, page, detach: browser.close?.bind(browser) };
|
|
310
318
|
} catch (error) {
|
|
311
319
|
await ignoreCleanup(() => browser.close?.());
|
|
@@ -316,15 +324,21 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
316
324
|
async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
317
325
|
const puppeteerModule = await loadPuppeteer();
|
|
318
326
|
const puppeteer = puppeteerModule.default ?? puppeteerModule;
|
|
319
|
-
const browser = await
|
|
320
|
-
buildPuppeteerConnectOptions(options)
|
|
327
|
+
const browser = await connectionStage(options, 'engine-connect', () =>
|
|
328
|
+
puppeteer.connect(buildPuppeteerConnectOptions(options))
|
|
321
329
|
);
|
|
322
330
|
try {
|
|
323
|
-
const page =
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
const page = await connectionStage(
|
|
332
|
+
options,
|
|
333
|
+
'page-selection',
|
|
334
|
+
async () =>
|
|
335
|
+
(await pickForegroundPage(await browser.pages(), options)) ??
|
|
336
|
+
(await browser.newPage())
|
|
337
|
+
);
|
|
326
338
|
|
|
327
|
-
await
|
|
339
|
+
await connectionStage(options, 'storage-restore', () =>
|
|
340
|
+
restorePuppeteerStorageState({ page, storageState })
|
|
341
|
+
);
|
|
328
342
|
return { browser, page, detach: browser.disconnect?.bind(browser) };
|
|
329
343
|
} catch (error) {
|
|
330
344
|
await ignoreCleanup(() => browser.disconnect?.());
|
|
@@ -7,10 +7,11 @@ export class BrowserLaunchError extends Error {
|
|
|
7
7
|
exitCode = null,
|
|
8
8
|
signal = null,
|
|
9
9
|
stderrTail = '',
|
|
10
|
+
attachmentStage,
|
|
10
11
|
cause,
|
|
11
12
|
}) {
|
|
12
13
|
super(
|
|
13
|
-
`Browser launch failed (${phase}: ${category}): ${
|
|
14
|
+
`Browser launch failed (${phase}: ${category}): ${cause?.message || ''}${stderrTail ? `\nBrowser stderr:\n${stderrTail}` : ''}`,
|
|
14
15
|
{ cause }
|
|
15
16
|
);
|
|
16
17
|
this.name = 'BrowserLaunchError';
|
|
@@ -21,6 +22,7 @@ export class BrowserLaunchError extends Error {
|
|
|
21
22
|
exitCode,
|
|
22
23
|
signal,
|
|
23
24
|
stderrTail,
|
|
25
|
+
...(attachmentStage ? { attachmentStage } : {}),
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
28
|
}
|
|
@@ -98,5 +100,6 @@ export function launchFailure(
|
|
|
98
100
|
signal: browserProcess?.signalCode ?? null,
|
|
99
101
|
stderrTail: scrub(browserProcess?.stderrTail ?? cause?.stderrTail ?? ''),
|
|
100
102
|
cause: safeCause,
|
|
103
|
+
attachmentStage: cause?.attachmentStage,
|
|
101
104
|
});
|
|
102
105
|
}
|
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 = {
|