@monoes/monobrowse 1.0.19 → 1.0.21

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 (61) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.d.ts +15 -0
  2. package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/browse-concurrency.test.js +198 -0
  4. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
  5. package/dist/src/__tests__/close-browser.test.js +2 -2
  6. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  7. package/dist/src/__tests__/frame-command.test.js +12 -2
  8. package/dist/src/__tests__/frame-command.test.js.map +1 -1
  9. package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
  10. package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
  11. package/dist/src/__tests__/ref-cache.test.js +96 -58
  12. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  13. package/dist/src/__tests__/report-exit-code.test.js +1 -1
  14. package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
  15. package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
  16. package/dist/src/__tests__/session-discovery.test.js +218 -0
  17. package/dist/src/__tests__/session-discovery.test.js.map +1 -0
  18. package/dist/src/browser/browser.d.ts +2 -1
  19. package/dist/src/browser/browser.d.ts.map +1 -1
  20. package/dist/src/browser/browser.js +17 -9
  21. package/dist/src/browser/browser.js.map +1 -1
  22. package/dist/src/browser/ref-cache.d.ts +50 -22
  23. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  24. package/dist/src/browser/ref-cache.js +122 -57
  25. package/dist/src/browser/ref-cache.js.map +1 -1
  26. package/dist/src/cli/commands-context.js +1 -1
  27. package/dist/src/cli/commands-context.js.map +1 -1
  28. package/dist/src/cli/commands-navigate.js +2 -2
  29. package/dist/src/cli/commands-navigate.js.map +1 -1
  30. package/dist/src/cli/commands-page.js +2 -2
  31. package/dist/src/cli/commands-page.js.map +1 -1
  32. package/dist/src/cli/commands-report.js +2 -2
  33. package/dist/src/cli/commands-report.js.map +1 -1
  34. package/dist/src/cli/commands-session.d.ts.map +1 -1
  35. package/dist/src/cli/commands-session.js +70 -56
  36. package/dist/src/cli/commands-session.js.map +1 -1
  37. package/dist/src/cli/commands.d.ts.map +1 -1
  38. package/dist/src/cli/commands.js +102 -66
  39. package/dist/src/cli/commands.js.map +1 -1
  40. package/dist/src/cli/session.d.ts +66 -2
  41. package/dist/src/cli/session.d.ts.map +1 -1
  42. package/dist/src/cli/session.js +190 -33
  43. package/dist/src/cli/session.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/browse-concurrency.test.ts +233 -0
  47. package/src/__tests__/close-browser.test.ts +2 -2
  48. package/src/__tests__/frame-command.test.ts +12 -2
  49. package/src/__tests__/reap-idle-browser.test.ts +15 -14
  50. package/src/__tests__/ref-cache.test.ts +112 -59
  51. package/src/__tests__/report-exit-code.test.ts +1 -1
  52. package/src/__tests__/session-discovery.test.ts +259 -0
  53. package/src/browser/browser.ts +23 -9
  54. package/src/browser/ref-cache.ts +144 -72
  55. package/src/cli/commands-context.ts +1 -1
  56. package/src/cli/commands-navigate.ts +2 -2
  57. package/src/cli/commands-page.ts +2 -2
  58. package/src/cli/commands-report.ts +2 -2
  59. package/src/cli/commands-session.ts +74 -55
  60. package/src/cli/commands.ts +106 -66
  61. package/src/cli/session.ts +208 -33
