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.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +26 -0
  3. package/package.json +1 -1
  4. package/src/browser/browser-handoff.js +304 -0
  5. package/src/browser/cdp-browser-endpoint.js +55 -0
  6. package/src/browser/connection-stage.js +23 -0
  7. package/src/browser/connector.js +29 -15
  8. package/src/browser/launch-diagnostics.js +4 -1
  9. package/src/browser/launcher.js +8 -0
  10. package/src/browser/persistent-session.js +5 -56
  11. package/src/browser/real-browser.js +56 -5
  12. package/src/browser/session-persistence.js +9 -5
  13. package/src/browser/webdriver.js +8 -1
  14. package/src/core/engine-adapter.js +0 -7
  15. package/src/elements/locators.js +1 -10
  16. package/src/elements/selectors.js +10 -155
  17. package/src/elements/text-selector.js +23 -0
  18. package/src/exports.js +6 -0
  19. package/src/utilities/subprocess.js +227 -38
  20. package/tests/e2e/browser-handoff.e2e.test.js +94 -0
  21. package/tests/e2e/selector-identity.e2e.test.js +104 -0
  22. package/tests/e2e/startup-readiness.e2e.test.js +95 -0
  23. package/tests/helpers/fake-cdp-endpoint.js +64 -0
  24. package/tests/helpers/temp-directory.js +25 -0
  25. package/tests/unit/browser/browser-handoff.test.js +406 -0
  26. package/tests/unit/browser/connection-stage.test.js +74 -0
  27. package/tests/unit/browser/launch-diagnostics.test.js +23 -1
  28. package/tests/unit/browser/launcher.test.js +23 -0
  29. package/tests/unit/browser/real-browser.test.js +1 -1
  30. package/tests/unit/browser/startup-readiness.test.js +129 -0
  31. package/tests/unit/elements/selector-identity.test.js +50 -0
  32. package/tests/unit/elements/selectors.test.js +12 -26
  33. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  34. package/tests/unit/utilities/interruption.test.js +182 -0
  35. package/tests/unit/utilities/subprocess.test.js +39 -13
