@monoes/monobrowse 1.0.24 → 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__/browser-launch.test.js +137 -7
- package/dist/src/__tests__/browser-launch.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/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browser-launch.test.ts +142 -7
- package/src/browser/browser.ts +32 -11
package/package.json
CHANGED
|
@@ -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
|
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
|
);
|