@monoes/monobrowse 1.0.22 → 1.0.25

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 (38) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.js +94 -1
  2. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
  3. package/dist/src/__tests__/browser-launch.test.js +137 -7
  4. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  5. package/dist/src/__tests__/session-discovery.test.js +81 -1
  6. package/dist/src/__tests__/session-discovery.test.js.map +1 -1
  7. package/dist/src/browser/browser.d.ts.map +1 -1
  8. package/dist/src/browser/browser.js +34 -13
  9. package/dist/src/browser/browser.js.map +1 -1
  10. package/dist/src/browser/ref-cache.d.ts +6 -0
  11. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  12. package/dist/src/browser/ref-cache.js +4 -0
  13. package/dist/src/browser/ref-cache.js.map +1 -1
  14. package/dist/src/cli/attention-wall.d.ts +12 -0
  15. package/dist/src/cli/attention-wall.d.ts.map +1 -0
  16. package/dist/src/cli/attention-wall.js +166 -0
  17. package/dist/src/cli/attention-wall.js.map +1 -0
  18. package/dist/src/cli/commands-session.d.ts.map +1 -1
  19. package/dist/src/cli/commands-session.js +24 -11
  20. package/dist/src/cli/commands-session.js.map +1 -1
  21. package/dist/src/cli/commands.d.ts.map +1 -1
  22. package/dist/src/cli/commands.js +21 -8
  23. package/dist/src/cli/commands.js.map +1 -1
  24. package/dist/src/cli/session.d.ts +17 -3
  25. package/dist/src/cli/session.d.ts.map +1 -1
  26. package/dist/src/cli/session.js +72 -162
  27. package/dist/src/cli/session.js.map +1 -1
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/browse-concurrency.test.ts +106 -1
  31. package/src/__tests__/browser-launch.test.ts +142 -7
  32. package/src/__tests__/session-discovery.test.ts +112 -2
  33. package/src/browser/browser.ts +32 -11
  34. package/src/browser/ref-cache.ts +16 -1
  35. package/src/cli/attention-wall.ts +202 -0
  36. package/src/cli/commands-session.ts +25 -12
  37. package/src/cli/commands.ts +22 -8
  38. package/src/cli/session.ts +78 -198
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.22",
3
+ "version": "1.0.25",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
- import { afterEach, describe, expect, it } from 'vitest';
21
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
22
22
  import { CHROME_EXECUTABLES } from '../browser/types.js';
23
23
 
24
24
  const here = dirname(fileURLToPath(import.meta.url));
@@ -100,7 +100,51 @@ afterEach(async () => {
100
100
  }
101
101
  });
102
102
 
