browser-commander 0.31.2 → 0.32.1
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/launcher.js +8 -0
- package/src/browser/persistent-session.js +5 -56
- package/src/browser/profile-directory.js +8 -1
- 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/launch-building-blocks.test.js +9 -2
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/scripts/preflight-credentials.test.js +156 -10
- package/tests/unit/scripts/trusted-publishing.test.js +29 -7
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +6 -13
|
@@ -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();
|
|
@@ -54,11 +54,15 @@ export async function installSessionPersistence(result, options) {
|
|
|
54
54
|
const persistAndClose = () =>
|
|
55
55
|
(closing ??= (async () => {
|
|
56
56
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// A handed-off session no longer has an engine connection to save
|
|
58
|
+
// from; the controller that reconnects owns persistence.
|
|
59
|
+
if (!result.detached) {
|
|
60
|
+
const state = await saveStorageState(result.page);
|
|
61
|
+
await writeRestrictedState(file, {
|
|
62
|
+
cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
|
|
63
|
+
origins: [],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
62
66
|
} finally {
|
|
63
67
|
await close();
|
|
64
68
|
}
|
package/src/browser/webdriver.js
CHANGED
|
@@ -30,6 +30,7 @@ import path from 'node:path';
|
|
|
30
30
|
import { createWebDriverPage } from '../core/webdriver-page.js';
|
|
31
31
|
import { isVerboseEnabled } from '../core/logger.js';
|
|
32
32
|
import {
|
|
33
|
+
interruptionMode,
|
|
33
34
|
runCommand as defaultRunCommand,
|
|
34
35
|
startProcess as defaultStartProcess,
|
|
35
36
|
} from '../utilities/subprocess.js';
|
|
@@ -483,6 +484,7 @@ async function teardown({
|
|
|
483
484
|
* @param {Object<string,string>} [options.env] - Extra environment for the driver and browser
|
|
484
485
|
* @param {number} [options.startupTimeout=30000]
|
|
485
486
|
* @param {boolean} [options.verbose=false] - Mirror the driver server's output
|
|
487
|
+
* @param {'managed'|'caller'} [options.interruption='managed'] - Who handles Ctrl+C for the driver server and the browser under it; see `startProcess`
|
|
486
488
|
* @param {Object} [dependencies] - Injected selenium modules, runCommand, startProcess, fetch, reservePort, environment, platform (tests)
|
|
487
489
|
* @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
|
|
488
490
|
*/
|
|
@@ -564,6 +566,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
564
566
|
startupTimeout = 30_000,
|
|
565
567
|
verbose = false,
|
|
566
568
|
debuggerAddress,
|
|
569
|
+
interruption,
|
|
567
570
|
} = options;
|
|
568
571
|
assertBrowser(browser);
|
|
569
572
|
const safari = isSafariChannel(browser);
|
|
@@ -607,7 +610,11 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
607
610
|
driverProcess = startProcess(
|
|
608
611
|
resolved.driverPath,
|
|
609
612
|
buildDriverServerArgs(browser, port),
|
|
610
|
-
{
|
|
613
|
+
{
|
|
614
|
+
env: childEnv,
|
|
615
|
+
forwardOutput: verbose,
|
|
616
|
+
interruption: interruptionMode(interruption),
|
|
617
|
+
}
|
|
611
618
|
);
|
|
612
619
|
for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
|
|
613
620
|
stream?.on('data', (chunk) => {
|
package/src/exports.js
CHANGED
|
@@ -70,6 +70,12 @@ export {
|
|
|
70
70
|
launchAndConnectRealBrowser,
|
|
71
71
|
launchRealBrowser,
|
|
72
72
|
} from './browser/real-browser.js';
|
|
73
|
+
export {
|
|
74
|
+
BROWSER_HANDOFF_VERSION,
|
|
75
|
+
BrowserHandoffError,
|
|
76
|
+
reconnectBrowser,
|
|
77
|
+
} from './browser/browser-handoff.js';
|
|
78
|
+
export { INTERRUPTION_MODES } from './utilities/subprocess.js';
|
|
73
79
|
export {
|
|
74
80
|
classifyDifferences,
|
|
75
81
|
compareCommandLines,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { constants as osConstants } from 'node:os';
|
|
1
2
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
2
3
|
|
|
3
4
|
/** Start an explicitly persistent process, with no parent-owned pipes or signal forwarding. */
|
|
@@ -8,15 +9,7 @@ export async function startDetachedProcess(file, args = [], options = {}) {
|
|
|
8
9
|
runner.child.once('spawn', resolve);
|
|
9
10
|
runner.child.once('error', reject);
|
|
10
11
|
});
|
|
11
|
-
const child = runner
|
|
12
|
-
// command-stream starts noninteractive children in their own process
|
|
13
|
-
// group. Release its controller-owned cancellation registry while retaining
|
|
14
|
-
// the child's exit listeners. Keep this adapter isolated until it exposes a
|
|
15
|
-
// public detach operation; normal subprocesses retain managed cancellation.
|
|
16
|
-
runner._child = null;
|
|
17
|
-
runner._abortController = null;
|
|
18
|
-
runner.finished = true;
|
|
19
|
-
runner._cleanup();
|
|
12
|
+
const child = releaseRunner(runner);
|
|
20
13
|
Promise.resolve(runner).catch(() => {});
|
|
21
14
|
child.stderrTail = '';
|
|
22
15
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -25,11 +18,65 @@ export async function startDetachedProcess(file, args = [], options = {}) {
|
|
|
25
18
|
child.on('error', (error) => {
|
|
26
19
|
child.spawnError = error;
|
|
27
20
|
});
|
|
28
|
-
child
|
|
21
|
+
unrefChild(child);
|
|
22
|
+
return child;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Release a started runner from command-stream's process-wide cancellation
|
|
27
|
+
* registry and return its raw child. command-stream 2.x has no public per-run
|
|
28
|
+
* opt-out: every runner joins a global set whose SIGINT handler forwards the
|
|
29
|
+
* signal to the child's process group. command-stream starts noninteractive
|
|
30
|
+
* children in their own process group, so once released nothing but the caller
|
|
31
|
+
* signals the child. Its exit listeners are retained.
|
|
32
|
+
*/
|
|
33
|
+
function releaseRunner(runner) {
|
|
34
|
+
const child = runner.child;
|
|
35
|
+
runner._child = null;
|
|
36
|
+
runner._abortController = null;
|
|
37
|
+
runner.finished = true;
|
|
38
|
+
runner._cleanup();
|
|
39
|
+
return child;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unrefChild(child) {
|
|
43
|
+
child.unref?.();
|
|
29
44
|
child.stdout?.unref?.();
|
|
30
45
|
child.stderr?.unref?.();
|
|
31
46
|
child.stdin?.unref?.();
|
|
32
|
-
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who answers a terminal interrupt (SIGINT) for a started process. */
|
|
50
|
+
export const INTERRUPTION_MODES = Object.freeze(['managed', 'caller']);
|
|
51
|
+
|
|
52
|
+
/** Validate an `interruption` option, returning the effective mode. */
|
|
53
|
+
export function interruptionMode(value = 'managed') {
|
|
54
|
+
if (!INTERRUPTION_MODES.includes(value)) {
|
|
55
|
+
throw new TypeError(
|
|
56
|
+
`interruption must be one of ${INTERRUPTION_MODES.join(', ')}, got ${String(value)}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function signalExitCode(signal) {
|
|
63
|
+
return 128 + (osConstants.signals[signal] ?? 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Signal a process and its process group, ignoring an already-gone one. */
|
|
67
|
+
export function signalProcessTree(pid, signal) {
|
|
68
|
+
let delivered = false;
|
|
69
|
+
for (const target of process.platform === 'win32' ? [pid] : [-pid, pid]) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(target, signal);
|
|
72
|
+
delivered = true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== 'ESRCH') {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return delivered;
|
|
33
80
|
}
|
|
34
81
|
|
|
35
82
|
/**
|
|
@@ -128,18 +175,27 @@ export async function runCommand(file, args = [], options = {}) {
|
|
|
128
175
|
* rely on (`pid`, `exitCode`, `kill()`, `stderr`/`stdout` `data` events and an
|
|
129
176
|
* `exit` event), plus an `exited` promise.
|
|
130
177
|
*
|
|
178
|
+
* By default (`interruption: 'managed'`) command-stream owns interruption: its
|
|
179
|
+
* process-wide SIGINT handler forwards Ctrl+C to the child's process group.
|
|
180
|
+
* With `interruption: 'caller'` the child is released from that registry right
|
|
181
|
+
* after it starts, so the embedding program's own SIGINT handler decides
|
|
182
|
+
* whether to stop, detach or keep the child (issue #174). Output streaming,
|
|
183
|
+
* `exited`, and `kill()` (which still awaits the actual exit) keep working.
|
|
184
|
+
*
|
|
131
185
|
* @param {string} file - Executable path
|
|
132
186
|
* @param {string[]} args - Exact arguments
|
|
133
187
|
* @param {Object} [options]
|
|
134
188
|
* @param {Object<string,string>} [options.env] - Environment for the child only
|
|
135
189
|
* @param {boolean} [options.forwardOutput=false] - Mirror the child's output to this process
|
|
136
190
|
* @param {number} [options.killGrace=2000] - Milliseconds between SIGTERM and SIGKILL
|
|
191
|
+
* @param {'managed'|'caller'} [options.interruption='managed'] - Who answers SIGINT
|
|
137
192
|
* @returns {ManagedProcess}
|
|
138
193
|
*/
|
|
139
194
|
export function startProcess(file, args = [], options = {}) {
|
|
140
195
|
const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
|
|
196
|
+
const interruption = interruptionMode(options.interruption);
|
|
141
197
|
const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
|
|
142
|
-
return new ManagedProcess(runner, { forwardOutput });
|
|
198
|
+
return new ManagedProcess(runner, { forwardOutput, killGrace, interruption });
|
|
143
199
|
}
|
|
144
200
|
|
|
145
201
|
class OutputChannel {
|
|
@@ -168,7 +224,10 @@ class OutputChannel {
|
|
|
168
224
|
|
|
169
225
|
/** Handle for a process started by {@link startProcess}. */
|
|
170
226
|
export class ManagedProcess {
|
|
171
|
-
constructor(
|
|
227
|
+
constructor(
|
|
228
|
+
runner,
|
|
229
|
+
{ forwardOutput, killGrace = 2000, interruption = 'managed' }
|
|
230
|
+
) {
|
|
172
231
|
this.runner = runner;
|
|
173
232
|
this.exitCode = null;
|
|
174
233
|
this.signalCode = null;
|
|
@@ -178,46 +237,120 @@ export class ManagedProcess {
|
|
|
178
237
|
this.stderr = new OutputChannel();
|
|
179
238
|
this.exitListeners = new Set();
|
|
180
239
|
this.termination = null;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
runner.on('stderr', (chunk) =>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
this.stderr.emit(chunk);
|
|
240
|
+
this.killGrace = killGrace;
|
|
241
|
+
this.interruption = interruption;
|
|
242
|
+
this.detached = false;
|
|
243
|
+
this.child = null;
|
|
244
|
+
this.forwardOutput = forwardOutput;
|
|
245
|
+
runner.on('stdout', (chunk) => this.receiveStdout(chunk));
|
|
246
|
+
runner.on('stderr', (chunk) => this.receiveStderr(chunk));
|
|
247
|
+
let settle;
|
|
248
|
+
const settled = new Promise((resolve) => {
|
|
249
|
+
settle = resolve;
|
|
193
250
|
});
|
|
251
|
+
this.settle = settle;
|
|
194
252
|
runner.start();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.then(async (result) => {
|
|
201
|
-
const actualExit = await this.termination;
|
|
202
|
-
if (actualExit) {
|
|
203
|
-
result = {
|
|
204
|
-
...result,
|
|
205
|
-
code: actualExit.code ?? result.code,
|
|
206
|
-
signal: actualExit.signal,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
210
|
-
this.signalCode = result.signal ?? null;
|
|
211
|
-
for (const listener of this.exitListeners) {
|
|
212
|
-
listener(this.exitCode);
|
|
253
|
+
Promise.resolve(runner).then(
|
|
254
|
+
(result) => {
|
|
255
|
+
// A released runner resolves without a result; its raw child reports.
|
|
256
|
+
if (!this.child) {
|
|
257
|
+
settle(result);
|
|
213
258
|
}
|
|
214
|
-
|
|
259
|
+
},
|
|
260
|
+
(error) => {
|
|
261
|
+
this.spawnError = error;
|
|
262
|
+
settle({ code: error?.code ?? 1 });
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
this.exited = settled.then(async (result) => {
|
|
266
|
+
const actualExit = await this.termination;
|
|
267
|
+
if (actualExit) {
|
|
268
|
+
result = {
|
|
269
|
+
...result,
|
|
270
|
+
code: actualExit.code ?? result.code,
|
|
271
|
+
signal: actualExit.signal,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
275
|
+
this.signalCode = result.signal ?? null;
|
|
276
|
+
for (const listener of this.exitListeners) {
|
|
277
|
+
listener(this.exitCode);
|
|
278
|
+
}
|
|
279
|
+
return this.exitCode;
|
|
280
|
+
});
|
|
281
|
+
if (interruption === 'caller') {
|
|
282
|
+
this.release();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
receiveStdout(chunk) {
|
|
287
|
+
if (this.forwardOutput) {
|
|
288
|
+
process.stdout.write(chunk);
|
|
289
|
+
}
|
|
290
|
+
this.stdout.emit(chunk);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
receiveStderr(chunk) {
|
|
294
|
+
this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
|
|
295
|
+
if (this.forwardOutput) {
|
|
296
|
+
process.stderr.write(chunk);
|
|
297
|
+
}
|
|
298
|
+
this.stderr.emit(chunk);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Take the child out of command-stream's SIGINT forwarding while this handle
|
|
303
|
+
* keeps observing its output and exit.
|
|
304
|
+
*/
|
|
305
|
+
release() {
|
|
306
|
+
if (this.exitCode !== null || this.termination) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (this.child) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const child = this.runner.child;
|
|
313
|
+
if (!child?.pid) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
releaseRunner(this.runner);
|
|
317
|
+
this.child = child;
|
|
318
|
+
this.interruption = 'caller';
|
|
319
|
+
child.stdout?.on('data', (chunk) => this.receiveStdout(chunk));
|
|
320
|
+
child.stderr?.on('data', (chunk) => this.receiveStderr(chunk));
|
|
321
|
+
const report = (code, signal) =>
|
|
322
|
+
this.settle({
|
|
323
|
+
code: Number.isInteger(code) ? code : signalExitCode(signal),
|
|
324
|
+
signal: signal ?? null,
|
|
215
325
|
});
|
|
326
|
+
if (Number.isInteger(child.exitCode) || child.signalCode) {
|
|
327
|
+
report(child.exitCode, child.signalCode);
|
|
328
|
+
} else {
|
|
329
|
+
child.once('exit', report);
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Leave the process running independently of this program: release it from
|
|
336
|
+
* command-stream's SIGINT forwarding and stop its handle and pipes from
|
|
337
|
+
* keeping the event loop alive. Exit observation keeps working while this
|
|
338
|
+
* program runs, and {@link kill} still stops it. Idempotent.
|
|
339
|
+
*
|
|
340
|
+
* @returns {boolean} Whether a running process is now detached
|
|
341
|
+
*/
|
|
342
|
+
detach() {
|
|
343
|
+
if (!this.release()) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
this.detached = true;
|
|
347
|
+
unrefChild(this.child);
|
|
348
|
+
return true;
|
|
216
349
|
}
|
|
217
350
|
|
|
218
351
|
/** Operating system process id, once spawned. */
|
|
219
352
|
get pid() {
|
|
220
|
-
return this.runner.pid;
|
|
353
|
+
return this.child?.pid ?? this.runner.pid;
|
|
221
354
|
}
|
|
222
355
|
|
|
223
356
|
on(event, listener) {
|
|
@@ -245,6 +378,9 @@ export class ManagedProcess {
|
|
|
245
378
|
if (this.exitCode !== null || this.termination) {
|
|
246
379
|
return false;
|
|
247
380
|
}
|
|
381
|
+
if (this.child) {
|
|
382
|
+
return this.killReleased(signal ?? 'SIGTERM');
|
|
383
|
+
}
|
|
248
384
|
// command-stream resolves cancellation immediately and removes the child's
|
|
249
385
|
// listeners. Retain its handle and observe the real exit after signalling,
|
|
250
386
|
// so launchers cannot delete profiles while Chrome still writes to them.
|
|
@@ -274,4 +410,22 @@ export class ManagedProcess {
|
|
|
274
410
|
}
|
|
275
411
|
return true;
|
|
276
412
|
}
|
|
413
|
+
|
|
414
|
+
/** Stop a released child directly; command-stream no longer tracks it. */
|
|
415
|
+
killReleased(signal) {
|
|
416
|
+
const { child } = this;
|
|
417
|
+
if (!signalProcessTree(child.pid, signal)) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
if (signal !== 'SIGKILL') {
|
|
421
|
+
const escalation = setTimeout(() => {
|
|
422
|
+
if (this.exitCode === null) {
|
|
423
|
+
signalProcessTree(child.pid, 'SIGKILL');
|
|
424
|
+
}
|
|
425
|
+
}, this.killGrace);
|
|
426
|
+
escalation.unref?.();
|
|
427
|
+
this.exited.then(() => clearTimeout(escalation));
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
277
431
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #174: an embedding supervisor owns Ctrl+C for the browser it launched,
|
|
3
|
+
* hands the exact browser and tab to its next run, and that run closes it.
|
|
4
|
+
*/
|
|
5
|
+
// feature-parity: process.interruption@native-typed sessions.handoff@native-typed
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import {
|
|
12
|
+
reconnectBrowser,
|
|
13
|
+
waitForPidExit,
|
|
14
|
+
} from '../../src/browser/browser-handoff.js';
|
|
15
|
+
import { startProcess } from '../../src/utilities/subprocess.js';
|
|
16
|
+
import { repoPath } from '../helpers/repo.js';
|
|
17
|
+
|
|
18
|
+
const SUPERVISOR = repoPath(
|
|
19
|
+
'experiments',
|
|
20
|
+
'issue-174',
|
|
21
|
+
'browser-supervisor.mjs'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Run the supervisor, which interrupts itself after opening a page. */
|
|
25
|
+
async function interruptSupervisor(mode, engine) {
|
|
26
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-e2e-174-'));
|
|
27
|
+
const handoffFile = path.join(directory, 'handoff.json');
|
|
28
|
+
try {
|
|
29
|
+
const supervisor = startProcess(process.execPath, [
|
|
30
|
+
SUPERVISOR,
|
|
31
|
+
mode,
|
|
32
|
+
engine,
|
|
33
|
+
handoffFile,
|
|
34
|
+
]);
|
|
35
|
+
const exitCode = await supervisor.exited;
|
|
36
|
+
const pid = Number(await readFile(`${handoffFile}.pid`, 'utf8'));
|
|
37
|
+
const handoff = await readFile(handoffFile, 'utf8').catch(() => null);
|
|
38
|
+
return { exitCode, pid, handoff, stderr: supervisor.stderrTail };
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(directory, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe(
|
|
45
|
+
'browser hand-off across an embedding supervisor (issue #174)',
|
|
46
|
+
{
|
|
47
|
+
skip:
|
|
48
|
+
(!process.env.RUN_E2E && 'set RUN_E2E=true') ||
|
|
49
|
+
(process.platform === 'win32' && 'SIGINT forwarding is Unix-only'),
|
|
50
|
+
timeout: 120000,
|
|
51
|
+
},
|
|
52
|
+
() => {
|
|
53
|
+
it('by default Ctrl+C reaches Chrome before the embedder can keep it', async () => {
|
|
54
|
+
const { exitCode, pid, handoff } = await interruptSupervisor(
|
|
55
|
+
'managed',
|
|
56
|
+
'playwright'
|
|
57
|
+
);
|
|
58
|
+
assert.equal(exitCode, 130);
|
|
59
|
+
assert.equal(handoff, null);
|
|
60
|
+
assert.equal(await waitForPidExit(pid, 10000), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const engine of ['playwright', 'puppeteer', 'selenium']) {
|
|
64
|
+
it(`${engine}: keeps the exact tab across Ctrl+C and closes it later`, async () => {
|
|
65
|
+
const { exitCode, pid, handoff, stderr } = await interruptSupervisor(
|
|
66
|
+
'caller',
|
|
67
|
+
engine
|
|
68
|
+
);
|
|
69
|
+
assert.equal(exitCode, 130, stderr);
|
|
70
|
+
assert.ok(handoff, stderr);
|
|
71
|
+
const descriptor = JSON.parse(handoff);
|
|
72
|
+
assert.equal(descriptor.pid, pid);
|
|
73
|
+
try {
|
|
74
|
+
assert.equal(await waitForPidExit(pid, 500), false);
|
|
75
|
+
const session = await reconnectBrowser(handoff);
|
|
76
|
+
const title =
|
|
77
|
+
engine === 'selenium'
|
|
78
|
+
? await session.browser.getTitle()
|
|
79
|
+
: await session.page.title();
|
|
80
|
+
assert.equal(title, 'waiting for a human');
|
|
81
|
+
await session.close();
|
|
82
|
+
assert.equal(await waitForPidExit(pid, 1000), true);
|
|
83
|
+
await assert.rejects(readFile(descriptor.userDataDir), {
|
|
84
|
+
code: 'ENOENT',
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
if (!(await waitForPidExit(pid, 0))) {
|
|
88
|
+
process.kill(-pid, 'SIGKILL');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake DevTools HTTP endpoint for hand-off tests (issue #174): it answers
|
|
3
|
+
* `/json/version` with a browser WebSocket URL and accepts `Browser.close`
|
|
4
|
+
* on that WebSocket, so detach/reconnect/close run without a browser.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { acceptUpgrade } from '../../src/browser/attach/websocket.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.browserId='browser-1'] - Identity in webSocketDebuggerUrl
|
|
12
|
+
* @param {Function} [options.onBrowserClose] - Called when Browser.close arrives
|
|
13
|
+
* @returns {Promise<Object>} `{ endpoint, webSocketDebuggerUrl, closes, setBrowserId, close }`
|
|
14
|
+
*/
|
|
15
|
+
export async function startFakeCdpEndpoint({
|
|
16
|
+
browserId = 'browser-1',
|
|
17
|
+
onBrowserClose = () => {},
|
|
18
|
+
} = {}) {
|
|
19
|
+
const closes = [];
|
|
20
|
+
let currentId = browserId;
|
|
21
|
+
const server = http.createServer((request, response) => {
|
|
22
|
+
if (request.url !== '/json/version') {
|
|
23
|
+
response.writeHead(404).end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
response.setHeader('content-type', 'application/json');
|
|
27
|
+
response.end(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
Browser: 'FakeChrome/1.0',
|
|
30
|
+
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/${currentId}`,
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
35
|
+
const { port } = server.address();
|
|
36
|
+
server.on('upgrade', (request, socket, head) => {
|
|
37
|
+
const connection = acceptUpgrade(request, socket, head);
|
|
38
|
+
connection?.on('message', (text) => {
|
|
39
|
+
const message = JSON.parse(text);
|
|
40
|
+
if (message.method === 'Browser.close') {
|
|
41
|
+
closes.push(request.url);
|
|
42
|
+
connection.send(JSON.stringify({ id: message.id, result: {} }));
|
|
43
|
+
onBrowserClose();
|
|
44
|
+
socket.end();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
endpoint: `http://127.0.0.1:${port}`,
|
|
50
|
+
get webSocketDebuggerUrl() {
|
|
51
|
+
return `ws://127.0.0.1:${port}/devtools/browser/${currentId}`;
|
|
52
|
+
},
|
|
53
|
+
closes,
|
|
54
|
+
/** Pretend another browser now listens on the same port. */
|
|
55
|
+
setBrowserId: (id) => {
|
|
56
|
+
currentId = id;
|
|
57
|
+
},
|
|
58
|
+
close: () =>
|
|
59
|
+
new Promise((resolve) => {
|
|
60
|
+
server.closeAllConnections?.();
|
|
61
|
+
server.close(() => resolve());
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|