@@ -41,8 +41,8 @@ describe('ref-cache', () => {
41
41
  const mod = await importRefCache();
42
42
  const refs = makeRefs(2);
43
43
 
44
- await mod.saveRefCache('target-1', 'https://example.com', refs as any);
45
- const loaded = await mod.loadRefCache('target-1');
44
+ await mod.saveRefCache(9222, 'target-1', 'https://example.com', refs as any);
45
+ const loaded = await mod.loadRefCache(9222, 'target-1');
46
46
 
47
47
  expect(loaded).not.toBeNull();
48
48
  expect(loaded!.url).toBe('https://example.com');
@@ -57,8 +57,8 @@ describe('ref-cache', () => {
57
57
  const mod = await importRefCache();
58
58
  const refs = makeRefs(1);
59
59
 
60
- await mod.saveRefCache('target-A', 'https://a.com', refs as any);
61
- const loaded = await mod.loadRefCache('target-B');
60
+ await mod.saveRefCache(9222, 'target-A', 'https://a.com', refs as any);
61
+ const loaded = await mod.loadRefCache(9222, 'target-B');
62
62
 
63
63
  expect(loaded).toBeNull();
64
64
  });
@@ -66,27 +66,27 @@ describe('ref-cache', () => {
66
66
  it('corrupt JSON file returns null', async () => {
67
67
  const mod = await importRefCache();
68
68
  // First save a valid cache to create the directory structure
69
- await mod.saveRefCache('t', 'https://x.com', makeRefs(1) as any);
69
+ await mod.saveRefCache(9222, 't', 'https://x.com', makeRefs(1) as any);
70
70
 
71
71
  // Now overwrite the file with invalid JSON
72
- const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
72
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot-9222.json');
73
73
  const { writeFile: wf } = await import('node:fs/promises');
74
74
  await wf(cacheFile, '{{{not valid json!!!');
75
75
 
76
- const loaded = await mod.loadRefCache('t');
76
+ const loaded = await mod.loadRefCache(9222, 't');
77
77
  expect(loaded).toBeNull();
78
78
  });
79
79
 
80
80
  it('clearRefCache removes the cache file', async () => {
81
81
  const mod = await importRefCache();
82
- await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
82
+ await mod.saveRefCache(9222, 't1', 'https://x.com', makeRefs(1) as any);
83
83
 
84
- const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
84
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot-9222.json');
85
85
  // Verify file exists
86
86
  const before = await stat(cacheFile).catch(() => null);
87
87
  expect(before).not.toBeNull();
88
88
 
89
- await mod.clearRefCache();
89
+ await mod.clearRefCache(9222);
90
90
 
91
91
  const after = await stat(cacheFile).catch(() => null);
92
92
  expect(after).toBeNull();
@@ -94,18 +94,18 @@ describe('ref-cache', () => {
94
94
 
95
95
  it('load after clear returns null', async () => {
96
96
  const mod = await importRefCache();
97
- await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
98
- await mod.clearRefCache();
97
+ await mod.saveRefCache(9222, 't1', 'https://x.com', makeRefs(1) as any);
98
+ await mod.clearRefCache(9222);
99
99
 
100
- const loaded = await mod.loadRefCache('t1');
100
+ const loaded = await mod.loadRefCache(9222, 't1');
101
101
  expect(loaded).toBeNull();
102
102
  });
103
103
 
104
104
  it('freshly saved cache is not stale', async () => {
105
105
  const mod = await importRefCache();
106
- await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
106
+ await mod.saveRefCache(9222, 't1', 'https://x.com', makeRefs(1) as any);
107
107
 
108
- const loaded = await mod.loadRefCache('t1');
108
+ const loaded = await mod.loadRefCache(9222, 't1');
109
109
  expect(loaded).not.toBeNull();
110
110
  expect(loaded!.stale).toBe(false);
111
111
  expect(loaded!.ageMs).toBeLessThan(mod.REF_CACHE_STALE_MS);
@@ -113,16 +113,16 @@ describe('ref-cache', () => {
113
113
 
114
114
  it('cache older than REF_CACHE_STALE_MS is flagged stale', async () => {
115
115
  const mod = await importRefCache();
116
- await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
116
+ await mod.saveRefCache(9222, 't1', 'https://x.com', makeRefs(1) as any);
117
117
 
118
118
  // Manually backdate the savedAt timestamp
119
- const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
119
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot-9222.json');
120
120
  const raw = JSON.parse(await readFile(cacheFile, 'utf8'));
121
121
  raw.savedAt = Date.now() - mod.REF_CACHE_STALE_MS - 5000;
122
122
  const { writeFile: wf } = await import('node:fs/promises');
123
123
  await wf(cacheFile, JSON.stringify(raw));
124
124
 
125
- const loaded = await mod.loadRefCache('t1');
125
+ const loaded = await mod.loadRefCache(9222, 't1');
126
126
  expect(loaded).not.toBeNull();
127
127
  expect(loaded!.stale).toBe(true);
128
128
  expect(loaded!.ageMs).toBeGreaterThan(mod.REF_CACHE_STALE_MS);
@@ -130,76 +130,117 @@ describe('ref-cache', () => {
130
130
 
131
131
  it('loading with no cache file returns null (no crash)', async () => {
132
132
  const mod = await importRefCache();
133
- const loaded = await mod.loadRefCache('nonexistent');
133
+ const loaded = await mod.loadRefCache(9222, 'nonexistent');
134
134
  expect(loaded).toBeNull();
135
135
  });
136
+
137
+ it('#318: each session keeps its own ref cache', async () => {
138
+ const mod = await importRefCache();
139
+ await mod.saveRefCache(41111, 'target-A', 'https://a.com', makeRefs(1) as any);
140
+ await mod.saveRefCache(42222, 'target-B', 'https://b.com', makeRefs(2) as any);
141
+
142
+ expect((await mod.loadRefCache(41111, 'target-A'))!.url).toBe('https://a.com');
143
+ expect((await mod.loadRefCache(42222, 'target-B'))!.refs.size).toBe(2);
144
+
145
+ await mod.clearRefCache(41111);
146
+ expect(await mod.loadRefCache(41111, 'target-A')).toBeNull();
147
+ expect(await mod.loadRefCache(42222, 'target-B')).not.toBeNull();
148
+ });
136
149
  });
137
150
 
138
- describe('active-port persistence', () => {
139
- it('round-trip: save then load returns the port; launched defaults true', async () => {
151
+ describe('#318 session store (one record per port)', () => {
152
+ it('round-trip: save then load returns the session; launched defaults true', async () => {
140
153
  const mod = await importRefCache();
141
- await mod.saveActivePort(9333);
142
- expect(await mod.loadActivePort()).toBe(9333);
143
- expect(await mod.loadActivePortInfo()).toEqual({
154
+ await mod.saveSessionRecord(9333);
155
+ expect(await mod.loadSessionRecord(9333)).toEqual({
144
156
  port: 9333,
145
157
  launched: true,
146
158
  savedAt: expect.any(Number),
147
159
  });
148
160
  });
149
161
 
150
- it('connect provenance: launched:false survives the round-trip', async () => {
162
+ it('two concurrent sessions coexist — neither overwrites the other', async () => {
151
163
  const mod = await importRefCache();
152
- await mod.saveActivePort(9229, { launched: false });
153
- expect(await mod.loadActivePortInfo()).toEqual({
154
- port: 9229,
155
- launched: false,
156
- savedAt: expect.any(Number),
157
- });
164
+ await mod.saveSessionRecord(41111, { pid: 11 });
165
+ await mod.saveSessionRecord(42222, { pid: 22 });
166
+
167
+ expect((await mod.listSessionRecords()).map((r) => r.port).sort()).toEqual([41111, 42222]);
168
+ expect((await mod.loadSessionRecord(41111))!.pid).toBe(11);
169
+ expect((await mod.loadSessionRecord(42222))!.pid).toBe(22);
170
+ });
171
+
172
+ it('lists newest first — the discovery order for a command with no --port', async () => {
173
+ const mod = await importRefCache();
174
+ const now = Date.now();
175
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(now - 5_000);
176
+ await mod.saveSessionRecord(41111);
177
+ clock.mockReturnValue(now);
178
+ await mod.saveSessionRecord(42222);
179
+ clock.mockRestore();
180
+
181
+ expect((await mod.listSessionRecords()).map((r) => r.port)).toEqual([42222, 41111]);
182
+ });
183
+
184
+ it('removing one session leaves the others alone', async () => {
185
+ const mod = await importRefCache();
186
+ await mod.saveSessionRecord(41111);
187
+ await mod.saveSessionRecord(42222);
188
+
189
+ await mod.removeSessionRecord(41111);
190
+
191
+ expect(await mod.loadSessionRecord(41111)).toBeNull();
192
+ expect((await mod.listSessionRecords()).map((r) => r.port)).toEqual([42222]);
158
193
  });
159
194
 
160
- it('clear removes the file; load returns null afterwards', async () => {
195
+ it('removing a session that was never recorded resolves without throwing', async () => {
161
196
  const mod = await importRefCache();
162
- await mod.saveActivePort(9333);
163
- await mod.clearActivePort();
164
- expect(await mod.loadActivePort()).toBeNull();
165
- expect(await mod.loadActivePortInfo()).toBeNull();
197
+ await expect(mod.removeSessionRecord(41111)).resolves.toBeUndefined();
166
198
  });
167
199
 
168
- it('clear with no file resolves without throwing', async () => {
200
+ it('listing with no sessions directory returns an empty list (no crash)', async () => {
169
201
  const mod = await importRefCache();
170
- await mod.clearActivePort();
171
- await expect(mod.clearActivePort()).resolves.toBeUndefined();
202
+ expect(await mod.listSessionRecords()).toEqual([]);
172
203
  });
173
204
 
174
- it('#115: pid and userDataDir persist and round-trip so a later process can kill an orphaned launch', async () => {
205
+ it('connect provenance: launched:false survives the round-trip', async () => {
175
206
  const mod = await importRefCache();
176
- await mod.saveActivePort(9333, { pid: 54321, userDataDir: '/tmp/monomind-browser-9333' });
177
- expect(await mod.loadActivePortInfo()).toEqual({
207
+ await mod.saveSessionRecord(9229, { launched: false });
208
+ expect(await mod.loadSessionRecord(9229)).toEqual({
209
+ port: 9229,
210
+ launched: false,
211
+ savedAt: expect.any(Number),
212
+ });
213
+ });
214
+
215
+ it('#115: pid and userDataDir persist so a later process can kill an orphaned launch', async () => {
216
+ const mod = await importRefCache();
217
+ await mod.saveSessionRecord(9333, { pid: 54321, userDataDir: '/tmp/monomind-browse-9333' });
218
+ expect(await mod.loadSessionRecord(9333)).toEqual({
178
219
  port: 9333,
179
220
  launched: true,
180
221
  pid: 54321,
181
- userDataDir: '/tmp/monomind-browser-9333',
222
+ userDataDir: '/tmp/monomind-browse-9333',
182
223
  savedAt: expect.any(Number),
183
224
  });
184
225
  });
185
226
 
186
227
  it('#115: an attached (launched:false) session with no pid argument persists none', async () => {
187
228
  const mod = await importRefCache();
188
- await mod.saveActivePort(9229, { launched: false });
189
- const info = await mod.loadActivePortInfo();
229
+ await mod.saveSessionRecord(9229, { launched: false });
230
+ const info = await mod.loadSessionRecord(9229);
190
231
  expect(info!.launched).toBe(false);
191
232
  expect(info!.pid).toBeUndefined();
192
233
  });
193
234
 
194
- it('#124-review: saveActivePort does NOT strip pid when launched:false — callers must not pass one for an attach', async () => {
235
+ it('#124-review: saveSessionRecord does NOT strip pid when launched:false — callers must not pass one for an attach', async () => {
195
236
  // The property above (attach sessions have no pid) is caller discipline,
196
237
  // not something this function enforces — the one production caller
197
- // (commands.ts's `connect` action) simply never passes a pid alongside
198
- // launched:false. This test documents that explicitly so it can't be
199
- // mistaken for an invariant the code itself guarantees.
238
+ // (`connect`) simply never passes a pid alongside launched:false. This
239
+ // test documents that explicitly so it can't be mistaken for an
240
+ // invariant the code itself guarantees.
200
241
  const mod = await importRefCache();
201
- await mod.saveActivePort(9229, { launched: false, pid: 4242 });
202
- const info = await mod.loadActivePortInfo();
242
+ await mod.saveSessionRecord(9229, { launched: false, pid: 4242 });
243
+ const info = await mod.loadSessionRecord(9229);
203
244
  expect(info!.launched).toBe(false);
204
245
  expect(info!.pid).toBe(4242);
205
246
  });
@@ -207,27 +248,39 @@ describe('active-port persistence', () => {
207
248
  it('#115: a non-numeric or non-positive persisted pid is dropped, not trusted', async () => {
208
249
  const mod = await importRefCache();
209
250
  const { writeFile, mkdir } = await import('node:fs/promises');
210
- const dir = join(process.cwd(), '.monomind', 'monobrowse');
251
+ const dir = join(process.cwd(), '.monomind', 'monobrowse', 'sessions');
211
252
  await mkdir(dir, { recursive: true });
212
253
  for (const badPid of ['54321', -1, 0, 1.5, null]) {
213
254
  await writeFile(
214
- join(dir, 'active-port.json'),
255
+ join(dir, '9333.json'),
215
256
  JSON.stringify({ port: 9333, launched: true, pid: badPid }),
216
257
  );
217
- const info = await mod.loadActivePortInfo();
258
+ const info = await mod.loadSessionRecord(9333);
218
259
  expect(info!.pid).toBeUndefined();
219
260
  }
220
261
  });
221
262
 
222
- it('rejects out-of-range or non-integer persisted ports', async () => {
263
+ it('rejects out-of-range or non-integer persisted ports, and skips them when listing', async () => {
223
264
  const mod = await importRefCache();
224
265
  const { writeFile, mkdir } = await import('node:fs/promises');
225
- const dir = join(process.cwd(), '.monomind', 'monobrowse');
266
+ const dir = join(process.cwd(), '.monomind', 'monobrowse', 'sessions');
226
267
  await mkdir(dir, { recursive: true });
227
268
  for (const bad of [80, 70000, 1.5, '9222', null]) {
228
- await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port: bad }));
229
- expect(await mod.loadActivePort()).toBeNull();
230
- expect(await mod.loadActivePortInfo()).toBeNull();
269
+ await writeFile(join(dir, '9333.json'), JSON.stringify({ port: bad }));
270
+ expect(await mod.loadSessionRecord(9333)).toBeNull();
271
+ expect(await mod.listSessionRecords()).toEqual([]);
231
272
  }
232
273
  });
274
+
275
+ it('a corrupt record is skipped, not fatal for the rest of the listing', async () => {
276
+ const mod = await importRefCache();
277
+ await mod.saveSessionRecord(42222);
278
+ const { writeFile } = await import('node:fs/promises');
279
+ await writeFile(
280
+ join(process.cwd(), '.monomind', 'monobrowse', 'sessions', '41111.json'),
281
+ '{{{not valid json!!!',
282
+ );
283
+
284
+ expect((await mod.listSessionRecords()).map((r) => r.port)).toEqual([42222]);
285
+ });
233
286
  });
@@ -91,7 +91,7 @@ export async function getBrowser() {
91
91
  stopRequestCapture: () => {},
92
92
  teardownConsoleCapture: () => {},
93
93
  ${close}
94
- clearActivePort: async () => {},
94
+ removeSessionRecord: async () => {},
95
95
  clearRefCache: async () => {},
96
96
  };
97
97
  }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * #318: how a CLI command with no `--port` finds "its" session, and what it
3
+ * does when it finds none — the discovery half of the per-session-browser
4
+ * fix. The allocation half (Chrome binding a free port in a profile of its
5
+ * own) is covered by browser-launch.test.ts and, end to end, by
6
+ * browse-concurrency.test.ts.
7
+ *
8
+ * resolveLiveSession() takes the browser namespace as a parameter, so the
9
+ * real session store (with process.cwd() pointed at a temp dir) can be used
10
+ * while only the CDP liveness probe is faked.
11
+ */
12
+
13
+ import { mkdtemp, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-session-'));
22
+ vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
23
+ });
24
+
25
+ afterEach(async () => {
26
+ vi.restoreAllMocks();
27
+ await rm(tempDir, { recursive: true, force: true });
28
+ });
29
+
30
+ async function load() {
31
+ vi.resetModules();
32
+ const store = await import('../browser/ref-cache.js');
33
+ const { resolveLiveSession, adoptLegacySession, pinnedPort, applySessionPortFlag, session } =
34
+ await import('../cli/session.js');
35
+ // The slice of the browser namespace resolveLiveSession uses.
36
+ const browser = {
37
+ listSessionRecords: store.listSessionRecords,
38
+ removeSessionRecord: store.removeSessionRecord,
39
+ clearRefCache: store.clearRefCache,
40
+ loadSessionRecord: store.loadSessionRecord,
41
+ saveSessionRecord: store.saveSessionRecord,
42
+ loadLegacySessionRecord: store.loadLegacySessionRecord,
43
+ removeLegacySessionRecord: store.removeLegacySessionRecord,
44
+ } as unknown as Parameters<typeof resolveLiveSession>[0];
45
+ return {
46
+ store,
47
+ browser,
48
+ resolveLiveSession,
49
+ adoptLegacySession,
50
+ pinnedPort,
51
+ applySessionPortFlag,
52
+ session,
53
+ };
54
+ }
55
+
56
+ /** Write the session file a pre-#318 CLI left behind in this directory. */
57
+ async function writeLegacyRecord(record: Record<string, unknown>) {
58
+ const { mkdir, writeFile } = await import('node:fs/promises');
59
+ const dir = join(process.cwd(), '.monomind', 'monobrowse');
60
+ await mkdir(dir, { recursive: true });
61
+ await writeFile(join(dir, 'active-port.json'), JSON.stringify(record));
62
+ }
63
+
64
+ async function legacyFileExists(): Promise<boolean> {
65
+ const { readFile } = await import('node:fs/promises');
66
+ return readFile(join(process.cwd(), '.monomind', 'monobrowse', 'active-port.json')).then(
67
+ () => true,
68
+ () => false,
69
+ );
70
+ }
71
+
72
+ /** Fake the CDP `/json/version` probe: only `live` ports answer. */
73
+ function onlyLive(...live: number[]) {
74
+ vi.stubGlobal('fetch', async (input: string | URL) => {
75
+ const port = Number(new URL(String(input)).port);
76
+ if (live.includes(port)) return new Response('{}', { status: 200 });
77
+ throw new Error('connection refused');
78
+ });
79
+ }
80
+
81
+ describe('#318 session discovery', () => {
82
+ it('with no sessions recorded, resolves to none — the caller starts its own', async () => {
83
+ const { browser, resolveLiveSession } = await load();
84
+ onlyLive();
85
+ expect(await resolveLiveSession(browser)).toBeNull();
86
+ });
87
+
88
+ it('resolves the newest live session', async () => {
89
+ const { store, browser, resolveLiveSession } = await load();
90
+ const now = Date.now();
91
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(now - 5_000);
92
+ await store.saveSessionRecord(41111, { pid: 11 });
93
+ clock.mockReturnValue(now);
94
+ await store.saveSessionRecord(42222, { pid: 22 });
95
+ clock.mockRestore();
96
+ onlyLive(41111, 42222);
97
+
98
+ expect((await resolveLiveSession(browser))?.port).toBe(42222);
99
+ });
100
+
101
+ it('skips a session whose browser died and resolves the older live one', async () => {
102
+ const { store, browser, resolveLiveSession } = await load();
103
+ const now = Date.now();
104
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(now - 5_000);
105
+ await store.saveSessionRecord(41111);
106
+ clock.mockReturnValue(now);
107
+ await store.saveSessionRecord(42222);
108
+ clock.mockRestore();
109
+ onlyLive(41111);
110
+
111
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
112
+ });
113
+
114
+ it('self-heals: a dead session is dropped from the store, not left to wedge later commands', async () => {
115
+ const { store, browser, resolveLiveSession } = await load();
116
+ await store.saveSessionRecord(42222);
117
+ onlyLive();
118
+
119
+ expect(await resolveLiveSession(browser)).toBeNull();
120
+ expect(await store.loadSessionRecord(42222)).toBeNull();
121
+ });
122
+
123
+ it('a dead connect-origin session fails loudly instead of being relaunched under the user', async () => {
124
+ const { store, browser, resolveLiveSession } = await load();
125
+ await store.saveSessionRecord(9229, { launched: false });
126
+ onlyLive();
127
+
128
+ await expect(resolveLiveSession(browser)).rejects.toThrow(/Connected browser on port 9229/);
129
+ expect(await store.loadSessionRecord(9229)).toBeNull();
130
+ });
131
+
132
+ it('close (strict:false) cleans a dead connect-origin session up instead of throwing', async () => {
133
+ const { store, browser, resolveLiveSession } = await load();
134
+ await store.saveSessionRecord(9229, { launched: false });
135
+ onlyLive();
136
+
137
+ expect(await resolveLiveSession(browser, { strict: false })).toBeNull();
138
+ expect(await store.loadSessionRecord(9229)).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe('#318 adoption of a pre-#318 session record', () => {
143
+ it('adopts a live legacy session: the record is rewritten per port and the old file is gone', async () => {
144
+ const { store, browser, resolveLiveSession } = await load();
145
+ const savedAt = Date.now() - 60_000;
146
+ await writeLegacyRecord({ port: 41111, launched: true, pid: 4242, savedAt });
147
+ onlyLive(41111);
148
+
149
+ expect((await resolveLiveSession(browser))?.port).toBe(41111);
150
+
151
+ expect(await store.loadSessionRecord(41111)).toEqual({
152
+ port: 41111,
153
+ launched: true,
154
+ pid: 4242,
155
+ // Preserved, not reset — the session's real age is what the idle
156
+ // reaper and the newest-first ordering read.
157
+ savedAt,
158
+ });
159
+ expect(await legacyFileExists()).toBe(false);
160
+ });
161
+
162
+ it('adoption keeps connect provenance, so close still never kills the user’s browser', async () => {
163
+ const { store, browser, resolveLiveSession } = await load();
164
+ await writeLegacyRecord({ port: 9229, launched: false });
165
+ onlyLive(9229);
166
+
167
+ expect((await resolveLiveSession(browser))?.launched).toBe(false);
168
+ expect((await store.loadSessionRecord(9229))!.launched).toBe(false);
169
+ });
170
+
171
+ it('a legacy session whose browser is gone is removed, without throwing', async () => {
172
+ const { store, browser, resolveLiveSession } = await load();
173
+ await writeLegacyRecord({ port: 41111, launched: true, pid: 4242 });
174
+ onlyLive();
175
+
176
+ expect(await resolveLiveSession(browser)).toBeNull();
177
+ expect(await legacyFileExists()).toBe(false);
178
+ expect(await store.loadSessionRecord(41111)).toBeNull();
179
+ });
180
+
181
+ it('a dead legacy connect-origin session is cleaned up too, not raised as an error', async () => {
182
+ const { browser, resolveLiveSession } = await load();
183
+ await writeLegacyRecord({ port: 9229, launched: false });
184
+ onlyLive();
185
+
186
+ expect(await resolveLiveSession(browser)).toBeNull();
187
+ expect(await legacyFileExists()).toBe(false);
188
+ });
189
+
190
+ it('a live native session wins; the superseded legacy file is dropped', async () => {
191
+ const { store, browser, resolveLiveSession } = await load();
192
+ await store.saveSessionRecord(42222, { pid: 22 });
193
+ await writeLegacyRecord({ port: 41111, launched: true });
194
+ onlyLive(41111, 42222);
195
+
196
+ expect((await resolveLiveSession(browser))?.port).toBe(42222);
197
+ // Not adopted while a native session is live — it is the older candidate.
198
+ expect(await legacyFileExists()).toBe(true);
199
+ });
200
+
201
+ it('a legacy record for a port that already has a native record is dropped, not adopted twice', async () => {
202
+ const { store, browser, adoptLegacySession } = await load();
203
+ await store.saveSessionRecord(41111, { launched: false });
204
+ await writeLegacyRecord({ port: 41111, launched: true, pid: 4242 });
205
+ onlyLive(41111);
206
+
207
+ expect(await adoptLegacySession(browser)).toBeNull();
208
+ expect(await legacyFileExists()).toBe(false);
209
+ expect((await store.loadSessionRecord(41111))!.launched).toBe(false);
210
+ });
211
+
212
+ it('adoption restricted to a named port leaves another port’s legacy record alone', async () => {
213
+ const { browser, adoptLegacySession } = await load();
214
+ await writeLegacyRecord({ port: 41111, launched: true });
215
+ onlyLive(41111);
216
+
217
+ expect(await adoptLegacySession(browser, { port: 42222 })).toBeNull();
218
+ expect(await legacyFileExists()).toBe(true);
219
+ expect((await adoptLegacySession(browser, { port: 41111 }))?.port).toBe(41111);
220
+ expect(await legacyFileExists()).toBe(false);
221
+ });
222
+
223
+ it('with no legacy file at all, adoption is a no-op', async () => {
224
+ const { browser, adoptLegacySession } = await load();
225
+ onlyLive();
226
+ expect(await adoptLegacySession(browser)).toBeNull();
227
+ });
228
+ });
229
+
230
+ describe('#318 --port session selector', () => {
231
+ it('reads a usable port from the flags, in either the number or string form', async () => {
232
+ const { pinnedPort } = await load();
233
+ expect(pinnedPort({ _: [], port: 9333 })).toBe(9333);
234
+ expect(pinnedPort({ _: [], port: '9333' })).toBe(9333);
235
+ });
236
+
237
+ it('treats absent, out-of-range and unparseable ports as "no selector"', async () => {
238
+ const { pinnedPort } = await load();
239
+ expect(pinnedPort({ _: [] })).toBeUndefined();
240
+ expect(pinnedPort({ _: [], port: 80 })).toBeUndefined();
241
+ expect(pinnedPort({ _: [], port: 70000 })).toBeUndefined();
242
+ expect(pinnedPort({ _: [], port: 1.5 })).toBeUndefined();
243
+ expect(pinnedPort({ _: [], port: 'nope' })).toBeUndefined();
244
+ expect(pinnedPort({ _: [], port: true })).toBeUndefined();
245
+ });
246
+
247
+ it('pins the process to the named session', async () => {
248
+ const { applySessionPortFlag, session } = await load();
249
+ applySessionPortFlag({ _: [], port: 9333 });
250
+ expect(session.port).toBe(9333);
251
+ });
252
+
253
+ it('a command with no --port leaves an established session alone (batch open → snapshot)', async () => {
254
+ const { applySessionPortFlag, session } = await load();
255
+ session.port = 41111; // as `open` set it earlier in this process
256
+ applySessionPortFlag({ _: [] });
257
+ expect(session.port).toBe(41111);
258
+ });
259
+ });
@@ -6,7 +6,12 @@ import { join } from 'node:path';
6
6
  import { CdpClient, fetchNewTarget, fetchTargets } from './cdp.js';
7
7
  import { enableConsoleCapture, setupConsoleCapture } from './console-log.js';
8
8
  import { setupDialogAutoHandling } from './dialog.js';
9
- import { clearActivePort, loadActivePortInfo } from './ref-cache.js';
9
+ import {
10
+ listSessionRecords,
11
+ loadSessionRecord,
12
+ removeSessionRecord,
13
+ type SessionRecord,
14
+ } from './ref-cache.js';
10
15
  import type { BrowserConfig, CdpTarget } from './types.js';
11
16
  import { CHROME_EXECUTABLES } from './types.js';
12
17
 
@@ -490,13 +495,13 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
490
495
  // Fresh process (each CLI invocation is its own node process — see
491
496
  // module header) — launchedPids is per-process and empty here even
492
497
  // though a prior `open` in a DIFFERENT process launched this Chrome.
493
- // Fall back to the PID that process persisted via saveActivePort().
498
+ // Fall back to the PID that process persisted via saveSessionRecord().
494
499
  try {
495
- const persisted = await loadActivePortInfo();
496
- if (persisted && persisted.port === port && persisted.launched && persisted.pid) {
500
+ const persisted = await loadSessionRecord(port);
501
+ if (persisted?.launched && persisted.pid) {
497
502
  // Bound how far we trust a cross-process PID: if the Chrome we
498
503
  // launched died outside this flow (OOM-killed, host slept, etc.)
499
- // without active-port.json ever being cleared, the OS can recycle
504
+ // without its session record ever being cleared, the OS can recycle
500
505
  // that PID for an unrelated process. There is no cmdline/start-time
501
506
  // check available portably, so a coarse age bound is the practical
502
507
  // guard — a PID persisted more than a day ago is far more likely to
@@ -594,12 +599,21 @@ async function waitForProcessExit(pid: number): Promise<boolean> {
594
599
  * ever used for closeBrowser's own age-bounded force-kill.
595
600
  *
596
601
  * Never throws: a failed reap must not stop a launch. Returns the reaped
597
- * port, or null when there was nothing to reap.
602
+ * port, or null when there was nothing to reap. Sessions are per-port
603
+ * (#318), so every recorded one is considered, not just the newest.
598
604
  */
599
605
  export async function reapIdleLaunchedBrowser(): Promise<number | null> {
606
+ let reaped: number | null = null;
607
+ for (const session of await listSessionRecords().catch(() => [])) {
608
+ const port = await reapSession(session);
609
+ reaped ??= port;
610
+ }
611
+ return reaped;
612
+ }
613
+
614
+ async function reapSession(session: SessionRecord): Promise<number | null> {
600
615
  try {
601
- const session = await loadActivePortInfo();
602
- if (!session?.launched || session.savedAt === undefined) return null;
616
+ if (!session.launched || session.savedAt === undefined) return null;
603
617
  if (Date.now() - session.savedAt < IDLE_REAP_AFTER_MS) return null;
604
618
 
605
619
  const wsUrl = await fetchBrowserWebSocketUrl(session.port);
@@ -636,7 +650,7 @@ export async function reapIdleLaunchedBrowser(): Promise<number | null> {
636
650
  }
637
651
  launchedPids.delete(session.port);
638
652
  launchedUserDataDirs.delete(session.port);
639
- await clearActivePort();
653
+ await removeSessionRecord(session.port);
640
654
  return session.port;
641
655
  } catch {
642
656
  return null;