103
+ /**
104
+ * Start Chrome once, outside any trial, and let it finish. A runner's first
105
+ * Chrome start pays for paging the binary and its libraries in and building
106
+ * the font cache; two of them at once in trial 1 took longer than `open`'s
107
+ * 10 s CDP wait on CI, while every later trial passed. `--dump-dom` exits on
108
+ * its own once the page loads, so this has no timeout of `open`'s to beat.
109
+ */
110
+ async function warmUpChrome(): Promise<void> {
111
+ let found: string;
112
+ try {
113
+ found = execFileSync('which', ['google-chrome', 'chromium-browser', 'chromium'], {
114
+ encoding: 'utf8',
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ });
117
+ } catch (err) {
118
+ // `which` exits non-zero when any one name is missing; the rest still print.
119
+ found = String((err as { stdout?: string }).stdout ?? '');
120
+ }
121
+ const chrome = CHROME_EXECUTABLES.find((p) => existsSync(p)) ?? found.split('\n')[0];
122
+ if (!chrome) return;
123
+ const profile = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-warmup-'));
124
+ try {
125
+ await new Promise<void>((resolve) => {
126
+ execFile(
127
+ chrome,
128
+ [
129
+ '--headless=new',
130
+ `--user-data-dir=${profile}`,
131
+ '--no-first-run',
132
+ ...(process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage'] : []),
133
+ '--dump-dom',
134
+ 'about:blank',
135
+ ],
136
+ { timeout: 60_000 },
137
+ () => resolve(),
138
+ );
139
+ });
140
+ } finally {
141
+ await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
142
+ }
143
+ }
144
+
103
145
  describe.skipIf(!runnable)('#318 concurrent browse sessions (real processes)', () => {
146
+ beforeAll(warmUpChrome, 90_000);
147
+
104
148
  for (const trial of [1, 2, 3, 4, 5]) {
105
149
  it(`trial ${trial}: two bare \`open\` calls each get their own browser, and each close kills only its own`, async () => {
106
150
  const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-'));
@@ -231,3 +275,64 @@ describe.skipIf(!runnable)('#318 adoption of a pre-#318 session (real processes)
231
275
  expect(await cdpAlive(legacyPort)).toBe(false);
232
276
  }, 90_000);
233
277
  });
278
+
279
+ /**
280
+ * `--session <name>` is documented (browse-qa, browse-electron skills) as a
281
+ * way to drive several browsers side by side. It used to be a no-op outside
282
+ * `open`, where it only restored saved state and failed when none existed —
283
+ * AFTER launching a browser — so every later command silently acted on
284
+ * whichever session was newest. Real processes, because the bug is in what a
285
+ * later, separate CLI invocation resolves.
286
+ */
287
+ describe.skipIf(!runnable)('--session named sessions (real processes)', () => {
288
+ beforeAll(warmUpChrome, 90_000);
289
+
290
+ it('two named sessions stay apart, a bare command never lands on them, and an unknown name fails', async () => {
291
+ const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-named-'));
292
+ dirs.push(root);
293
+ const pageA = join(root, 'a.html');
294
+ const pageB = join(root, 'b.html');
295
+ await writeFile(pageA, '<!doctype html><title>Page A</title><h1>a</h1>');
296
+ await writeFile(pageB, '<!doctype html><title>Page B</title><h1>b</h1>');
297
+
298
+ const openA = await runCli(['open', `file://${pageA}`, '--session', 'ref'], root, root);
299
+ expect(openA.code, `open ref failed: ${openA.stdout}${openA.stderr}`).toBe(0);
300
+ const openB = await runCli(['open', `file://${pageB}`, '--session', 'build'], root, root);
301
+ expect(openB.code, `open build failed: ${openB.stdout}${openB.stderr}`).toBe(0);
302
+ const portA = reportedPort(openA);
303
+ const portB = reportedPort(openB);
304
+ expect(portA).not.toBeNull();
305
+ expect(portA).not.toBe(portB);
306
+
307
+ // 'build' is the newest session, yet --session ref reaches 'ref'.
308
+ const titleA = await runCli(['get', 'title', '--session', 'ref'], root, root);
309
+ expect(titleA.code, `get title ref failed: ${titleA.stdout}${titleA.stderr}`).toBe(0);
310
+ expect(titleA.stdout).toContain('Page A');
311
+ const titleB = await runCli(['get', 'title', '--session', 'build'], root, root);
312
+ expect(titleB.stdout).toContain('Page B');
313
+
314
+ // Re-opening a name navigates that session's own browser.
315
+ const reopen = await runCli(['open', `file://${pageB}`, '--session', 'ref'], root, root);
316
+ expect(reopen.code, `re-open ref failed: ${reopen.stdout}${reopen.stderr}`).toBe(0);
317
+ expect(reportedPort(reopen)).toBe(portA);
318
+
319
+ // An unknown name is an error, not a silent fallback to the newest session.
320
+ const unknown = await runCli(['get', 'title', '--session', 'nope'], root, root);
321
+ expect(unknown.code).not.toBe(0);
322
+ expect(`${unknown.stdout}${unknown.stderr}`).toMatch(/No live browse session named "nope"/);
323
+
324
+ // A bare command starts its own browser instead of joining a named one.
325
+ const bare = await runCli(['get', 'title'], root, root);
326
+ expect(bare.code, `bare get title failed: ${bare.stdout}${bare.stderr}`).toBe(0);
327
+ expect(bare.stdout).not.toContain('Page');
328
+
329
+ // close --session ends exactly that session.
330
+ const closeA = await runCli(['close', '--session', 'ref'], root, root);
331
+ expect(closeA.code, `close ref failed: ${closeA.stdout}${closeA.stderr}`).toBe(0);
332
+ expect(await cdpAlive(portA!)).toBe(false);
333
+ expect(await cdpAlive(portB!)).toBe(true);
334
+ await runCli(['close', '--session', 'build'], root, root);
335
+ expect(await cdpAlive(portB!)).toBe(false);
336
+ await runCli(['close'], root, root);
337
+ }, 120_000);
338
+ });
@@ -9,12 +9,13 @@
9
9
  * services; each test binds/tears down its own listeners.
10
10
  */
11
11
 
12
+ import { execFileSync } from 'node:child_process';
12
13
  import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
14
  import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
14
15
  import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
15
16
  import { tmpdir } from 'node:os';
16
17
  import { join } from 'node:path';
17
- import { afterEach, describe, expect, it } from 'vitest';
18
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
18
19
  import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
19
20
 
20
21
  const BASE = 23470;
@@ -32,6 +33,72 @@ afterEach(async () => {
32
33
  servers = [];
33
34
  }, 5000);
34
35
 
36
+ // Every fake-chrome.cjs below is spawned by launchBrowser (detached, so a
37
+ // test cannot reach it through a ChildProcess). Its temp dir is on its
38
+ // command line, so each dir is recorded here and afterAll kills whatever is
39
+ // still running out of one — a fixture must never outlive this file (one
40
+ // that did kept 127.0.0.1:23480 bound and failed later runs with
41
+ // EADDRINUSE).
42
+ const fixtureDirs: string[] = [];
43
+
44
+ function fixtureDir(prefix: string): string {
45
+ const dir = mkdtempSync(join(tmpdir(), prefix));
46
+ fixtureDirs.push(dir);
47
+ return dir;
48
+ }
49
+
50
+ /** Pids of processes whose command line mentions `dir`. */
51
+ function processesIn(dir: string): string[] {
52
+ try {
53
+ return execFileSync('pgrep', ['-f', dir], { encoding: 'utf8' })
54
+ .trim()
55
+ .split('\n')
56
+ .filter(Boolean);
57
+ } catch {
58
+ return []; // pgrep exits 1 when nothing matches
59
+ }
60
+ }
61
+
62
+ afterAll(async () => {
63
+ const leaked: string[] = [];
64
+ for (const dir of fixtureDirs.splice(0)) {
65
+ // A fixture a test just SIGKILLed can take a moment to disappear.
66
+ let pids = processesIn(dir);
67
+ for (let i = 0; i < 20 && pids.length > 0; i++) {
68
+ await new Promise((resolve) => setTimeout(resolve, 100));
69
+ pids = processesIn(dir);
70
+ }
71
+ leaked.push(...pids);
72
+ for (const pid of pids) {
73
+ try {
74
+ process.kill(Number(pid), 'SIGKILL');
75
+ } catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ // Swept above either way; failing here names a test whose own cleanup
82
+ // missed a fixture.
83
+ expect(leaked, 'fake-chrome fixtures outlived their test').toEqual([]);
84
+ });
85
+
86
+ // Prepended to every fake-chrome script: exit once the process that launched
87
+ // it is gone (launchBrowser spawns detached, so a vitest worker that dies or
88
+ // is killed mid-test would otherwise leave the fixture — and its port —
89
+ // behind, reparented to init), and never live longer than a minute anyway.
90
+ const FIXTURE_WATCHDOG = `const parent = process.ppid;
91
+ setInterval(() => {
92
+ try {
93
+ if (process.ppid !== parent) process.exit(0);
94
+ process.kill(parent, 0);
95
+ } catch {
96
+ process.exit(0);
97
+ }
98
+ }, 1000).unref();
99
+ setTimeout(() => process.exit(0), 60000).unref();
100
+ `;
101
+
35
102
  /** Bind a bare TCP listener — accepts connections but speaks no HTTP/CDP. */
36
103
  function occupyNonChrome(port: number): Promise<void> {
37
104
  return new Promise((resolve, reject) => {
@@ -119,7 +186,7 @@ describe('launchBrowser — port scan/attach decisions', () => {
119
186
  // --remote-debugging-port=0, it binds a kernel-assigned port and only then
120
187
  // writes that port to <user-data-dir>/DevToolsActivePort.
121
188
  const FAKE_CHROME = `#!/usr/bin/env node
122
- const { createServer } = require('node:http');
189
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
123
190
  const { writeFileSync } = require('node:fs');
124
191
  const { join } = require('node:path');
125
192
  const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
@@ -133,11 +200,32 @@ setTimeout(() => server.listen(0, '127.0.0.1', () => {
133
200
  }), 600);
134
201
  `;
135
202
 
203
+ // Like FAKE_CHROME, but its first /json/version answer takes longer than
204
+ // launchBrowser's 1 s identity timeout, while /json/list answers at once.
205
+ const FAKE_CHROME_SLOW_IDENTITY = `#!/usr/bin/env node
206
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
207
+ const { writeFileSync } = require('node:fs');
208
+ const { join } = require('node:path');
209
+ const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
210
+ let versionCalls = 0;
211
+ const server = createServer((req, res) => {
212
+ const send = () => {
213
+ res.writeHead(200, { 'Content-Type': 'application/json' });
214
+ res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
215
+ };
216
+ if (req.url === '/json/version' && versionCalls++ === 0) setTimeout(send, 1500);
217
+ else send();
218
+ });
219
+ server.listen(0, '127.0.0.1', () => {
220
+ writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
221
+ });
222
+ `;
223
+
136
224
  describe.skipIf(process.platform === 'win32')(
137
225
  'launchBrowser — port 0 (Chrome picks the port)',
138
226
  () => {
139
227
  it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
140
- const dir = mkdtempSync(join(tmpdir(), 'monobrowse-port0-'));
228
+ const dir = fixtureDir('monobrowse-port0-');
141
229
  const exe = join(dir, 'fake-chrome.cjs');
142
230
  writeFileSync(exe, FAKE_CHROME);
143
231
  chmodSync(exe, 0o755);
@@ -168,6 +256,48 @@ describe.skipIf(process.platform === 'win32')(
168
256
  }
169
257
  });
170
258
 
259
+ it('does not reject its own Chrome when /json/version answers slowly (loaded machine)', async () => {
260
+ // On a busy CI runner Chrome's DevTools server can take longer than the
261
+ // identity check's timeout to answer /json/version right after it
262
+ // starts listening, while /json/list (no timeout) got through. That
263
+ // timeout used to be read as "not Chrome" and failed the launch with
264
+ // "occupied by a CDP-speaking process that does not identify as
265
+ // Chrome/Chromium" — for the very Chrome it had just started.
266
+ const dir = fixtureDir('monobrowse-slowid-');
267
+ const exe = join(dir, 'fake-chrome.cjs');
268
+ writeFileSync(exe, FAKE_CHROME_SLOW_IDENTITY);
269
+ chmodSync(exe, 0o755);
270
+ const profile = join(dir, 'profile');
271
+ mkdirSync(profile);
272
+
273
+ let port: number | undefined;
274
+ try {
275
+ port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
276
+ expect(port).toBeGreaterThan(0);
277
+ expect(getLaunchedPid(port)).toBeTypeOf('number');
278
+ } finally {
279
+ const pid = port === undefined ? undefined : getLaunchedPid(port);
280
+ if (pid) process.kill(pid, 'SIGKILL');
281
+ }
282
+ }, 15000);
283
+
284
+ it('still refuses a CDP endpoint that answers as a non-Chrome browser', async () => {
285
+ const dir = fixtureDir('monobrowse-notchrome-');
286
+ const exe = join(dir, 'fake-chrome.cjs');
287
+ writeFileSync(exe, FAKE_CHROME.replace('Chrome/999.0.0.0', 'Firefox/1.0'));
288
+ chmodSync(exe, 0o755);
289
+ const profile = join(dir, 'profile');
290
+ mkdirSync(profile);
291
+ await expect(
292
+ launchBrowser({ port: 0, userDataDir: profile, executablePath: exe }),
293
+ ).rejects.toThrow(/does not identify as Chrome/);
294
+ // Refused, so never tracked — launchBrowser must stop it itself.
295
+ for (let i = 0; i < 20 && processesIn(dir).length > 0; i++) {
296
+ await new Promise((resolve) => setTimeout(resolve, 100));
297
+ }
298
+ expect(processesIn(dir)).toEqual([]);
299
+ }, 15000);
300
+
171
301
  it('refuses port 0 without a dedicated userDataDir', async () => {
172
302
  await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
173
303
  });
@@ -182,7 +312,7 @@ describe.skipIf(process.platform === 'win32')(
182
312
  // when a concurrent launch wins the same "free-looking" port: before its
183
313
  // CDP endpoint ever opens.
184
314
  const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
185
- const { createServer } = require('node:http');
315
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
186
316
  const { writeFileSync, mkdirSync } = require('node:fs');
187
317
  const { join } = require('node:path');
188
318
  const port = Number(
@@ -206,7 +336,7 @@ describe.skipIf(process.platform === 'win32')(
206
336
  'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
207
337
  () => {
208
338
  it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
209
- const dir = mkdtempSync(join(tmpdir(), 'monobrowse-concurrent-'));
339
+ const dir = fixtureDir('monobrowse-concurrent-');
210
340
  const exe = join(dir, 'fake-chrome.cjs');
211
341
  writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
212
342
  chmodSync(exe, 0o755);
@@ -216,12 +346,17 @@ describe.skipIf(process.platform === 'win32')(
216
346
  // whether or not the user passed it (cli/commands.ts:288).
217
347
  const port = BASE + 10;
218
348
 
349
+ // allSettled, not all: when one launch rejects, the other's fixture is
350
+ // still running and must be killed too — Promise.all dropped its port
351
+ // and leaked it.
352
+ let results: PromiseSettledResult<number>[] = [];
219
353
  let ports: number[] = [];
220
354
  try {
221
- ports = await Promise.all([
355
+ results = await Promise.allSettled([
222
356
  launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
223
357
  launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
224
358
  ]);
359
+ ports = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
225
360
  } finally {
226
361
  for (const p of ports) {
227
362
  const pid = getLaunchedPid(p);
@@ -233,9 +368,9 @@ describe.skipIf(process.platform === 'win32')(
233
368
  }
234
369
  }
235
370
  }
236
- rmSync(dir, { recursive: true, force: true });
237
371
  }
238
372
 
373
+ for (const r of results) if (r.status === 'rejected') throw r.reason;
239
374
  expect(ports).toHaveLength(2);
240
375
  // The old probe-then-launch race let both calls observe the same
241
376
  // candidate as free and spawn Chrome on it — one bind wins, the
@@ -30,8 +30,14 @@ afterEach(async () => {
30
30
  async function load() {
31
31
  vi.resetModules();
32
32
  const store = await import('../browser/ref-cache.js');
33
- const { resolveLiveSession, adoptLegacySession, pinnedPort, applySessionPortFlag, session } =
34
- await import('../cli/session.js');
33
+ const {
34
+ resolveLiveSession,
35
+ adoptLegacySession,
36
+ pinnedPort,
37
+ applySessionPortFlag,
38
+ applySessionNameFlag,
39
+ session,
40
+ } = await import('../cli/session.js');
35
41
  // The slice of the browser namespace resolveLiveSession uses.
36
42
  const browser = {
37
43
  listSessionRecords: store.listSessionRecords,
@@ -49,6 +55,7 @@ async function load() {
49
55
  adoptLegacySession,
50
56
  pinnedPort,
51
57
  applySessionPortFlag,
58
+ applySessionNameFlag,
52
59
  session,
53
60
  };
54
61
  }
@@ -257,3 +264,106 @@ describe('#318 --port session selector', () => {
257
264
  expect(session.port).toBe(41111);
258
265
  });
259
266
  });
267
+
268
+ describe('--session names a live session', () => {
269
+ /** Record sessions oldest → newest, so newest-first order is deterministic. */
270
+ async function recordInOrder(
271
+ store: Awaited<ReturnType<typeof load>>['store'],
272
+ ...records: Array<{ port: number; name?: string }>
273
+ ) {
274
+ const now = Date.now();
275
+ const clock = vi.spyOn(Date, 'now');
276
+ for (const [i, r] of records.entries()) {
277
+ clock.mockReturnValue(now - (records.length - i) * 1_000);
278
+ await store.saveSessionRecord(r.port, { pid: 10 + i, name: r.name });
279
+ }
280
+ clock.mockRestore();
281
+ }
282
+
283
+ it('the record keeps its name', async () => {
284
+ const { store } = await load();
285
+ await store.saveSessionRecord(41111, { pid: 11, name: 'ref' });
286
+ expect((await store.loadSessionRecord(41111))?.name).toBe('ref');
287
+ });
288
+
289
+ it('resolves the live session with that name, not the newest one', async () => {
290
+ const { store, browser, resolveLiveSession } = await load();
291
+ await recordInOrder(
292
+ store,
293
+ { port: 41111, name: 'ref' },
294
+ { port: 42222, name: 'build' },
295
+ { port: 43333 },
296
+ );
297
+ onlyLive(41111, 42222, 43333);
298
+
299
+ expect((await resolveLiveSession(browser, { name: 'ref' }))?.port).toBe(41111);
300
+ expect((await resolveLiveSession(browser, { name: 'build' }))?.port).toBe(42222);
301
+ });
302
+
303
+ it('an unknown name resolves to none instead of falling back to another session', async () => {
304
+ const { store, browser, resolveLiveSession } = await load();
305
+ await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'build' });
306
+ onlyLive(41111, 42222);
307
+
308
+ expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
309
+ });
310
+
311
+ it('a command with no name never lands on a named session', async () => {
312
+ const { store, browser, resolveLiveSession } = await load();
313
+ await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'ref' });
314
+ onlyLive(41111, 42222);
315
+
316
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
317
+ });
318
+
319
+ it('a dead named session is dropped, and the name resolves to none', async () => {
320
+ const { store, browser, resolveLiveSession } = await load();
321
+ await recordInOrder(store, { port: 41111, name: 'ref' });
322
+ onlyLive();
323
+
324
+ expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
325
+ expect(await store.loadSessionRecord(41111)).toBeNull();
326
+ });
327
+
328
+ // Regression: a command whose flags never set `session.name` (e.g.
329
+ // `snapshot -s <name>` — `-s` is `--selector` on snapshot, not
330
+ // `--session`, see withSessionSelector in cli/commands.ts) used to find
331
+ // NO live session at all once the only live session was named, so
332
+ // ensureConnected silently launched a brand-new, unrelated headless
333
+ // Chrome — its blank "New Tab" is what a following snapshot showed
334
+ // instead of the page the named session had actually opened. With only
335
+ // one live session, named or not, a nameless lookup must still resolve to
336
+ // it: there is nothing else it could mean.
337
+ it('a command with no name resolves the sole live session even if it is named', async () => {
338
+ const { store, browser, resolveLiveSession } = await load();
339
+ await recordInOrder(store, { port: 41111, name: 'ctrl' });
340
+ onlyLive(41111);
341
+
342
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
343
+ });
344
+
345
+ // But guessing between two DIFFERENT named sessions would reintroduce the
346
+ // exact cross-session bleed #318 fixed — refuse instead of picking one.
347
+ it('a command with no name refuses to guess between two live named sessions', async () => {
348
+ const { store, browser, resolveLiveSession } = await load();
349
+ await recordInOrder(store, { port: 41111, name: 'alpha' }, { port: 42222, name: 'beta' });
350
+ onlyLive(41111, 42222);
351
+
352
+ expect(await resolveLiveSession(browser)).toBeNull();
353
+ });
354
+
355
+ it('--session sets the process session name; a missing flag leaves it alone', async () => {
356
+ const { applySessionNameFlag, session } = await load();
357
+ applySessionNameFlag({ _: [], session: 'ref' });
358
+ expect(session.name).toBe('ref');
359
+ applySessionNameFlag({ _: [] });
360
+ expect(session.name).toBe('ref');
361
+ });
362
+
363
+ it('rejects a session name that is not a plain identifier', async () => {
364
+ const { applySessionNameFlag } = await load();
365
+ for (const bad of ['', '../x', 'a b', 'x'.repeat(65)]) {
366
+ expect(() => applySessionNameFlag({ _: [], session: bad })).toThrow(/Invalid session name/);
367
+ }
368
+ });
369
+ });
@@ -92,18 +92,27 @@ export async function isPortOpen(port: number): Promise<boolean> {
92
92
  * attaching to an unrelated real browser that happens to be listening there.
93
93
  */
94
94
  async function isChromeIdentity(port: number): Promise<boolean> {
95
+ return (await chromeIdentity(port)) === 'chrome';
96
+ }
97
+
98
+ /** isChromeIdentity, telling "answered as something else" apart from "did
99
+ * not answer in time" ('unknown': a timeout, a refused or reset connection,
100
+ * a non-OK status or an unparseable body). */
101
+ async function chromeIdentity(port: number): Promise<'chrome' | 'other' | 'unknown'> {
95
102
  try {
96
103
  const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
97
104
  signal: AbortSignal.timeout(1000),
98
105
  });
99
- if (!res.ok) return false;
106
+ if (!res.ok) return 'unknown';
100
107
  const info = (await res.json()) as { Browser?: string };
101
108
  // Edge's Browser field is "Edg/<version>" with no "chrome"/"chromium"
102
109
  // substring, even though it's Chromium-based and CDP-compatible —
103
110
  // accept it alongside Chrome/Chromium rather than rejecting a valid target.
104
- return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser);
111
+ return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser)
112
+ ? 'chrome'
113
+ : 'other';
105
114
  } catch {
106
- return false;
115
+ return 'unknown';
107
116
  }
108
117
  }
109
118
 
@@ -353,6 +362,17 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
353
362
  // process's pid overwriting the real winner's, corrupting the very map
354
363
  // closeBrowser()'s kill fallback depends on.
355
364
 
365
+ // With port 0 a refused or timed-out launch is not tracked under any port,
366
+ // so no closeBrowser() could ever reach it — stop it rather than leave it
367
+ // running.
368
+ const killUntracked = () => {
369
+ try {
370
+ if (child.pid) process.kill(child.pid, 'SIGKILL');
371
+ } catch {
372
+ /* already gone */
373
+ }
374
+ };
375
+
356
376
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
357
377
  const deadline = Date.now() + launchTimeout;
358
378
  while (Date.now() < deadline) {
@@ -361,10 +381,17 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
361
381
  if (earlyFailure) throw earlyFailure;
362
382
  const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
363
383
  if (boundPort !== null && (await isPortOpen(boundPort))) {
364
- if (await isChromeIdentity(boundPort)) {
384
+ const identity = await chromeIdentity(boundPort);
385
+ if (identity === 'chrome') {
365
386
  track(boundPort);
366
387
  return boundPort;
367
388
  }
389
+ // A Chrome that has only just opened its endpoint, on a loaded
390
+ // machine, can answer /json/list (no timeout) yet miss the identity
391
+ // check's 1 s timeout on /json/version. That says nothing about who is
392
+ // listening — keep polling rather than refuse our own Chrome.
393
+ if (identity === 'unknown') continue;
394
+ if (port === 0) killUntracked();
368
395
  throw new Error(
369
396
  `Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
370
397
  `Refusing to attach — pass a different port or free port ${boundPort}.`,
@@ -375,13 +402,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
375
402
  if (earlyFailure) throw earlyFailure;
376
403
 
377
404
  if (port === 0) {
378
- // Not tracked under any port yet, so no closeBrowser() could ever reach
379
- // it — stop it here rather than leave it running.
380
- try {
381
- if (child.pid) process.kill(child.pid, 'SIGKILL');
382
- } catch {
383
- /* already gone */
384
- }
405
+ killUntracked();
385
406
  throw new Error(
386
407
  `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
387
408
  );
@@ -119,8 +119,14 @@ export interface SessionRecord {
119
119
  pid?: number;
120
120
  userDataDir?: string;
121
121
  savedAt?: number;
122
+ /** Set by `--session <name>`: only commands naming it resolve this session,
123
+ * and commands naming none never land on it. */
124
+ name?: string;
122
125
  }
123
126
 
127
+ /** A `--session` name: a plain identifier, safe as a label and in messages. */
128
+ export const SESSION_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
129
+
124
130
  /**
125
131
  * Record a browse session under its own port, so concurrent sessions in the
126
132
  * same working directory coexist instead of overwriting one shared handle.
@@ -132,7 +138,13 @@ export interface SessionRecord {
132
138
  */
133
139
  export async function saveSessionRecord(
134
140
  port: number,
135
- opts?: { launched?: boolean; pid?: number; userDataDir?: string; savedAt?: number },
141
+ opts?: {
142
+ launched?: boolean;
143
+ pid?: number;
144
+ userDataDir?: string;
145
+ savedAt?: number;
146
+ name?: string;
147
+ },
136
148
  ): Promise<void> {
137
149
  try {
138
150
  await mkdir(SESSIONS_DIR, { recursive: true });
@@ -150,6 +162,7 @@ export async function saveSessionRecord(
150
162
  pid: opts?.pid,
151
163
  userDataDir: opts?.userDataDir,
152
164
  savedAt: opts?.savedAt ?? Date.now(),
165
+ name: opts?.name,
153
166
  }),
154
167
  );
155
168
  } catch {
@@ -235,6 +248,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
235
248
  pid?: unknown;
236
249
  userDataDir?: unknown;
237
250
  savedAt?: unknown;
251
+ name?: unknown;
238
252
  };
239
253
  if (
240
254
  typeof data.port !== 'number' ||
@@ -253,5 +267,6 @@ function parseSessionRecord(raw: string): SessionRecord | null {
253
267
  : undefined,
254
268
  userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
255
269
  savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
270
+ name: typeof data.name === 'string' && SESSION_NAME_RE.test(data.name) ? data.name : undefined,
256
271
  };
257
272
  }