@@ -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.child;
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.unref();
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
- return child;
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(runner, { forwardOutput }) {
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;
@@ -177,38 +236,121 @@ export class ManagedProcess {
177
236
  this.stdout = new OutputChannel();
178
237
  this.stderr = new OutputChannel();
179
238
  this.exitListeners = new Set();
180
- runner.on('stdout', (chunk) => {
181
- if (forwardOutput) {
182
- process.stdout.write(chunk);
183
- }
184
- this.stdout.emit(chunk);
185
- });
186
- runner.on('stderr', (chunk) => {
187
- this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
188
- if (forwardOutput) {
189
- process.stderr.write(chunk);
190
- }
191
- this.stderr.emit(chunk);
239
+ this.termination = null;
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;
192
250
  });
251
+ this.settle = settle;
193
252
  runner.start();
194
- this.exited = Promise.resolve(runner)
195
- .catch((error) => {
196
- this.spawnError = error;
197
- return { code: error?.code ?? 1 };
198
- })
199
- .then((result) => {
200
- this.exitCode = typeof result.code === 'number' ? result.code : 1;
201
- this.signalCode = result.signal ?? null;
202
- for (const listener of this.exitListeners) {
203
- 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);
204
258
  }
205
- return this.exitCode;
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,
206
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;
207
349
  }
208
350
 
209
351
  /** Operating system process id, once spawned. */
210
352
  get pid() {
211
- return this.runner.pid;
353
+ return this.child?.pid ?? this.runner.pid;
212
354
  }
213
355
 
214
356
  on(event, listener) {
@@ -233,10 +375,57 @@ export class ManagedProcess {
233
375
  * @returns {boolean} Whether a running process was signalled
234
376
  */
235
377
  kill(signal) {
236
- if (this.exitCode !== null) {
378
+ if (this.exitCode !== null || this.termination) {
237
379
  return false;
238
380
  }
381
+ if (this.child) {
382
+ return this.killReleased(signal ?? 'SIGTERM');
383
+ }
384
+ // command-stream resolves cancellation immediately and removes the child's
385
+ // listeners. Retain its handle and observe the real exit after signalling,
386
+ // so launchers cannot delete profiles while Chrome still writes to them.
387
+ const child = this.runner.child;
388
+ let resolveExit;
389
+ if (child?.pid) {
390
+ this.termination = new Promise((resolve) => {
391
+ resolveExit = resolve;
392
+ });
393
+ }
239
394
  this.runner.kill(signal);
395
+ if (resolveExit) {
396
+ if (child.exited) {
397
+ child.exited.then((code) =>
398
+ resolveExit({ code, signal: child.signalCode ?? null })
399
+ );
400
+ } else if (
401
+ Number.isInteger(child.exitCode) ||
402
+ typeof child.signalCode === 'string'
403
+ ) {
404
+ resolveExit({ code: child.exitCode, signal: child.signalCode });
405
+ } else {
406
+ child.once('exit', (code, exitSignal) =>
407
+ resolveExit({ code, signal: exitSignal })
408
+ );
409
+ }
410
+ }
411
+ return true;
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
+ }
240
429
  return true;
241
430
  }
242
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,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import { makeBrowserCommander } from '../../src/factory.js';
4
+ import { createEngineAdapter } from '../../src/core/engine-adapter.js';
5
+ import { launchE2EBrowser } from '../helpers/e2e-browser.js';
6
+
7
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
8
+ it(
9
+ `${engine} keeps scoped text selectors on the intended element`,
10
+ { skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
11
+ async () => {
12
+ const session = await launchE2EBrowser({ engine });
13
+ const commander = makeBrowserCommander({
14
+ page: session.page,
15
+ verbose: false,
16
+ debug: false,
17
+ enableNetworkTracking: false,
18
+ enableNavigationManager: false,
19
+ });
20
+ try {
21
+ await session.page.setContent(`
22
+ <nav><button>A</button><button data-qa='duplicate'
23
+ onclick="window.clicked='nav-2'">B</button></nav>
24
+ <div class="parent"><section><button>A</button>
25
+ <button data-qa='duplicate' data-target='B'
26
+ onclick="window.clicked='B'; this.setAttribute('aria-pressed','true')">B</button>
27
+ <textarea>Edit</textarea>
28
+ </section></div>`);
29
+ for (const exact of [false, true]) {
30
+ const selector = await commander.findByText({
31
+ selector: '.parent button',
32
+ text: 'B',
33
+ exact,
34
+ });
35
+ assert.equal(
36
+ await commander.getAttribute({
37
+ selector,
38
+ attribute: 'data-target',
39
+ }),
40
+ 'B'
41
+ );
42
+ assert.equal(await commander.textContent({ selector }), 'B');
43
+ assert.equal(await commander.isVisible({ selector }), true);
44
+ assert.equal(await commander.isEnabled({ selector }), true);
45
+ await commander.clickButton({ selector, verify: false });
46
+ assert.equal(await session.page.evaluate(() => window.clicked), 'B');
47
+ }
48
+ const editable = await commander.findByText({
49
+ selector: '.parent textarea',
50
+ text: 'Edit',
51
+ exact: true,
52
+ });
53
+ await commander.fillTextArea({
54
+ selector: editable,
55
+ text: 'Changed',
56
+ checkEmpty: false,
57
+ simulateTyping: false,
58
+ });
59
+ assert.equal(
60
+ await session.page.evaluate(
61
+ () => document.querySelector('.parent textarea').value
62
+ ),
63
+ 'Changed'
64
+ );
65
+ } finally {
66
+ await commander.destroy();
67
+ await session.cleanup();
68
+ }
69
+ }
70
+ );
71
+ }
72
+
73
+ it(
74
+ 'Playwright preserves CSS sibling position across multiple parents',
75
+ { skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
76
+ async () => {
77
+ const session = await launchE2EBrowser({ engine: 'playwright' });
78
+ const commander = makeBrowserCommander({
79
+ page: session.page,
80
+ debug: false,
81
+ enableNetworkTracking: false,
82
+ enableNavigationManager: false,
83
+ });
84
+ try {
85
+ await session.page.setContent(
86
+ '<section><span></span><button>A</button></section><section><button>B</button><button>C</button></section>'
87
+ );
88
+ const matches = await commander.querySelectorAll({
89
+ selector: 'section button:nth-of-type(2)',
90
+ });
91
+ assert.equal(matches.length, 1);
92
+ assert.equal(await matches[0].textContent(), 'C');
93
+ const adapter = createEngineAdapter(session.page, 'playwright');
94
+ const adapterMatches = await adapter.querySelectorAll(
95
+ 'section button:nth-of-type(2)'
96
+ );
97
+ assert.equal(adapterMatches.length, 1);
98
+ assert.equal(await adapterMatches[0].textContent(), 'C');
99
+ } finally {
100
+ await commander.destroy();
101
+ await session.cleanup();
102
+ }
103
+ }
104
+ );
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { launchBrowser } from '../../src/browser/launcher.js';
7
+ import { launchAndConnectRealBrowserWithDependencies } from '../../src/browser/real-browser.js';
8
+ import { startProcess } from '../../src/utilities/subprocess.js';
9
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
10
+
11
+ it(
12
+ 'fresh-profile Chrome starts repeatedly with session persistence',
13
+ { skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
14
+ async () => {
15
+ const root = await mkdtemp(path.join(os.tmpdir(), 'bc-startup-smoke-'));
16
+ try {
17
+ for (let attempt = 0; attempt < 3; attempt++) {
18
+ const userDataDir = path.join(root, `profile-${attempt}`);
19
+ const session = await launchBrowser({
20
+ debug: false,
21
+ channel: 'chrome',
22
+ headless: true,
23
+ userDataDir,
24
+ persistSessionCookies: true,
25
+ ...CHROME_LAUNCH_OPTIONS,
26
+ });
27
+ try {
28
+ assert.equal(await session.page.evaluate(() => 2 + 2), 4);
29
+ } finally {
30
+ await session.close();
31
+ }
32
+ assert.notEqual(session.browserProcess.exitCode, null);
33
+ await access(userDataDir);
34
+ }
35
+ } finally {
36
+ await rm(root, { recursive: true, force: true });
37
+ }
38
+ }
39
+ );
40
+
41
+ it(
42
+ 'failure after real DevTools readiness leaves another Chrome and caller profile intact',
43
+ { skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
44
+ async () => {
45
+ const sibling = await launchBrowser({
46
+ debug: false,
47
+ headless: true,
48
+ ...CHROME_LAUNCH_OPTIONS,
49
+ });
50
+ const root = await mkdtemp(path.join(os.tmpdir(), 'bc-owned-startup-'));
51
+ const marker = path.join(root, 'keep.txt');
52
+ await writeFile(marker, 'caller profile');
53
+ try {
54
+ for (const suppliedProfile of [false, true]) {
55
+ let child, profile;
56
+ await assert.rejects(
57
+ () =>
58
+ launchAndConnectRealBrowserWithDependencies(
59
+ {
60
+ debug: false,
61
+ headless: true,
62
+ persistSessionCookies: suppliedProfile,
63
+ ...(suppliedProfile ? { userDataDir: root } : {}),
64
+ ...CHROME_LAUNCH_OPTIONS,
65
+ },
66
+ {
67
+ spawnBrowser: (file, args, options) => {
68
+ profile = args
69
+ .find((arg) => arg.startsWith('--user-data-dir='))
70
+ .slice('--user-data-dir='.length);
71
+ child = startProcess(file, args, { env: options.env });
72
+ return child;
73
+ },
74
+ connect: async () => {
75
+ throw new Error(
76
+ 'Injected attachment timeout after endpoint readiness'
77
+ );
78
+ },
79
+ }
80
+ ),
81
+ /Injected attachment timeout/
82
+ );
83
+ assert.notEqual(child.exitCode, null);
84
+ assert.equal(await sibling.page.evaluate(() => 2 + 2), 4);
85
+ assert.equal(await readFile(marker, 'utf8'), 'caller profile');
86
+ if (!suppliedProfile) {
87
+ await assert.rejects(() => access(profile), /ENOENT/);
88
+ }
89
+ }
90
+ } finally {
91
+ await sibling.close();
92
+ await rm(root, { recursive: true, force: true });
93
+ }
94
+ }
95
+ );
@@ -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
+ }