browser-commander 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2f5b1d4: Leave JavaScript launch and commander recording off by default for read-only-safe navigation. Opt in with debug:true, a debug object or BROWSER_COMMANDER_DEBUG=1. Add bounded managed recording directories, retention controls and stopDebug(), with cleanup limited to the session's owned child directory. Document migration from 0.30.x and application-owned signal teardown.
8
+
9
+ ## 0.30.1
10
+
11
+ ### Patch Changes
12
+
13
+ - aee418c: Upgrade Command Stream to ^2.0.2 to remove the vulnerable ShellJS/braces dependency chain from fresh consumer installations while preserving subprocess and detached browser launch behavior.
14
+
3
15
  ## 0.30.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -816,12 +816,19 @@ See [capture, debugging and reusable sessions](https://github.com/link-foundatio
816
816
  for screenshots, recordings, full/text DOM links, network/HAR, rotation,
817
817
  persistent browsers, early trigger readiness and engine capability limits.
818
818
 
819
- JavaScript commanders and browser launches record complete sessions by default
820
- under `./browser-commander-traces/`, including trusted user actions, DOM changes,
821
- network metadata and `trace.lino`. Await `commander.ready` before using its raw
822
- page, and call `commander.destroy()` or the launch's `close()` to finish the files.
823
- Use `debug: false` or `BROWSER_COMMANDER_DEBUG=0` to opt out; `debug: { output }`
824
- chooses the directory. `commander.tracePath` exposes its path.
819
+ JavaScript commanders and browser launches leave recording off by default and
820
+ support navigation in a read-only cwd. Request `debug: true`, `debug: { output }`
821
+ or `BROWSER_COMMANDER_DEBUG=1` for complete capture; an output hint alone does not
822
+ enable recording. `debug: false` or `BROWSER_COMMANDER_DEBUG=0` overrides automatic
823
+ capture. Explicit trace APIs remain available.
824
+
825
+ Use `debug: { directory: '/owned/evidence', retain: false }` for a private unique
826
+ session child with finite data/duration limits and cleanup on teardown. Await
827
+ `commander.ready` before using its raw page; `commander.tracePath` exposes the
828
+ recording path. Await `commander.destroy()` or launch `close()` in `finally`.
829
+ `stopDebug({ discard: true })` removes only a managed child; recordings are
830
+ retained by default. See the guide above for migration from 0.30.x, limits and
831
+ application-owned signal handling.
825
832
 
826
833
  `startTrace()` records a session into a schema-versioned bundle any of the three
827
834
  languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "bplist-parser": "^0.5.0",
84
- "command-stream": "^1.4.0",
84
+ "command-stream": "^2.0.2",
85
85
  "csv-parse": "^7.0.3",
86
86
  "gifenc": "^1.0.3",
87
87
  "links-notation": "^0.23.0",
@@ -177,8 +177,8 @@ function runNodeTests(options, testFiles) {
177
177
  process.execPath,
178
178
  buildNodeTestArgs(options, testFiles),
179
179
  {
180
- // Most fixtures exercise unrelated APIs with incomplete fake pages. The
181
- // default-recording regressions explicitly unset this harness opt-out.
180
+ // Isolate unrelated fixtures from ambient debug opt-ins. Recording policy
181
+ // regressions explicitly clear this harness opt-out.
182
182
  env: { BROWSER_COMMANDER_DEBUG: '0', ...process.env, ...options.env },
183
183
  stdio: 'inherit',
184
184
  }
@@ -8,7 +8,8 @@ import {
8
8
  import { assertSupportedEngine } from './connector.js';
9
9
  import { emulateMedia } from './media.js';
10
10
  import { reserveLoopbackPort } from './debugging-port.js';
11
- import { attachLaunchDebug } from '../traces/debug-session.js';
11
+ import { attachLaunchDebug, debugEnabled } from '../traces/debug-session.js';
12
+ import { validateDebugConfig } from '../traces/debug-output.js';
12
13
  import {
13
14
  buildPlaywrightLaunchOptions,
14
15
  buildPuppeteerLaunchOptions,
@@ -307,6 +308,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
307
308
  * @param {Object} [options.fingerprint] - Environment fields to present to pages, such as userAgent, timezone, locale, hardwareConcurrency or screen. Applied over CDP after launch; see src/fingerprint/profile.js for the full field list and presets.js for ready-made profiles.
308
309
  * @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false where a launch switch would turn it on (headless or engine launches)
309
310
  * @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}. The directory may be an absolute path, 'user-downloads' or 'temporary'.
311
+ * @param {boolean|Object} [options.debug=false] - Opt-in recording: true, {output}, or {directory, retain, maxDurationMs, limits} for managed storage
310
312
  * @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, launch: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>}>} Browser (a BrowserContext for Playwright), page, download manager and launch metadata
311
313
  */
