@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.
- package/dist/src/__tests__/browse-concurrency.test.js +94 -1
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +137 -7
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/session-discovery.test.js +81 -1
- package/dist/src/__tests__/session-discovery.test.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +34 -13
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +6 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +4 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/cli/attention-wall.d.ts +12 -0
- package/dist/src/cli/attention-wall.d.ts.map +1 -0
- package/dist/src/cli/attention-wall.js +166 -0
- package/dist/src/cli/attention-wall.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +24 -11
- 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 +21 -8
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +17 -3
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +72 -162
- 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 +106 -1
- package/src/__tests__/browser-launch.test.ts +142 -7
- package/src/__tests__/session-discovery.test.ts +112 -2
- package/src/browser/browser.ts +32 -11
- package/src/browser/ref-cache.ts +16 -1
- package/src/cli/attention-wall.ts +202 -0
- package/src/cli/commands-session.ts +25 -12
- package/src/cli/commands.ts +22 -8
- package/src/cli/session.ts +78 -198
package/package.json
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
34
|
-
|
|
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
|
+
});
|
package/src/browser/browser.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|
package/src/browser/ref-cache.ts
CHANGED
|
@@ -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?: {
|
|
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
|
}
|