browser-commander 0.31.2 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ // feature-parity: sessions.handoff@native-typed
2
+ import { afterEach, describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
7
+
8
+ import {
9
+ BROWSER_HANDOFF_VERSION,
10
+ BrowserHandoffError,
11
+ closeHandedOffBrowser,
12
+ installHandoff,
13
+ reconnectBrowserWithDependencies,
14
+ waitForPidExit,
15
+ } from '../../../src/browser/browser-handoff.js';
16
+ import { launchAndConnectRealBrowserWithDependencies } from '../../../src/browser/real-browser.js';
17
+ import { startProcess } from '../../../src/utilities/subprocess.js';
18
+ import * as api from '../../../src/index.js';
19
+ import { startFakeCdpEndpoint } from '../../helpers/fake-cdp-endpoint.js';
20
+
21
+ const cleanups = [];
22
+ afterEach(async () => {
23
+ for (const cleanup of cleanups.splice(0).reverse()) {
24
+ await cleanup();
25
+ }
26
+ });
27
+
28
+ async function fakeEndpoint(options) {
29
+ const endpoint = await startFakeCdpEndpoint(options);
30
+ cleanups.push(() => endpoint.close());
31
+ return endpoint;
32
+ }
33
+
34
+ function handoffFor(endpoint, overrides = {}) {
35
+ return {
36
+ version: BROWSER_HANDOFF_VERSION,
37
+ engine: 'selenium',
38
+ cdpEndpoint: endpoint.endpoint,
39
+ webSocketDebuggerUrl: endpoint.webSocketDebuggerUrl,
40
+ targetId: 'TARGET-1',
41
+ pid: null,
42
+ userDataDir: null,
43
+ temporaryProfile: false,
44
+ remoteDebuggingPort: null,
45
+ executablePath: null,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ /** A connectBrowser stand-in returning a Selenium-shaped connection. */
51
+ function fakeConnect(calls, { targetId = 'TARGET-1' } = {}) {
52
+ return async (options) => {
53
+ calls.push(['connect', options]);
54
+ return {
55
+ browser: {
56
+ getWindowHandle: async () => `CDwindow-${targetId}`,
57
+ },
58
+ page: { id: 'page' },
59
+ detach: async () => calls.push(['engine-detach']),
60
+ };
61
+ };
62
+ }
63
+
64
+ /** A long-running process in its own process group, like a browser. */
65
+ function sleeper(t, script = 'setInterval(()=>{},1000)') {
66
+ const child = startProcess(process.execPath, ['-e', script], {
67
+ interruption: 'caller',
68
+ });
69
+ cleanups.push(async () => {
70
+ child.kill('SIGKILL');
71
+ await child.exited;
72
+ });
73
+ return child;
74
+ }
75
+
76
+ // These tests stop real processes with POSIX signals and exit codes.
77
+ const posixSignals = {
78
+ skip: process.platform === 'win32' && 'POSIX signals and exit codes',
79
+ };
80
+
81
+ describe('browser hand-off (issue #174)', () => {
82
+ it('is exported from the package API', () => {
83
+ assert.equal(typeof api.reconnectBrowser, 'function');
84
+ assert.equal(api.BrowserHandoffError, BrowserHandoffError);
85
+ assert.equal(api.BROWSER_HANDOFF_VERSION, BROWSER_HANDOFF_VERSION);
86
+ });
87
+
88
+ it('reattaches to the exact tab and never falls back to another page', async () => {
89
+ const endpoint = await fakeEndpoint();
90
+ const calls = [];
91
+ const session = await reconnectBrowserWithDependencies(
92
+ JSON.stringify(handoffFor(endpoint)),
93
+ { verbose: false },
94
+ { connect: fakeConnect(calls) }
95
+ );
96
+ assert.deepEqual(calls, [
97
+ [
98
+ 'connect',
99
+ {
100
+ verbose: false,
101
+ engine: 'selenium',
102
+ cdpEndpoint: endpoint.endpoint,
103
+ targetId: 'TARGET-1',
104
+ fallback: false,
105
+ },
106
+ ],
107
+ ]);
108
+ assert.equal(session.cdpEndpoint, endpoint.endpoint);
109
+ assert.deepEqual(await session.handoff(), handoffFor(endpoint));
110
+ });
111
+
112
+ it('reads a hand-off from a JSON file', async () => {
113
+ const endpoint = await fakeEndpoint();
114
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
115
+ cleanups.push(() => rm(directory, { recursive: true, force: true }));
116
+ const file = path.join(directory, 'handoff.json');
117
+ await writeFile(file, JSON.stringify(handoffFor(endpoint)));
118
+ const calls = [];
119
+ await reconnectBrowserWithDependencies(
120
+ file,
121
+ {},
122
+ {
123
+ connect: fakeConnect(calls),
124
+ }
125
+ );
126
+ assert.equal(calls[0][1].targetId, 'TARGET-1');
127
+ });
128
+
129
+ it('refuses a browser that is gone or replaced instead of launching one', async () => {
130
+ const endpoint = await fakeEndpoint();
131
+ const handoff = handoffFor(endpoint);
132
+ const connect = () => assert.fail('connect reached');
133
+ endpoint.setBrowserId('browser-2');
134
+ await assert.rejects(
135
+ reconnectBrowserWithDependencies(handoff, {}, { connect }),
136
+ (error) =>
137
+ error instanceof BrowserHandoffError &&
138
+ /another browser instance/.test(error.message)
139
+ );
140
+ await endpoint.close();
141
+ await assert.rejects(
142
+ reconnectBrowserWithDependencies(handoff, {}, { connect }),
143
+ /no longer answers/
144
+ );
145
+ });
146
+
147
+ it('reports a closed tab as a hand-off error', async () => {
148
+ const endpoint = await fakeEndpoint();
149
+ await assert.rejects(
150
+ reconnectBrowserWithDependencies(
151
+ handoffFor(endpoint),
152
+ {},
153
+ {
154
+ connect: async () => {
155
+ throw new Error('No page with target id TARGET-1');
156
+ },
157
+ }
158
+ ),
159
+ (error) =>
160
+ error instanceof BrowserHandoffError &&
161
+ /Could not reattach to tab TARGET-1/.test(error.message) &&
162
+ error.cause.message === 'No page with target id TARGET-1'
163
+ );
164
+ });
165
+
166
+ it('validates the hand-off before touching the network', async () => {
167
+ const connect = () => assert.fail('connect reached');
168
+ await assert.rejects(
169
+ reconnectBrowserWithDependencies(
170
+ { version: 1, cdpEndpoint: 'http://127.0.0.1:1' },
171
+ {},
172
+ { connect }
173
+ ),
174
+ /missing webSocketDebuggerUrl/
175
+ );
176
+ await assert.rejects(
177
+ reconnectBrowserWithDependencies(
178
+ {
179
+ version: 2,
180
+ cdpEndpoint: 'http://127.0.0.1:1',
181
+ webSocketDebuggerUrl: 'ws://127.0.0.1:1/devtools/browser/x',
182
+ targetId: 'x',
183
+ },
184
+ {},
185
+ { connect }
186
+ ),
187
+ /Unsupported browser hand-off version 2/
188
+ );
189
+ });
190
+
191
+ it('detach() hands off once, drops the engine connection, and releases the process', async () => {
192
+ const endpoint = await fakeEndpoint();
193
+ const calls = [];
194
+ const state = {};
195
+ const session = installHandoff(
196
+ {
197
+ browser: { getWindowHandle: async () => 'CDwindow-TAB' },
198
+ page: {},
199
+ detach: async () => calls.push('engine-detach'),
200
+ },
201
+ {
202
+ engine: 'selenium',
203
+ cdpEndpoint: endpoint.endpoint,
204
+ pid: () => 1234,
205
+ connectedBrowser: () => ({ getWindowHandle: async () => 'TAB' }),
206
+ temporaryProfile: true,
207
+ userDataDir: '/tmp/profile',
208
+ release: () => calls.push('release'),
209
+ },
210
+ state
211
+ );
212
+ assert.equal(session.detached, false);
213
+ assert.equal(Object.keys({ ...session }).includes('detached'), false);
214
+ const [first, second] = await Promise.all([
215
+ session.detach(),
216
+ session.detach(),
217
+ ]);
218
+ assert.equal(first, second);
219
+ assert.deepEqual(calls, ['engine-detach', 'release']);
220
+ assert.equal(session.detached, true);
221
+ assert.equal(state.webSocketDebuggerUrl, endpoint.webSocketDebuggerUrl);
222
+ assert.deepEqual(JSON.parse(JSON.stringify(first)), {
223
+ version: BROWSER_HANDOFF_VERSION,
224
+ engine: 'selenium',
225
+ cdpEndpoint: endpoint.endpoint,
226
+ webSocketDebuggerUrl: endpoint.webSocketDebuggerUrl,
227
+ targetId: 'TAB',
228
+ pid: 1234,
229
+ userDataDir: '/tmp/profile',
230
+ temporaryProfile: true,
231
+ remoteDebuggingPort: null,
232
+ executablePath: null,
233
+ });
234
+ assert.equal(await session.handoff(), first);
235
+ });
236
+
237
+ it(
238
+ 'close() of a reconnected session closes the browser and awaits its process',
239
+ posixSignals,
240
+ async (t) => {
241
+ const child = sleeper(t);
242
+ const endpoint = await fakeEndpoint({
243
+ onBrowserClose: () => child.kill('SIGTERM'),
244
+ });
245
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
246
+ cleanups.push(() => rm(directory, { recursive: true, force: true }));
247
+ const session = await reconnectBrowserWithDependencies(
248
+ handoffFor(endpoint, {
249
+ pid: child.pid,
250
+ userDataDir: directory,
251
+ temporaryProfile: true,
252
+ }),
253
+ {},
254
+ { connect: fakeConnect([]) }
255
+ );
256
+ await session.close();
257
+ assert.equal(endpoint.closes.length, 1);
258
+ assert.equal(await child.exited, 128 + 15);
259
+ await assert.rejects(access(directory), { code: 'ENOENT' });
260
+ // Idempotent.
261
+ await session.close();
262
+ assert.equal(endpoint.closes.length, 1);
263
+ }
264
+ );
265
+
266
+ it(
267
+ 'kills the process tree when Browser.close does not stop it',
268
+ posixSignals,
269
+ async (t) => {
270
+ const child = sleeper(t);
271
+ const endpoint = await fakeEndpoint();
272
+ await closeHandedOffBrowser(handoffFor(endpoint, { pid: child.pid }), {
273
+ closeTimeout: 300,
274
+ });
275
+ assert.equal(endpoint.closes.length, 1);
276
+ assert.equal(await child.exited, 128 + 9);
277
+ }
278
+ );
279
+
280
+ it(
281
+ 'never closes a different browser that took over the endpoint',
282
+ posixSignals,
283
+ async (t) => {
284
+ const child = sleeper(t);
285
+ const endpoint = await fakeEndpoint();
286
+ const handoff = handoffFor(endpoint, { pid: child.pid });
287
+ endpoint.setBrowserId('someone-else');
288
+ await closeHandedOffBrowser(handoff, { closeTimeout: 200 });
289
+ assert.equal(endpoint.closes.length, 0);
290
+ assert.equal(await child.exited, 128 + 9);
291
+ }
292
+ );
293
+
294
+ it('waitForPidExit reports whether a process ended in time', async (t) => {
295
+ const child = sleeper(t);
296
+ assert.equal(await waitForPidExit(child.pid, 100), false);
297
+ child.kill('SIGKILL');
298
+ assert.equal(await waitForPidExit(child.pid, 2000), true);
299
+ });
300
+ });
301
+
302
+ describe('launch hand-off (issue #174)', () => {
303
+ function fakeBrowserProcess(calls) {
304
+ const listeners = [];
305
+ const browserProcess = {
306
+ pid: 4321,
307
+ exitCode: null,
308
+ kill: () => {
309
+ calls.push('kill');
310
+ browserProcess.exit(143);
311
+ },
312
+ detach: () => calls.push('process-detach'),
313
+ once: (_event, listener) => {
314
+ if (browserProcess.exitCode === null) {
315
+ listeners.push(listener);
316
+ } else {
317
+ listener(browserProcess.exitCode);
318
+ }
319
+ },
320
+ exit: (code) => {
321
+ browserProcess.exitCode = code;
322
+ for (const listener of listeners.splice(0)) {
323
+ listener(code);
324
+ }
325
+ },
326
+ };
327
+ return browserProcess;
328
+ }
329
+
330
+ async function launch({ interruption } = {}) {
331
+ const calls = [];
332
+ const browserProcess = fakeBrowserProcess(calls);
333
+ const endpoint = await fakeEndpoint({
334
+ onBrowserClose: () => browserProcess.exit(0),
335
+ });
336
+ const cdp = {
337
+ send: async () => ({ targetInfo: { targetId: 'LAUNCHED-TAB' } }),
338
+ detach: async () => {},
339
+ };
340
+ const session = await launchAndConnectRealBrowserWithDependencies(
341
+ {
342
+ engine: 'puppeteer',
343
+ channel: 'chrome',
344
+ ...(interruption ? { interruption } : {}),
345
+ },
346
+ {
347
+ resolveExecutable: async () => '/opt/google/chrome',
348
+ reservePort: async () => 9555,
349
+ spawnBrowser: (_path, _args, options) => {
350
+ calls.push(['spawn', options.interruption]);
351
+ return browserProcess;
352
+ },
353
+ waitForEndpoint: async () => endpoint.endpoint,
354
+ connect: async (options) => {
355
+ calls.push(['connect', 'interruption' in options]);
356
+ return {
357
+ browser: {
358
+ close: async () => {
359
+ calls.push('engine-close');
360
+ browserProcess.exit(0);
361
+ },
362
+ },
363
+ page: { target: () => ({ createCDPSession: async () => cdp }) },
364
+ detach: async () => calls.push('engine-detach'),
365
+ };
366
+ },
367
+ }
368
+ );
369
+ return { session, calls, endpoint, browserProcess };
370
+ }
371
+
372
+ it('passes interruption ownership to the spawn, not to the engine', async () => {
373
+ const managed = await launch();
374
+ assert.deepEqual(managed.calls.slice(0, 2), [
375
+ ['spawn', 'managed'],
376
+ ['connect', false],
377
+ ]);
378
+ await managed.session.close();
379
+ const caller = await launch({ interruption: 'caller' });
380
+ assert.deepEqual(caller.calls[0], ['spawn', 'caller']);
381
+ await caller.session.close();
382
+ await assert.rejects(launch({ interruption: 'detached' }), TypeError);
383
+ });
384
+
385
+ it('detach() leaves the browser running and returns its hand-off', async () => {
386
+ const { session, calls, endpoint, browserProcess } = await launch();
387
+ const handoff = await session.detach();
388
+ assert.equal(handoff.targetId, 'LAUNCHED-TAB');
389
+ assert.equal(handoff.pid, 4321);
390
+ assert.equal(handoff.remoteDebuggingPort, 9555);
391
+ assert.equal(handoff.temporaryProfile, true);
392
+ assert.equal(handoff.webSocketDebuggerUrl, endpoint.webSocketDebuggerUrl);
393
+ assert.deepEqual(calls.slice(2), ['engine-detach', 'process-detach']);
394
+ assert.equal(browserProcess.exitCode, null);
395
+ await access(handoff.userDataDir);
396
+
397
+ // The launch still owns the browser: close() closes it over its
398
+ // WebSocket (the engine connection is gone) and awaits the process.
399
+ await session.close();
400
+ assert.equal(endpoint.closes.length, 1);
401
+ assert.equal(browserProcess.exitCode, 0);
402
+ assert.equal(calls.includes('engine-close'), false);
403
+ assert.equal(calls.includes('kill'), false);
404
+ await assert.rejects(access(handoff.userDataDir), { code: 'ENOENT' });
405
+ });
406
+ });
@@ -232,6 +232,29 @@ describe('launchBrowser', () => {
232
232
  await result.close();
233
233
  assert.deepEqual(calls.at(-1), ['browser.close']);
234
234
  });
235
+
236
+ it("leaves Ctrl+C to the caller with interruption: 'caller' (#174)", async () => {
237
+ for (const [interruption, expected] of [
238
+ [undefined, undefined],
239
+ ['managed', undefined],
240
+ ['caller', false],
241
+ ]) {
242
+ const calls = [];
243
+ const result = await launchBrowserWithDependencies(
244
+ { launch: 'engine', interruption },
245
+ { loadEngineModule: async () => fakePlaywright(calls), settleMs: 0 }
246
+ );
247
+ assert.equal(calls[0][2].handleSIGINT, expected);
248
+ await result.close();
249
+ }
250
+ await assert.rejects(
251
+ launchBrowserWithDependencies(
252
+ { launch: 'engine', interruption: 'nobody' },
253
+ { loadEngineModule: async () => fakePlaywright([]), settleMs: 0 }
254
+ ),
255
+ /interruption must be one of managed, caller/
256
+ );
257
+ });
235
258
  });
236
259
  });