312
314
  export async function launchBrowser(options = {}) {
@@ -318,6 +320,9 @@ export async function launchBrowserWithDependencies(
318
320
  options = {},
319
321
  dependencies = {}
320
322
  ) {
323
+ if (debugEnabled(options) && typeof options.debug === 'object') {
324
+ validateDebugConfig(options.debug);
325
+ }
321
326
  if (options.keepOpen) {
322
327
  const { connectOrLaunch } = await import('./persistent-session.js');
323
328
  return attachLaunchDebug(
@@ -10,6 +10,7 @@ import { launchSafari } from './safari-launch.js';
10
10
  import { isSafariChannel } from './safari-support.js';
11
11
 
12
12
  import { startProcess } from '../utilities/subprocess.js';
13
+ import { withDeadline } from '../traces/deadline.js';
13
14
  import {
14
15
  applyAutomationParityArgs,
15
16
  detectAutomationControlledTriggers,
@@ -308,15 +309,26 @@ function createCloser({
308
309
  temporaryProfile,
309
310
  closeTimeout,
310
311
  closeConnection,
312
+ verbose,
311
313
  }) {
312
314
  const originalClose = closeConnection ?? browser?.close;
313
315
  let closing;
314
316
  const close = () => {
315
317
  closing ??= (async () => {
316
318
  if (browserProcess.exitCode === null && originalClose) {
317
- await requestBrowserClose({ engine, browser, originalClose }).catch(
318
- () => {}
319
- );
319
+ // A signal can sever the engine transport while its close RPC is pending.
320
+ // Keep the existing close budget active until owned-process cleanup runs.
321
+ await withDeadline(
322
+ requestBrowserClose({ engine, browser, originalClose }),
323
+ closeTimeout,
324
+ 'browser close'
325
+ ).catch((error) => {
326
+ if (verbose) {
327
+ console.debug(
328
+ `[browser-commander] Engine close failed: ${error.message}; finishing owned process cleanup`
329
+ );
330
+ }
331
+ });
320
332
  }
321
333
  if (!(await waitForExit(browserProcess, closeTimeout))) {
322
334
  browserProcess.kill();
@@ -755,6 +767,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
755
767
  temporaryProfile,
756
768
  closeTimeout,
757
769
  closeConnection: engine === 'selenium' ? connection.close : undefined,
770
+ verbose,
758
771
  });
759
772
  return await installSessionPersistence(
760
773
  {
@@ -52,6 +52,14 @@ export function attachCommanderFeatures(commander, options, recreate) {
52
52
  let debugSession;
53
53
  let detachActions;
54
54
  commander.tracePath = null;
55
+ commander.stopDebug = async (stopOptions) => {
56
+ await commander.ready;
57
+ try {
58
+ return (await debugSession?.stop(stopOptions)) ?? null;
59
+ } finally {
60
+ detachActions?.();
61
+ }
62
+ };
55
63
  const ensureDebug = () => commander.ready;
56
64
  for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
57
65
  const method = commander[name];
@@ -71,10 +79,14 @@ export function attachCommanderFeatures(commander, options, recreate) {
71
79
  commander.destroy = async () => {
72
80
  try {
73
81
  await commander.ready;
74
- await debugSession?.stop();
75
- await Promise.all([...managed].map((item) => item.stop()));
76
- detachActions?.();
82
+ try {
83
+ await commander.stopDebug();
84
+ } finally {
85
+ await Promise.all([...managed].map((item) => item.stop()));
86
+ detachActions?.();
87
+ }
77
88
  } finally {
89
+ detachActions?.();
78
90
  await destroy();
79
91
  }
80
92
  };
package/src/factory.js CHANGED
@@ -33,6 +33,7 @@ import { createCdpSession } from './browser/cdp-session.js';
33
33
  * @param {boolean} options.enableNetworkTracking - Enable network request tracking (default: true)
34
34
  * @param {boolean} options.enableNavigationManager - Enable navigation manager (default: true)
35
35
  * @param {Object} [options.downloads] - A download manager to expose as commander.downloads, such as the one launchBrowser() returned
36
+ * @param {boolean|Object} [options.debug=false] - Opt-in recording: true, {output}, or {directory, retain, maxDurationMs, limits} for managed storage
36
37
  * @returns {Object} - Browser commander API
37
38
  */
38
39
  export function makeBrowserCommander(options = {}) {
@@ -141,6 +141,10 @@ export function observeCommanderActions({
141
141
  commander[name] = original;
142
142
  }
143
143
  }
144
- commanders.delete(commander);
144
+ // A repeated teardown may belong to an older, already stopped recording.
145
+ // Preserve observers installed by a newer explicit trace on this commander.
146
+ if (commanders.get(commander) === state) {
147
+ commanders.delete(commander);
148
+ }
145
149
  };
146
150
  }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { openTraceBundle } from './bundle.js';
5
+
6
+ /** Managed debug sessions own only a fresh child of the caller's storage root. */
7
+ export function validateDebugConfig(config = {}) {
8
+ if (config.retain !== undefined && typeof config.retain !== 'boolean') {
9
+ throw new TypeError('debug retain must be a boolean');
10
+ }
11
+ if (config.retain === false && config.directory === undefined) {
12
+ throw new TypeError('debug retain:false requires a managed directory');
13
+ }
14
+ if (config.directory === undefined) {
15
+ return;
16
+ }
17
+ if (typeof config.directory !== 'string' || config.directory === '') {
18
+ throw new TypeError('debug managed directory must be a path');
19
+ }
20
+ if (config.output !== undefined || config.limits?.rotate) {
21
+ throw new TypeError(
22
+ 'debug managed directory cannot use output or rotation'
23
+ );
24
+ }
25
+ if (config.links && config.links !== false) {
26
+ throw new TypeError('debug managed directory uses links:false');
27
+ }
28
+ if (typeof config.network?.har === 'string') {
29
+ throw new TypeError('debug managed HAR must stay inside its directory');
30
+ }
31
+ for (const [name, value] of Object.entries({
32
+ maxBundleBytes: config.limits?.maxBundleBytes ?? 8 * 1024 * 1024,
33
+ maxResourceBytes: config.limits?.maxResourceBytes ?? 1024 * 1024,
34
+ maxDurationMs: config.maxDurationMs ?? 60000,
35
+ })) {
36
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2147483647) {
37
+ throw new RangeError(
38
+ `debug managed ${name} must be a finite positive integer`
39
+ );
40
+ }
41
+ }
42
+ }
43
+
44
+ export async function prepareDebugOutput(options, config) {
45
+ if (config.directory !== undefined) {
46
+ const directory = path.resolve(config.directory);
47
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
48
+ const output = await fs.mkdtemp(path.join(directory, 'session-'));
49
+ // No unbounded side export or rotation bypasses the managed bundle budget.
50
+ return {
51
+ output,
52
+ owned: true,
53
+ maxDurationMs: config.maxDurationMs ?? 60000,
54
+ traceOptions: {
55
+ ...config,
56
+ // Continuous traces otherwise rotate automatically when the budget fills.
57
+ openBundle: openTraceBundle,
58
+ links: false,
59
+ network: config.network ?? { har: false },
60
+ limits: {
61
+ ...config.limits,
62
+ maxBundleBytes: config.limits?.maxBundleBytes ?? 8 * 1024 * 1024,
63
+ maxResourceBytes: config.limits?.maxResourceBytes ?? 1024 * 1024,
64
+ },
65
+ },
66
+ };
67
+ }
68
+ return {
69
+ output: path.resolve(
70
+ config.output ??
71
+ options.output ??
72
+ process.env.BROWSER_COMMANDER_TRACE ??
73
+ path.join(
74
+ 'browser-commander-traces',
75
+ `${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
76
+ )
77
+ ),
78
+ owned: false,
79
+ traceOptions: config,
80
+ };
81
+ }
@@ -1,15 +1,23 @@
1
- import path from 'node:path';
2
- import { randomUUID } from 'node:crypto';
1
+ import fs from 'node:fs/promises';
3
2
  import { startTrace } from './recorder.js';
3
+ import { prepareDebugOutput, validateDebugConfig } from './debug-output.js';
4
4
 
5
5
  const sessions = new WeakMap();
6
6
  const disabledPages = new WeakSet();
7
7
 
8
8
  export function debugEnabled(options = {}) {
9
+ if (options.debug === false || process.env.BROWSER_COMMANDER_DEBUG === '0') {
10
+ return false;
11
+ }
12
+ if (options.debug === undefined && disabledPages.has(options.page)) {
13
+ return false;
14
+ }
9
15
  return (
10
- options.debug !== false &&
11
- process.env.BROWSER_COMMANDER_DEBUG !== '0' &&
12
- !(options.debug === undefined && disabledPages.has(options.page))
16
+ options.debug === true ||
17
+ (options.debug !== null && typeof options.debug === 'object') ||
18
+ (options.debug === undefined &&
19
+ (sessions.has(options.page) ||
20
+ process.env.BROWSER_COMMANDER_DEBUG === '1'))
13
21
  );
14
22
  }
15
23
 
@@ -18,7 +26,9 @@ export async function ensureDebugSession(options) {
18
26
  const { page, commander } = options;
19
27
  const existing = sessions.get(page);
20
28
  if (!debugEnabled(options)) {
21
- disabledPages.add(page);
29
+ if (options.debug === false) {
30
+ disabledPages.add(page);
31
+ }
22
32
  await existing?.stop();
23
33
  return null;
24
34
  }
@@ -27,50 +37,78 @@ export async function ensureDebugSession(options) {
27
37
  if (!session) {
28
38
  const config =
29
39
  options.debug && typeof options.debug === 'object' ? options.debug : {};
30
- const output = path.resolve(
31
- config.output ??
32
- options.output ??
33
- process.env.BROWSER_COMMANDER_TRACE ??
34
- path.join(
35
- 'browser-commander-traces',
36
- `${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
37
- )
38
- );
39
- session = { path: output };
40
+ validateDebugConfig(config);
41
+ session = { path: null, owned: false };
40
42
  sessions.set(page, session);
41
- session.ready = startTrace({
42
- ...config,
43
- output,
44
- page,
45
- commander: commander ?? {
46
- page,
47
- engine: options.engine,
48
- downloads: options.downloads,
49
- },
50
- checkpointOnNavigation: config.checkpointOnNavigation ?? true,
51
- })
52
- .then((trace) => {
53
- console.error(`[browser-commander] Recording session to ${output}`);
43
+ let timer;
44
+ session.ready = prepareDebugOutput(options, config)
45
+ .then(async (prepared) => {
46
+ session.path = prepared.output;
47
+ session.owned = prepared.owned;
48
+ const trace = await startTrace({
49
+ ...prepared.traceOptions,
50
+ output: prepared.output,
51
+ page,
52
+ commander: commander ?? {
53
+ page,
54
+ engine: options.engine,
55
+ downloads: options.downloads,
56
+ },
57
+ checkpointOnNavigation: config.checkpointOnNavigation ?? true,
58
+ });
59
+ console.error(
60
+ `[browser-commander] Recording session to ${session.path}`
61
+ );
62
+ if (prepared.maxDurationMs) {
63
+ timer = setTimeout(() => {
64
+ session
65
+ .stop()
66
+ .catch((error) =>
67
+ console.warn(
68
+ `[browser-commander] Could not finish recording: ${error.message}`
69
+ )
70
+ );
71
+ }, prepared.maxDurationMs);
72
+ timer.unref?.();
73
+ }
54
74
  return trace;
55
75
  })
56
- .catch((error) => {
76
+ .catch(async (error) => {
57
77
  sessions.delete(page);
78
+ if (session.owned) {
79
+ await fs.rm(session.path, { recursive: true, force: true });
80
+ }
58
81
  console.warn(
59
82
  `[browser-commander] Could not start recording: ${error.message}`
60
83
  );
61
84
  return null;
62
85
  });
63
86
  let stopping;
64
- session.stop = () =>
65
- (stopping ??= session.ready.then(async (trace) => {
87
+ session.stop = (stopOptions = {}) => {
88
+ const discard = stopOptions.discard ?? config.retain === false;
89
+ if (discard && !session.owned && session.path) {
90
+ return Promise.reject(
91
+ new Error('Only a managed debug directory can be discarded')
92
+ );
93
+ }
94
+ return (stopping ??= session.ready.then(async (trace) => {
95
+ if (discard && trace && !session.owned) {
96
+ throw new Error('Only a managed debug directory can be discarded');
97
+ }
98
+ clearTimeout(timer);
99
+ disabledPages.add(page);
66
100
  try {
67
- return await trace?.stop();
101
+ return await trace?.stop({ ...stopOptions, discard });
68
102
  } finally {
69
103
  if (sessions.get(page) === session) {
70
104
  sessions.delete(page);
71
105
  }
106
+ if (discard && session.owned) {
107
+ await fs.rm(session.path, { recursive: true, force: true });
108
+ }
72
109
  }
73
110
  }));
111
+ };
74
112
  }
75
113
  const trace = await session.ready;
76
114
  if (!trace) {
@@ -93,6 +131,8 @@ export async function attachLaunchDebug(result, options) {
93
131
  downloads: result.downloads,
94
132
  });
95
133
  result.tracePath = session?.path ?? null;
134
+ result.stopDebug = (options) =>
135
+ session?.stop(options) ?? Promise.resolve(null);
96
136
  if (!session) {
97
137
  return result;
98
138
  }
@@ -9,7 +9,7 @@ export async function startDetachedProcess(file, args = [], options = {}) {
9
9
  runner.child.once('error', reject);
10
10
  });
11
11
  const child = runner.child;
12
- // command-stream 1.x starts noninteractive children in their own process
12
+ // command-stream starts noninteractive children in their own process
13
13
  // group. Release its controller-owned cancellation registry while retaining
14
14
  // the child's exit listeners. Keep this adapter isolated until it exposes a
15
15
  // public detach operation; normal subprocesses retain managed cancellation.
@@ -0,0 +1,96 @@
1
+ /** Real launch and capture contracts for the Command Stream upgrade (#164). */
2
+ import { it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import UPNG from 'upng-js';
5
+ import {
6
+ screenshot,
7
+ startRecording,
8
+ encodeAnimation,
9
+ encodeVideo,
10
+ launchBrowser,
11
+ } from '../../src/index.js';
12
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
13
+
14
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
15
+ it(
16
+ `${engine}: screenshots and bounded animation/video capture after a real launch`,
17
+ { skip: !process.env.RUN_E2E, timeout: 60000 },
18
+ async () => {
19
+ const session = await launchBrowser({
20
+ engine,
21
+ headless: process.env.HEADLESS !== 'false',
22
+ ...CHROME_LAUNCH_OPTIONS,
23
+ ...(engine === 'selenium' ? { bidi: true } : {}),
24
+ });
25
+ let recording;
26
+ try {
27
+ const { page } = session;
28
+ await page.goto(
29
+ 'data:text/html,<input id="focus"><div style="height:2000px;background:teal">Capture fixture</div>'
30
+ );
31
+ const state = () =>
32
+ page.evaluate(() => ({
33
+ scroll: window.scrollY,
34
+ active: document.activeElement.id,
35
+ }));
36
+ await page.evaluate(() => {
37
+ document.querySelector('input').focus();
38
+ window.scrollTo(0, 200);
39
+ });
40
+ const before = await state();
41
+ const png = await screenshot({ page, engine, stableViewport: true });
42
+ assert.equal(png.subarray(1, 4).toString(), 'PNG');
43
+ assert.deepEqual(await state(), before);
44
+ const jpeg = await screenshot({ page, engine, format: 'jpeg' });
45
+ assert.equal(jpeg.readUInt16BE(0), 0xffd8);
46
+ const webp = await screenshot({ page, engine, format: 'webp' });
47
+ assert.equal(webp.subarray(8, 12).toString(), 'WEBP');
48
+
49
+ recording = await startRecording({
50
+ page,
51
+ engine,
52
+ format: 'frames',
53
+ fps: 2,
54
+ maxFrames: 2,
55
+ maxBytes: 1024 * 1024,
56
+ maxDurationMs: 3000,
57
+ size: { width: 120, height: 100 },
58
+ });
59
+ await page.evaluate(() => {
60
+ document.querySelector('div').style.background = 'red';
61
+ });
62
+ const deadline = Date.now() + 3000;
63
+ while (recording.frames.length < 2 && Date.now() < deadline) {
64
+ await new Promise((resolve) => setTimeout(resolve, 50));
65
+ }
66
+ const { frames } = await recording.stop();
67
+ assert.equal(frames.length, 2);
68
+ const gif = await encodeAnimation(frames, { format: 'gif', fps: 2 });
69
+ assert.equal(gif.subarray(0, 6).toString(), 'GIF89a');
70
+ const apng = await encodeAnimation(frames, { format: 'apng', fps: 2 });
71
+ assert.equal(UPNG.decode(apng).frames.length, 2);
72
+ const animatedWebp = await encodeAnimation(frames, {
73
+ format: 'webp',
74
+ fps: 2,
75
+ });
76
+ assert.equal(
77
+ animatedWebp.toString('latin1').split('ANMF').length - 1,
78
+ 2
79
+ );
80
+ const video = await encodeVideo(frames, {
81
+ page,
82
+ engine,
83
+ format: 'webm',
84
+ fps: 2,
85
+ });
86
+ assert.equal(video.readUInt32BE(0), 0x1a45dfa3);
87
+ } finally {
88
+ try {
89
+ await recording?.stop();
90
+ } finally {
91
+ await session.close();
92
+ }
93
+ }
94
+ }
95
+ );
96
+ }
@@ -0,0 +1,169 @@
1
+ import { clearDebugEnvironment } from '../helpers/debug-environment.js';
2
+ import { it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { once } from 'node:events';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { launchBrowser } from '../../src/browser/launcher.js';
11
+ import { makeBrowserCommander } from '../../src/factory.js';
12
+ import { readTrace } from '../../src/traces/reader.js';
13
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
14
+
15
+ function navigate(commander, url, timeout = 5000) {
16
+ return commander.goto({
17
+ url,
18
+ timeout,
19
+ waitForStableUrlBefore: false,
20
+ waitForStableUrlAfter: false,
21
+ waitForNetworkIdle: false,
22
+ });
23
+ }
24
+
25
+ async function fixture(run) {
26
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-readonly-'));
27
+ const cwd = process.cwd();
28
+ const restoreEnvironment = clearDebugEnvironment();
29
+ const readonly = path.join(root, 'checkout');
30
+ const evidence = path.join(root, 'evidence');
31
+ await fs.mkdir(readonly);
32
+ await fs.mkdir(evidence);
33
+ await fs.writeFile(path.join(evidence, 'keep.txt'), 'caller evidence');
34
+ await fs.chmod(readonly, 0o555);
35
+ process.chdir(readonly);
36
+ try {
37
+ if (process.platform !== 'win32') {
38
+ await assert.rejects(fs.access(readonly, fs.constants.W_OK));
39
+ }
40
+ await run({ readonly, evidence });
41
+ assert.deepEqual(await fs.readdir(readonly), []);
42
+ } finally {
43
+ process.chdir(cwd);
44
+ restoreEnvironment();
45
+ await fs.chmod(readonly, 0o700);
46
+ await fs.rm(root, { recursive: true, force: true });
47
+ }
48
+ }
49
+
50
+ for (const engine of ['playwright', 'puppeteer']) {
51
+ it(
52
+ `navigates in read-only cwd and cleans managed recording with ${engine}`,
53
+ {
54
+ skip: process.env.RUN_E2E !== 'true',
55
+ timeout: 90000,
56
+ },
57
+ async () => {
58
+ await fixture(async ({ evidence }) => {
59
+ let launched, commander;
60
+ const managedCommander = () =>
61
+ makeBrowserCommander({
62
+ page: launched.page,
63
+ debug: { directory: evidence, retain: false },
64
+ });
65
+ try {
66
+ launched = await launchBrowser({
67
+ engine,
68
+ headless: true,
69
+ ...CHROME_LAUNCH_OPTIONS,
70
+ });
71
+ commander = makeBrowserCommander({ page: launched.page });
72
+ await navigate(
73
+ commander,
74
+ 'data:text/html,<h1>No recording requested</h1>'
75
+ );
76
+ assert.equal(launched.tracePath, null);
77
+ assert.equal(commander.tracePath, null);
78
+ await commander.destroy();
79
+ commander = managedCommander();
80
+ await navigate(commander, 'data:text/html,<h1>Owned recording</h1>');
81
+ assert.equal(path.dirname(commander.tracePath), evidence);
82
+ await commander.stopDebug({ discard: false });
83
+ const trace = await readTrace(commander.tracePath);
84
+ assert.equal(trace.manifest.outcome, 'complete');
85
+ assert.ok(trace.events.some((event) => event.source === 'commander'));
86
+ // First stop determines retention; retained output is now caller-owned.
87
+ await fs.rm(commander.tracePath, { recursive: true });
88
+ await commander.destroy();
89
+ commander = managedCommander();
90
+ await commander.ready;
91
+ await assert.rejects(
92
+ navigate(commander, 'http://127.0.0.1:1/'),
93
+ /ERR_UNSAFE_PORT/
94
+ );
95
+ } finally {
96
+ try {
97
+ await commander?.destroy();
98
+ } finally {
99
+ await launched?.close();
100
+ }
101
+ }
102
+ assert.deepEqual(await fs.readdir(evidence), ['keep.txt']);
103
+ });
104
+ }
105
+ );
106
+ }
107
+
108
+ for (const signal of ['SIGINT', 'SIGTERM']) {
109
+ it(
110
+ `application signal handler cleans managed recording on ${signal}`,
111
+ {
112
+ skip: process.env.RUN_E2E !== 'true' || process.platform === 'win32',
113
+ timeout: 90000,
114
+ },
115
+ async () => {
116
+ await fixture(async ({ readonly, evidence }) => {
117
+ const script = fileURLToPath(
118
+ new URL(
119
+ '../../../examples/managed-debug-session.mjs',
120
+ import.meta.url
121
+ )
122
+ );
123
+ const child = spawn(
124
+ process.execPath,
125
+ [script, evidence, '--wait-for-signal'],
126
+ {
127
+ cwd: readonly,
128
+ env: process.env,
129
+ stdio: ['ignore', 'pipe', 'pipe'],
130
+ }
131
+ );
132
+ const exited = once(child, 'exit');
133
+ let output = '',
134
+ errors = '';
135
+ child.stderr.on('data', (data) => {
136
+ errors += data;
137
+ });
138
+ try {
139
+ await new Promise((resolve, reject) => {
140
+ child.stdout.on('data', (data) => {
141
+ output += data;
142
+ if (output.includes('"ready":true')) {
143
+ resolve();
144
+ }
145
+ });
146
+ child.once('error', reject);
147
+ child.once('exit', (code) =>
148
+ reject(new Error(`Child exited ${code}: ${errors}`))
149
+ );
150
+ });
151
+ const { tracePath } = JSON.parse(
152
+ output.split('\n').find((line) => line.includes('"ready":true'))
153
+ );
154
+ assert.equal(path.dirname(tracePath), evidence);
155
+ child.kill(signal);
156
+ const [code, killed] = await exited;
157
+ assert.equal(code, 0, errors);
158
+ assert.equal(killed, null);
159
+ assert.deepEqual(await fs.readdir(evidence), ['keep.txt']);
160
+ } finally {
161
+ if (child.exitCode === null && child.signalCode === null) {
162
+ child.kill('SIGTERM');
163
+ await exited;
164
+ }
165
+ }
166
+ });
167
+ }
168
+ );
169
+ }
@@ -0,0 +1,17 @@
1
+ /** Isolate explicit debug policy tests from the runner's environment opt-out. */
2
+ export function clearDebugEnvironment() {
3
+ const names = ['BROWSER_COMMANDER_DEBUG', 'BROWSER_COMMANDER_TRACE'];
4
+ const previous = names.map((name) => process.env[name]);
5
+ names.forEach((name) => {
6
+ delete process.env[name];
7
+ });
8
+ return () => {
9
+ names.forEach((name, index) => {
10
+ if (previous[index] === undefined) {
11
+ delete process.env[name];
12
+ } else {
13
+ process.env[name] = previous[index];
14
+ }
15
+ });
16
+ };
17
+ }
@@ -460,6 +460,35 @@ describe('launchAndConnectRealBrowser', () => {
460
460
  await assert.rejects(() => access(result.userDataDir), /ENOENT/);
461
461
  });
462
462
 
463
+ it(
464
+ 'finishes owned cleanup when the engine close stalls after interruption',
465
+ { timeout: 1000 },
466
+ async () => {
467
+ const calls = [];
468
+ const browserProcess = fakeProcess(calls);
469
+ const result = await launchAndConnectRealBrowserWithDependencies(
470
+ { engine: 'puppeteer', closeTimeout: 20 },
471
+ {
472
+ resolveExecutable: async () => '/opt/google/chrome',
473
+ reservePort: async () => 40005,
474
+ spawnBrowser: () => browserProcess,
475
+ waitForEndpoint: async () => 'http://127.0.0.1:40005',
476
+ connect: async () => ({
477
+ browser: { close: () => new Promise(() => {}) },
478
+ page: null,
479
+ }),
480
+ }
481
+ );
482
+ temporaryDirectory = result.userDataDir;
483
+ await result.close();
484
+ assert.deepEqual(
485
+ { calls, exitCode: browserProcess.exitCode },
486
+ { calls: [['kill']], exitCode: 143 }
487
+ );
488
+ await assert.rejects(() => access(result.userDataDir), /ENOENT/);
489
+ }
490
+ );
491
+
463
492
  it('passes restriction environment to the browser only', async () => {
464
493
  const before = process.env.GOOGLE_API_KEY;
465
494
  let spawnOptions;
@@ -1,3 +1,4 @@
1
+ import { clearDebugEnvironment } from '../../helpers/debug-environment.js';
1
2
  import { describe, it } from 'node:test';
2
3
  import assert from 'node:assert/strict';
3
4
  import fs from 'node:fs/promises';
@@ -19,23 +20,12 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
19
20
  const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-defaults-'));
20
21
  const page = createFakePage();
21
22
  const commander = createFakeCommander(page, { destroy: async () => {} });
22
- const previous = { ...process.env };
23
- delete process.env.BROWSER_COMMANDER_DEBUG;
24
- delete process.env.BROWSER_COMMANDER_TRACE;
23
+ const restoreEnvironment = clearDebugEnvironment();
25
24
  try {
26
25
  await run({ output, page, commander });
27
26
  } finally {
28
27
  await commander.destroy();
29
- for (const key of [
30
- 'BROWSER_COMMANDER_DEBUG',
31
- 'BROWSER_COMMANDER_TRACE',
32
- ]) {
33
- if (previous[key] === undefined) {
34
- delete process.env[key];
35
- } else {
36
- process.env[key] = previous[key];
37
- }
38
- }
28
+ restoreEnvironment();
39
29
  await fs.rm(output, { recursive: true, force: true });
40
30
  }
41
31
  }
@@ -112,7 +102,7 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
112
102
  });
113
103
  });
114
104
 
115
- it('records the very first call with no options and chooses a unique session directory', async () => {
105
+ it('records the very first call with debug:true and chooses a unique session directory', async () => {
116
106
  await fixture(async ({ output, page }) => {
117
107
  const cwd = process.cwd();
118
108
  process.chdir(output);
@@ -123,6 +113,7 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
123
113
  try {
124
114
  commander = makeBrowserCommander({
125
115
  page,
116
+ debug: true,
126
117
  enableNetworkTracking: false,
127
118
  enableNavigationManager: false,
128
119
  enableDialogManager: false,
@@ -0,0 +1,333 @@
1
+ import { clearDebugEnvironment } from '../../helpers/debug-environment.js';
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { attachCommanderFeatures } from '../../../src/core/commander-features.js';
8
+ import { attachLaunchDebug } from '../../../src/traces/debug-session.js';
9
+ import { readTrace } from '../../../src/traces/reader.js';
10
+ import { launchBrowserWithDependencies } from '../../../src/browser/launcher.js';
11
+ import {
12
+ createFakePage,
13
+ createFakeCommander,
14
+ } from '../../helpers/trace-fixtures.js';
15
+
16
+ describe('opt-in recording policy (issue #166)', { timeout: 15000 }, () => {
17
+ async function fixture(run) {
18
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-policy-'));
19
+ const page = createFakePage();
20
+ const commander = createFakeCommander(page, { destroy: async () => {} });
21
+ const restoreEnvironment = clearDebugEnvironment();
22
+ const cwd = process.cwd();
23
+ process.chdir(root);
24
+ try {
25
+ const configureDebug = async (debug) => {
26
+ attachCommanderFeatures(commander, { debug }, () => {});
27
+ await commander.ready;
28
+ };
29
+ await run({ root, page, commander, configureDebug });
30
+ } finally {
31
+ await commander.destroy();
32
+ process.chdir(cwd);
33
+ restoreEnvironment();
34
+ await fs.rm(root, { recursive: true, force: true });
35
+ }
36
+ }
37
+
38
+ for (const outputHint of ['none', 'option', 'environment']) {
39
+ it(`navigates without recording or writes with ${outputHint} output hints`, async () => {
40
+ await fixture(async ({ root, page, commander }) => {
41
+ const output = path.join(root, 'unrequested');
42
+ if (outputHint === 'environment') {
43
+ process.env.BROWSER_COMMANDER_TRACE = output;
44
+ }
45
+ attachCommanderFeatures(
46
+ commander,
47
+ outputHint === 'option' ? { output } : {},
48
+ () => {}
49
+ );
50
+ await commander.goto('https://example.com');
51
+ assert.equal(commander.tracePath, null);
52
+ assert.equal(page.listenerCount('request'), 0);
53
+ assert.deepEqual(page.state.initScripts, []);
54
+ assert.deepEqual(await fs.readdir(root), []);
55
+ });
56
+ });
57
+ }
58
+
59
+ it('enables recording through BROWSER_COMMANDER_DEBUG=1', async () => {
60
+ await fixture(async ({ root, commander }) => {
61
+ process.env.BROWSER_COMMANDER_DEBUG = '1';
62
+ process.env.BROWSER_COMMANDER_TRACE = path.join(root, 'requested');
63
+ attachCommanderFeatures(commander, {}, () => {});
64
+ await commander.ready;
65
+ assert.equal(commander.tracePath, process.env.BROWSER_COMMANDER_TRACE);
66
+ });
67
+ });
68
+
69
+ it('leaves default launches unrecorded', async () => {
70
+ await fixture(async ({ root, page }) => {
71
+ const launched = await attachLaunchDebug({ page }, {});
72
+ assert.equal(launched.tracePath, null);
73
+ assert.deepEqual(await fs.readdir(root), []);
74
+ });
75
+ });
76
+
77
+ it('owns a unique managed child, shares it with launch, and discards only that child', async () => {
78
+ await fixture(async ({ root, page, commander }) => {
79
+ const directory = path.join(root, 'evidence');
80
+ await fs.mkdir(directory);
81
+ await fs.writeFile(path.join(directory, 'keep.txt'), 'caller owned');
82
+ let closed = 0;
83
+ const launched = await attachLaunchDebug(
84
+ {
85
+ page,
86
+ close: async () => {
87
+ closed += 1;
88
+ },
89
+ },
90
+ { debug: { directory, retain: false } }
91
+ );
92
+ attachCommanderFeatures(commander, {}, () => {});
93
+ await commander.ready;
94
+ assert.equal(commander.tracePath, launched.tracePath);
95
+ assert.equal(path.dirname(commander.tracePath), directory);
96
+ await commander.goto('https://example.com');
97
+ await commander.destroy();
98
+ await launched.close();
99
+ assert.equal(closed, 1);
100
+ assert.deepEqual(await fs.readdir(directory), ['keep.txt']);
101
+ assert.deepEqual(await fs.readdir(root), ['evidence']);
102
+ });
103
+ });
104
+
105
+ it('retains managed recordings unless discarded, with idempotent explicit stop', async () => {
106
+ await fixture(async ({ root, commander, configureDebug }) => {
107
+ await configureDebug({ directory: root });
108
+ const stopped = await commander.stopDebug();
109
+ assert.equal(stopped, await commander.stopDebug());
110
+ assert.equal(
111
+ (await readTrace(commander.tracePath)).manifest.outcome,
112
+ 'complete'
113
+ );
114
+ });
115
+ });
116
+
117
+ it('allows explicit discard of a managed child', async () => {
118
+ await fixture(async ({ root, commander, configureDebug }) => {
119
+ await configureDebug({ directory: root });
120
+ const stopped = await commander.stopDebug({ discard: true });
121
+ assert.equal(stopped.discarded, true);
122
+ assert.deepEqual(await fs.readdir(root), []);
123
+ });
124
+ });
125
+
126
+ it('leaves later explicit traces intact when debug teardown repeats', async () => {
127
+ await fixture(async ({ root, page, commander, configureDebug }) => {
128
+ await configureDebug({ directory: root, retain: false });
129
+ await commander.stopDebug();
130
+ const first = await commander.startTrace({
131
+ output: path.join(root, 'first-explicit'),
132
+ });
133
+ await commander.stopDebug();
134
+ const second = await commander.startTrace({
135
+ output: path.join(root, 'second-explicit'),
136
+ });
137
+ await first.stop();
138
+ await second.stop();
139
+ let captures = 0;
140
+ page.evaluate = async () => {
141
+ captures += 1;
142
+ };
143
+ await commander.click('#after-teardown');
144
+ assert.equal(captures, 0, 'stopped traces must leave no input labels');
145
+ });
146
+ });
147
+
148
+ it('refuses to discard a caller-supplied output directory', async () => {
149
+ await fixture(async ({ root, commander }) => {
150
+ attachCommanderFeatures(commander, { debug: { output: root } }, () => {});
151
+ await commander.ready;
152
+ await assert.rejects(commander.stopDebug({ discard: true }), /managed/);
153
+ await commander.stopDebug();
154
+ assert.equal((await readTrace(root)).manifest.outcome, 'complete');
155
+ });
156
+ });
157
+
158
+ it('removes its managed child when recorder startup fails', async () => {
159
+ await fixture(async ({ root, page, commander }) => {
160
+ page.failEvaluate('captureSnapshotInPage', new Error('capture failed'));
161
+ attachCommanderFeatures(
162
+ commander,
163
+ { debug: { directory: root, strict: true } },
164
+ () => {}
165
+ );
166
+ await commander.ready;
167
+ assert.equal(commander.tracePath, null);
168
+ assert.equal(page.listenerCount('request'), 0);
169
+ assert.deepEqual(await fs.readdir(root), []);
170
+ });
171
+ });
172
+
173
+ it('cleans managed recording in finally after failed navigation', async () => {
174
+ await fixture(async ({ root, commander }) => {
175
+ commander.goto = async () => {
176
+ throw new Error('navigation failed');
177
+ };
178
+ attachCommanderFeatures(
179
+ commander,
180
+ { debug: { directory: root, retain: false } },
181
+ () => {}
182
+ );
183
+ try {
184
+ await assert.rejects(
185
+ commander.goto('https://example.com'),
186
+ /navigation failed/
187
+ );
188
+ } finally {
189
+ await commander.destroy();
190
+ }
191
+ assert.deepEqual(await fs.readdir(root), []);
192
+ });
193
+ });
194
+
195
+ it('stops managed recording automatically at its duration limit', async () => {
196
+ await fixture(async ({ root, commander }) => {
197
+ attachCommanderFeatures(
198
+ commander,
199
+ {
200
+ debug: {
201
+ directory: root,
202
+ retain: false,
203
+ maxDurationMs: 20,
204
+ },
205
+ },
206
+ () => {}
207
+ );
208
+ await commander.ready;
209
+ // Poll actual teardown, bounded by the suite timeout, rather than a fixed sleep.
210
+ while ((await fs.readdir(root)).length) {
211
+ await new Promise((resolve) => setTimeout(resolve, 10));
212
+ }
213
+ assert.equal((await commander.stopDebug()).discarded, true);
214
+ });
215
+ });
216
+
217
+ it('enforces the managed data budget and preserves partial outcome', async () => {
218
+ await fixture(async ({ root, page, commander }) => {
219
+ attachCommanderFeatures(
220
+ commander,
221
+ {
222
+ debug: {
223
+ directory: root,
224
+ limits: { maxBundleBytes: 1024, maxResourceBytes: 512 },
225
+ },
226
+ },
227
+ () => {}
228
+ );
229
+ await commander.ready;
230
+ for (let index = 0; index < 20; index += 1) {
231
+ page.emit('pageerror', new Error('bounded error '.repeat(20)));
232
+ }
233
+ const result = await commander.stopDebug();
234
+ const trace = await readTrace(result.path);
235
+ assert.equal(trace.manifest.outcome, 'partial');
236
+ const events = await fs.stat(path.join(result.path, 'events.ndjson'));
237
+ assert.ok(events.size <= 1024);
238
+ assert.equal(trace.manifest.limits.maxBundleBytes, 1024);
239
+ await assert.rejects(fs.access(path.join(result.path, 'trace.lino')));
240
+ });
241
+ });
242
+
243
+ it('does not create recording storage when browser launch fails', async () => {
244
+ await fixture(async ({ root }) => {
245
+ await assert.rejects(
246
+ launchBrowserWithDependencies(
247
+ { debug: { directory: root } },
248
+ {
249
+ resolveSystem: async () => '/usr/bin/google-chrome',
250
+ launchRealBrowser: async () => {
251
+ throw new Error('launch failed');
252
+ },
253
+ }
254
+ ),
255
+ /launch failed/
256
+ );
257
+ assert.deepEqual(await fs.readdir(root), []);
258
+ });
259
+ });
260
+
261
+ it('discards its child and closes the browser even when finalization fails', async () => {
262
+ await fixture(async ({ root, page }) => {
263
+ let closed = false;
264
+ const launched = await attachLaunchDebug(
265
+ {
266
+ page,
267
+ close: async () => {
268
+ closed = true;
269
+ },
270
+ },
271
+ {
272
+ debug: { directory: root, retain: false },
273
+ }
274
+ );
275
+ await fs.mkdir(path.join(launched.tracePath, 'manifest.json'));
276
+ await assert.rejects(launched.close(), /EISDIR|EPERM|EACCES/);
277
+ assert.equal(closed, true);
278
+ assert.deepEqual(await fs.readdir(root), []);
279
+ });
280
+ });
281
+
282
+ it('validates managed launch configuration before starting the browser', async () => {
283
+ await fixture(async ({ root }) => {
284
+ let started = false;
285
+ await assert.rejects(
286
+ launchBrowserWithDependencies(
287
+ {
288
+ debug: { directory: root, retain: false, maxDurationMs: Infinity },
289
+ },
290
+ {
291
+ launchRealBrowser: async () => {
292
+ started = true;
293
+ },
294
+ }
295
+ ),
296
+ /debug managed/
297
+ );
298
+ assert.equal(started, false);
299
+ assert.deepEqual(await fs.readdir(root), []);
300
+ });
301
+ });
302
+
303
+ for (const debug of [false, true, { directory: '.' }]) {
304
+ it(`environment opt-out prevents recording with debug=${JSON.stringify(debug)}`, async () => {
305
+ await fixture(async ({ root, commander }) => {
306
+ process.env.BROWSER_COMMANDER_DEBUG = '0';
307
+ attachCommanderFeatures(commander, { debug }, () => {});
308
+ await commander.ready;
309
+ assert.equal(commander.tracePath, null);
310
+ assert.deepEqual(await fs.readdir(root), []);
311
+ });
312
+ });
313
+ }
314
+
315
+ for (const debug of [
316
+ { retain: false },
317
+ { directory: '.', output: 'other' },
318
+ { directory: '.', limits: { maxBundleBytes: Infinity } },
319
+ { directory: '.', limits: { rotate: { maxBytes: 1024 } } },
320
+ { directory: '.', links: { output: '../outside.lino' } },
321
+ { directory: '.', network: { har: '../outside.har' } },
322
+ { directory: '.', maxDurationMs: Infinity },
323
+ ]) {
324
+ it(`rejects unsafe managed settings before writing: ${JSON.stringify(debug)}`, async () => {
325
+ await fixture(async ({ root, commander }) => {
326
+ attachCommanderFeatures(commander, { debug }, () => {});
327
+ await assert.rejects(commander.ready, /debug|managed/);
328
+ commander.ready = Promise.resolve();
329
+ assert.deepEqual(await fs.readdir(root), []);
330
+ });
331
+ });
332
+ }
333
+ });
@@ -1,14 +1,18 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
+ import * as fs from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
3
6
  import {
4
7
  CommandError,
5
8
  runCommand,
6
9
  startProcess,
7
10
  } from '../../../src/utilities/subprocess.js';
11
+ import { repoPath } from '../../helpers/repo.js';
8
12
 
9
13
  const node = process.execPath;
10
14
 
11
- describe('runCommand', () => {
15
+ describe('runCommand', { timeout: 10000 }, () => {
12
16
  it('passes arguments without a shell and captures output', async () => {
13
17
  const result = await runCommand(node, [
14
18
  '-e',
@@ -46,9 +50,25 @@ describe('runCommand', () => {
46
50
  const result = await runCommand(node, args, { check: false });
47
51
  assert.equal(result.code, 3);
48
52
  });
53
+
54
+ it('reports a missing executable as exit 127 with ENOENT diagnostics', async () => {
55
+ const file = path.join(tmpdir(), 'bc-missing-executable-164', 'missing');
56
+ await assert.rejects(
57
+ () => runCommand(file),
58
+ (error) => {
59
+ assert.ok(error instanceof CommandError);
60
+ assert.equal(error.code, 127);
61
+ assert.match(error.stderr, /ENOENT/);
62
+ return true;
63
+ }
64
+ );
65
+ const result = await runCommand(file, [], { check: false });
66
+ assert.equal(result.code, 127);
67
+ assert.match(result.stderr, /ENOENT/);
68
+ });
49
69
  });
50
70
 
51
- describe('startProcess', () => {
71
+ describe('startProcess', { timeout: 10000 }, () => {
52
72
  it('streams output and reports the exit code', async () => {
53
73
  const child = startProcess(node, [
54
74
  '-e',
@@ -78,3 +98,61 @@ describe('startProcess', () => {
78
98
  assert.notEqual(exitCode, 0);
79
99
  });
80
100
  });
101
+
102
+ describe(
103
+ 'startDetachedProcess',
104
+ {
105
+ timeout: 10000,
106
+ // Both the original 1.4.0 and 2.0.2 runner disable Windows detachment.
107
+ skip:
108
+ process.platform === 'win32' &&
109
+ 'Command Stream only creates detached process groups on Unix',
110
+ },
111
+ () => {
112
+ it('lets the launcher exit while the detached child survives', async (t) => {
113
+ const directory = await fs.mkdtemp(
114
+ path.join(tmpdir(), 'bc-detached-164-')
115
+ );
116
+ const marker = path.join(directory, 'survived');
117
+ let pid;
118
+ const launcher = startProcess(node, [
119
+ repoPath('experiments', 'issue-164', 'detached-launcher.mjs'),
120
+ marker,
121
+ ]);
122
+ t.after(async () => {
123
+ launcher.kill();
124
+ if (pid) {
125
+ try {
126
+ process.kill(pid, 'SIGKILL');
127
+ } catch (error) {
128
+ if (error.code !== 'ESRCH') {
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+ await fs.rm(directory, { recursive: true, force: true });
134
+ });
135
+ let output = '';
136
+ launcher.stdout.on('data', (chunk) => {
137
+ output += chunk;
138
+ pid = Number(output.trim());
139
+ });
140
+ assert.equal(await launcher.exited, 0, launcher.stderrTail);
141
+ assert.ok(pid > 0);
142
+ await assert.rejects(fs.readFile(marker), { code: 'ENOENT' });
143
+ const deadline = Date.now() + 5000;
144
+ while (Date.now() < deadline) {
145
+ try {
146
+ assert.equal(await fs.readFile(marker, 'utf8'), 'survived');
147
+ return;
148
+ } catch (error) {
149
+ if (error.code !== 'ENOENT') {
150
+ throw error;
151
+ }
152
+ await new Promise((resolve) => setTimeout(resolve, 50));
153
+ }
154
+ }
155
+ assert.fail('detached child did not survive the launcher exit');
156
+ });
157
+ }
158
+ );