@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.
- package/dist/src/__tests__/browse-concurrency.test.d.ts +15 -0
- package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
- package/dist/src/__tests__/browse-concurrency.test.js +198 -0
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
- package/dist/src/__tests__/close-browser.test.js +2 -2
- package/dist/src/__tests__/close-browser.test.js.map +1 -1
- package/dist/src/__tests__/frame-command.test.js +12 -2
- package/dist/src/__tests__/frame-command.test.js.map +1 -1
- package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
- package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
- package/dist/src/__tests__/ref-cache.test.js +96 -58
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/__tests__/report-exit-code.test.js +1 -1
- package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
- package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
- package/dist/src/__tests__/session-discovery.test.js +218 -0
- package/dist/src/__tests__/session-discovery.test.js.map +1 -0
- package/dist/src/browser/browser.d.ts +2 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +17 -9
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +50 -22
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +122 -57
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/cli/commands-context.js +1 -1
- package/dist/src/cli/commands-context.js.map +1 -1
- package/dist/src/cli/commands-navigate.js +2 -2
- package/dist/src/cli/commands-navigate.js.map +1 -1
- package/dist/src/cli/commands-page.js +2 -2
- package/dist/src/cli/commands-page.js.map +1 -1
- package/dist/src/cli/commands-report.js +2 -2
- package/dist/src/cli/commands-report.js.map +1 -1
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +70 -56
- package/dist/src/cli/commands-session.js.map +1 -1
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +102 -66
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +66 -2
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +190 -33
- package/dist/src/cli/session.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browse-concurrency.test.ts +233 -0
- package/src/__tests__/close-browser.test.ts +2 -2
- package/src/__tests__/frame-command.test.ts +12 -2
- package/src/__tests__/reap-idle-browser.test.ts +15 -14
- package/src/__tests__/ref-cache.test.ts +112 -59
- package/src/__tests__/report-exit-code.test.ts +1 -1
- package/src/__tests__/session-discovery.test.ts +259 -0
- package/src/browser/browser.ts +23 -9
- package/src/browser/ref-cache.ts +144 -72
- package/src/cli/commands-context.ts +1 -1
- package/src/cli/commands-navigate.ts +2 -2
- package/src/cli/commands-page.ts +2 -2
- package/src/cli/commands-report.ts +2 -2
- package/src/cli/commands-session.ts +74 -55
- package/src/cli/commands.ts +106 -66
- 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('
|
|
139
|
-
it('round-trip: save then load returns the
|
|
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.
|
|
142
|
-
expect(await mod.
|
|
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('
|
|
162
|
+
it('two concurrent sessions coexist — neither overwrites the other', async () => {
|
|
151
163
|
const mod = await importRefCache();
|
|
152
|
-
await mod.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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('
|
|
195
|
+
it('removing a session that was never recorded resolves without throwing', async () => {
|
|
161
196
|
const mod = await importRefCache();
|
|
162
|
-
await mod.
|
|
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('
|
|
200
|
+
it('listing with no sessions directory returns an empty list (no crash)', async () => {
|
|
169
201
|
const mod = await importRefCache();
|
|
170
|
-
await mod.
|
|
171
|
-
await expect(mod.clearActivePort()).resolves.toBeUndefined();
|
|
202
|
+
expect(await mod.listSessionRecords()).toEqual([]);
|
|
172
203
|
});
|
|
173
204
|
|
|
174
|
-
it('
|
|
205
|
+
it('connect provenance: launched:false survives the round-trip', async () => {
|
|
175
206
|
const mod = await importRefCache();
|
|
176
|
-
await mod.
|
|
177
|
-
expect(await mod.
|
|
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-
|
|
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.
|
|
189
|
-
const info = await mod.
|
|
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:
|
|
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
|
-
// (
|
|
198
|
-
//
|
|
199
|
-
//
|
|
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.
|
|
202
|
-
const info = await mod.
|
|
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, '
|
|
255
|
+
join(dir, '9333.json'),
|
|
215
256
|
JSON.stringify({ port: 9333, launched: true, pid: badPid }),
|
|
216
257
|
);
|
|
217
|
-
const info = await mod.
|
|
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, '
|
|
229
|
-
expect(await mod.
|
|
230
|
-
expect(await mod.
|
|
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
|
});
|
|
@@ -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
|
+
});
|
package/src/browser/browser.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
498
|
+
// Fall back to the PID that process persisted via saveSessionRecord().
|
|
494
499
|
try {
|
|
495
|
-
const persisted = await
|
|
496
|
-
if (persisted
|
|
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
|
|
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
|
-
|
|
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
|
|
653
|
+
await removeSessionRecord(session.port);
|
|
640
654
|
return session.port;
|
|
641
655
|
} catch {
|
|
642
656
|
return null;
|