237
260
 
@@ -266,7 +266,7 @@ describe('launchAndConnectRealBrowser', () => {
266
266
  '--remote-debugging-port=9444',
267
267
  'about:blank',
268
268
  ],
269
- { env: undefined, verbose: false },
269
+ { env: undefined, interruption: 'managed', verbose: false },
270
270
  ]);
271
271
  assert.deepEqual(calls[1], ['wait', 9444]);
272
272
  assert.deepEqual(calls[2], [
@@ -0,0 +1,182 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import assert from 'node:assert';
5
+ import { describe, it } from 'node:test';
6
+ import {
7
+ interruptionMode,
8
+ startProcess,
9
+ } from '../../../src/utilities/subprocess.js';
10
+ import { repoPath } from '../../helpers/repo.js';
11
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
+
13
+ // feature-parity: process.interruption@native-typed
14
+
15
+ const node = process.execPath;
16
+ const delayedShutdown = repoPath(
17
+ 'experiments',
18
+ 'issue-172',
19
+ 'delayed-shutdown.mjs'
20
+ );
21
+ const unixOnly = {
22
+ timeout: 15000,
23
+ skip:
24
+ process.platform === 'win32' &&
25
+ 'Command Stream only creates separate process groups on Unix',
26
+ };
27
+
28
+ function isAlive(pid) {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (error) {
33
+ if (error.code === 'ESRCH') {
34
+ return false;
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function forceKill(pid) {
41
+ if (pid && isAlive(pid)) {
42
+ process.kill(pid, 'SIGKILL');
43
+ }
44
+ }
45
+
46
+ async function tempDirectory(t, prefix) {
47
+ const directory = await fs.mkdtemp(path.join(tmpdir(), prefix));
48
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
49
+ return directory;
50
+ }
51
+
52
+ /**
53
+ * Run an embedding supervisor that interrupts itself, keeps its child from
54
+ * its own SIGINT handler, and exits. Returns whether the child survived.
55
+ */
56
+ async function childSurvivesSupervisorInterrupt(t, mode) {
57
+ const directory = await tempDirectory(t, 'bc-interrupt-174-');
58
+ const pidFile = path.join(directory, 'pid');
59
+ const supervisor = startProcess(node, [
60
+ repoPath('experiments', 'issue-174', 'sigint-supervisor.mjs'),
61
+ mode,
62
+ pidFile,
63
+ ]);
64
+ assert.equal(await supervisor.exited, 130, supervisor.stderrTail);
65
+ const pid = Number(await fs.readFile(pidFile, 'utf8'));
66
+ t.after(() => forceKill(pid));
67
+ assert.ok(pid > 0);
68
+ await new Promise((resolve) => setTimeout(resolve, 300));
69
+ return isAlive(pid);
70
+ }
71
+
72
+ describe('interruption ownership (issue #174)', unixOnly, () => {
73
+ it('forwards SIGINT to the child by default, before the embedder can keep it', async (t) => {
74
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'managed'), false);
75
+ assert.equal(
76
+ await childSurvivesSupervisorInterrupt(t, 'detach-in-handler'),
77
+ false
78
+ );
79
+ });
80
+
81
+ it("leaves SIGINT to the embedder's handler with interruption: 'caller'", async (t) => {
82
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'caller'), true);
83
+ });
84
+
85
+ it('keeps streaming output and reporting the exit code for a caller-owned child', async () => {
86
+ const child = startProcess(
87
+ node,
88
+ [
89
+ '-e',
90
+ 'setTimeout(()=>{process.stderr.write("hello\\n");process.exit(4)},100)',
91
+ ],
92
+ { interruption: 'caller' }
93
+ );
94
+ assert.equal(child.interruption, 'caller');
95
+ let stderr = '';
96
+ child.stderr.on('data', (chunk) => {
97
+ stderr += chunk;
98
+ });
99
+ assert.equal(await child.exited, 4);
100
+ assert.equal(stderr, 'hello\n');
101
+ assert.equal(child.kill(), false);
102
+ });
103
+
104
+ it('kill() still awaits the actual exit of a caller-owned child', async (t) => {
105
+ const directory = await tempDirectory(t, 'bc-caller-kill-174-');
106
+ const marker = path.join(directory, 'flushed');
107
+ const child = startProcess(node, [delayedShutdown, marker], {
108
+ interruption: 'caller',
109
+ killGrace: 1000,
110
+ });
111
+ t.after(() => forceKill(child.pid));
112
+ await new Promise((resolve) => child.stdout.on('data', resolve));
113
+ assert.equal(child.kill(), true);
114
+ assert.equal(await child.exited, 0);
115
+ assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
116
+ assert.equal(isAlive(child.pid), false);
117
+ });
118
+
119
+ it('escalates to SIGKILL when a caller-owned child ignores SIGTERM', async (t) => {
120
+ const child = startProcess(
121
+ node,
122
+ [
123
+ '-e',
124
+ 'process.on("SIGTERM",()=>{});process.stdout.write("ready");setInterval(()=>{},1000)',
125
+ ],
126
+ { interruption: 'caller', killGrace: 200 }
127
+ );
128
+ t.after(() => forceKill(child.pid));
129
+ await new Promise((resolve) => child.stdout.on('data', resolve));
130
+ child.kill();
131
+ assert.equal(await child.exited, 128 + 9);
132
+ assert.equal(child.signalCode, 'SIGKILL');
133
+ });
134
+
135
+ it('detach() lets the supervisor end while the exact child keeps running', async (t) => {
136
+ const directory = await tempDirectory(t, 'bc-detach-174-');
137
+ const marker = path.join(directory, 'survived');
138
+ const launcher = startProcess(node, [
139
+ repoPath('experiments', 'issue-174', 'detach-launcher.mjs'),
140
+ marker,
141
+ ]);
142
+ let output = '';
143
+ launcher.stdout.on('data', (chunk) => {
144
+ output += chunk;
145
+ });
146
+ assert.equal(await launcher.exited, 0, launcher.stderrTail);
147
+ const { pid, detached } = JSON.parse(output);
148
+ t.after(() => forceKill(pid));
149
+ assert.equal(detached, true);
150
+ assert.equal(isAlive(pid), true);
151
+ assert.equal(
152
+ await waitForFileText(marker, 'survived'),
153
+ true,
154
+ 'detached child did not outlive its supervisor'
155
+ );
156
+ });
157
+
158
+ it('detach() is idempotent and a detached child can still be stopped', async () => {
159
+ const child = startProcess(node, ['-e', 'setInterval(()=>{},1000)'], {
160
+ killGrace: 200,
161
+ });
162
+ await new Promise((resolve) => setTimeout(resolve, 100));
163
+ assert.equal(child.detach(), true);
164
+ assert.equal(child.detach(), true);
165
+ assert.equal(child.detached, true);
166
+ assert.equal(child.kill(), true);
167
+ assert.equal(await child.exited, 128 + 15);
168
+ assert.equal(child.detach(), false);
169
+ });
170
+ });
171
+
172
+ describe('interruptionMode', () => {
173
+ it('defaults to managed and rejects unknown modes', () => {
174
+ assert.equal(interruptionMode(), 'managed');
175
+ assert.equal(interruptionMode('caller'), 'caller');
176
+ assert.throws(() => interruptionMode('detached'), TypeError);
177
+ assert.throws(
178
+ () => startProcess(node, [], { interruption: 'none' }),
179
+ /interruption must be one of managed, caller/
180
+ );
181
+ });
182
+ });
@@ -9,6 +9,7 @@ import {
9
9
  startProcess,
10
10
  } from '../../../src/utilities/subprocess.js';
11
11
  import { repoPath } from '../../helpers/repo.js';
12
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
13
 
13
14
  const node = process.execPath;
14
15
 
@@ -173,19 +174,11 @@ describe(
173
174
  assert.equal(await launcher.exited, 0, launcher.stderrTail);
174
175
  assert.ok(pid > 0);
175
176
  await assert.rejects(fs.readFile(marker), { code: 'ENOENT' });
176
- const deadline = Date.now() + 5000;
177
- while (Date.now() < deadline) {
178
- try {
179
- assert.equal(await fs.readFile(marker, 'utf8'), 'survived');
180
- return;
181
- } catch (error) {
182
- if (error.code !== 'ENOENT') {
183
- throw error;
184
- }
185
- await new Promise((resolve) => setTimeout(resolve, 50));
186
- }
187
- }
188
- assert.fail('detached child did not survive the launcher exit');
177
+ assert.equal(
178
+ await waitForFileText(marker, 'survived'),
179
+ true,
180
+ 'detached child did not survive the launcher exit'
181
+ );
189
182
  });
190
183
  }
191
184
  );