@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,12 +9,13 @@
9
9
  * services; each test binds/tears down its own listeners.
10
10
  */
11
11
 
12
+ import { execFileSync } from 'node:child_process';
12
13
  import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
14
  import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
14
15
  import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
15
16
  import { tmpdir } from 'node:os';
16
17
  import { join } from 'node:path';
17
- import { afterEach, describe, expect, it } from 'vitest';
18
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
18
19
  import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
19
20
 
20
21
  const BASE = 23470;
@@ -32,6 +33,72 @@ afterEach(async () => {
32
33
  servers = [];
33
34
  }, 5000);
34
35
 
36
+ // Every fake-chrome.cjs below is spawned by launchBrowser (detached, so a
37
+ // test cannot reach it through a ChildProcess). Its temp dir is on its
38
+ // command line, so each dir is recorded here and afterAll kills whatever is
39
+ // still running out of one — a fixture must never outlive this file (one
40
+ // that did kept 127.0.0.1:23480 bound and failed later runs with
41
+ // EADDRINUSE).
42
+ const fixtureDirs: string[] = [];
43
+
44
+ function fixtureDir(prefix: string): string {
45
+ const dir = mkdtempSync(join(tmpdir(), prefix));
46
+ fixtureDirs.push(dir);
47
+ return dir;
48
+ }
49
+
50
+ /** Pids of processes whose command line mentions `dir`. */
51
+ function processesIn(dir: string): string[] {
52
+ try {
53
+ return execFileSync('pgrep', ['-f', dir], { encoding: 'utf8' })
54
+ .trim()
55
+ .split('\n')
56
+ .filter(Boolean);
57
+ } catch {
58
+ return []; // pgrep exits 1 when nothing matches
59
+ }
60
+ }
61
+
62
+ afterAll(async () => {
63
+ const leaked: string[] = [];
64
+ for (const dir of fixtureDirs.splice(0)) {
65
+ // A fixture a test just SIGKILLed can take a moment to disappear.
66
+ let pids = processesIn(dir);
67
+ for (let i = 0; i < 20 && pids.length > 0; i++) {
68
+ await new Promise((resolve) => setTimeout(resolve, 100));
69
+ pids = processesIn(dir);
70
+ }
71
+ leaked.push(...pids);
72
+ for (const pid of pids) {
73
+ try {
74
+ process.kill(Number(pid), 'SIGKILL');
75
+ } catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ // Swept above either way; failing here names a test whose own cleanup
82
+ // missed a fixture.
83
+ expect(leaked, 'fake-chrome fixtures outlived their test').toEqual([]);
84
+ });
85
+
86
+ // Prepended to every fake-chrome script: exit once the process that launched
87
+ // it is gone (launchBrowser spawns detached, so a vitest worker that dies or
88
+ // is killed mid-test would otherwise leave the fixture — and its port —
89
+ // behind, reparented to init), and never live longer than a minute anyway.
90
+ const FIXTURE_WATCHDOG = `const parent = process.ppid;
91
+ setInterval(() => {
92
+ try {
93
+ if (process.ppid !== parent) process.exit(0);
94
+ process.kill(parent, 0);
95
+ } catch {
96
+ process.exit(0);
97
+ }
98
+ }, 1000).unref();
99
+ setTimeout(() => process.exit(0), 60000).unref();
100
+ `;
101
+
35
102
  /** Bind a bare TCP listener — accepts connections but speaks no HTTP/CDP. */
36
103
  function occupyNonChrome(port: number): Promise<void> {
37
104
  return new Promise((resolve, reject) => {
@@ -119,7 +186,7 @@ describe('launchBrowser — port scan/attach decisions', () => {
119
186
  // --remote-debugging-port=0, it binds a kernel-assigned port and only then
120
187
  // writes that port to <user-data-dir>/DevToolsActivePort.
121
188
  const FAKE_CHROME = `#!/usr/bin/env node
122
- const { createServer } = require('node:http');
189
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
123
190
  const { writeFileSync } = require('node:fs');
124
191
  const { join } = require('node:path');
125
192
  const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
@@ -133,11 +200,32 @@ setTimeout(() => server.listen(0, '127.0.0.1', () => {
133
200
  }), 600);
134
201
  `;
135
202
 
203
+ // Like FAKE_CHROME, but its first /json/version answer takes longer than
204
+ // launchBrowser's 1 s identity timeout, while /json/list answers at once.
205
+ const FAKE_CHROME_SLOW_IDENTITY = `#!/usr/bin/env node
206
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
207
+ const { writeFileSync } = require('node:fs');
208
+ const { join } = require('node:path');
209
+ const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
210
+ let versionCalls = 0;
211
+ const server = createServer((req, res) => {
212
+ const send = () => {
213
+ res.writeHead(200, { 'Content-Type': 'application/json' });
214
+ res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
215
+ };
216
+ if (req.url === '/json/version' && versionCalls++ === 0) setTimeout(send, 1500);
217
+ else send();
218
+ });
219
+ server.listen(0, '127.0.0.1', () => {
220
+ writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
221
+ });
222
+ `;
223
+
136
224
  describe.skipIf(process.platform === 'win32')(
137
225
  'launchBrowser — port 0 (Chrome picks the port)',
138
226
  () => {
139
227
  it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
140
- const dir = mkdtempSync(join(tmpdir(), 'monobrowse-port0-'));
228
+ const dir = fixtureDir('monobrowse-port0-');
141
229
  const exe = join(dir, 'fake-chrome.cjs');
142
230
  writeFileSync(exe, FAKE_CHROME);
143
231
  chmodSync(exe, 0o755);
@@ -168,6 +256,48 @@ describe.skipIf(process.platform === 'win32')(
168
256
  }
169
257
  });
170
258
 
259
+ it('does not reject its own Chrome when /json/version answers slowly (loaded machine)', async () => {
260
+ // On a busy CI runner Chrome's DevTools server can take longer than the
261
+ // identity check's timeout to answer /json/version right after it
262
+ // starts listening, while /json/list (no timeout) got through. That
263
+ // timeout used to be read as "not Chrome" and failed the launch with
264
+ // "occupied by a CDP-speaking process that does not identify as
265
+ // Chrome/Chromium" — for the very Chrome it had just started.
266
+ const dir = fixtureDir('monobrowse-slowid-');
267
+ const exe = join(dir, 'fake-chrome.cjs');
268
+ writeFileSync(exe, FAKE_CHROME_SLOW_IDENTITY);
269
+ chmodSync(exe, 0o755);
270
+ const profile = join(dir, 'profile');
271
+ mkdirSync(profile);
272
+
273
+ let port: number | undefined;
274
+ try {
275
+ port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
276
+ expect(port).toBeGreaterThan(0);
277
+ expect(getLaunchedPid(port)).toBeTypeOf('number');
278
+ } finally {
279
+ const pid = port === undefined ? undefined : getLaunchedPid(port);
280
+ if (pid) process.kill(pid, 'SIGKILL');
281
+ }
282
+ }, 15000);
283
+
284
+ it('still refuses a CDP endpoint that answers as a non-Chrome browser', async () => {
285
+ const dir = fixtureDir('monobrowse-notchrome-');
286
+ const exe = join(dir, 'fake-chrome.cjs');
287
+ writeFileSync(exe, FAKE_CHROME.replace('Chrome/999.0.0.0', 'Firefox/1.0'));
288
+ chmodSync(exe, 0o755);
289
+ const profile = join(dir, 'profile');
290
+ mkdirSync(profile);
291
+ await expect(
292
+ launchBrowser({ port: 0, userDataDir: profile, executablePath: exe }),
293
+ ).rejects.toThrow(/does not identify as Chrome/);
294
+ // Refused, so never tracked — launchBrowser must stop it itself.
295
+ for (let i = 0; i < 20 && processesIn(dir).length > 0; i++) {
296
+ await new Promise((resolve) => setTimeout(resolve, 100));
297
+ }
298
+ expect(processesIn(dir)).toEqual([]);
299
+ }, 15000);
300
+
171
301
  it('refuses port 0 without a dedicated userDataDir', async () => {
172
302
  await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
173
303
  });
@@ -182,7 +312,7 @@ describe.skipIf(process.platform === 'win32')(
182
312
  // when a concurrent launch wins the same "free-looking" port: before its
183
313
  // CDP endpoint ever opens.
184
314
  const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
185
- const { createServer } = require('node:http');
315
+ ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
186
316
  const { writeFileSync, mkdirSync } = require('node:fs');
187
317
  const { join } = require('node:path');
188
318
  const port = Number(
@@ -206,7 +336,7 @@ describe.skipIf(process.platform === 'win32')(
206
336
  'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
207
337
  () => {
208
338
  it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
209
- const dir = mkdtempSync(join(tmpdir(), 'monobrowse-concurrent-'));
339
+ const dir = fixtureDir('monobrowse-concurrent-');
210
340
  const exe = join(dir, 'fake-chrome.cjs');
211
341
  writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
212
342
  chmodSync(exe, 0o755);
@@ -216,12 +346,17 @@ describe.skipIf(process.platform === 'win32')(
216
346
  // whether or not the user passed it (cli/commands.ts:288).
217
347
  const port = BASE + 10;
218
348
 
349
+ // allSettled, not all: when one launch rejects, the other's fixture is
350
+ // still running and must be killed too — Promise.all dropped its port
351
+ // and leaked it.
352
+ let results: PromiseSettledResult<number>[] = [];
219
353
  let ports: number[] = [];
220
354
  try {
221
- ports = await Promise.all([
355
+ results = await Promise.allSettled([
222
356
  launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
223
357
  launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
224
358
  ]);
359
+ ports = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
225
360
  } finally {
226
361
  for (const p of ports) {
227
362
  const pid = getLaunchedPid(p);
@@ -233,9 +368,9 @@ describe.skipIf(process.platform === 'win32')(
233
368
  }
234
369
  }
235
370
  }
236
- rmSync(dir, { recursive: true, force: true });
237
371
  }
238
372
 
373
+ for (const r of results) if (r.status === 'rejected') throw r.reason;
239
374
  expect(ports).toHaveLength(2);
240
375
  // The old probe-then-launch race let both calls observe the same
241
376
  // candidate as free and spawn Chrome on it — one bind wins, the
@@ -92,18 +92,27 @@ export async function isPortOpen(port: number): Promise<boolean> {
92
92
  * attaching to an unrelated real browser that happens to be listening there.
93
93
  */
94
94
  async function isChromeIdentity(port: number): Promise<boolean> {
95
+ return (await chromeIdentity(port)) === 'chrome';
96
+ }
97
+
98
+ /** isChromeIdentity, telling "answered as something else" apart from "did
99
+ * not answer in time" ('unknown': a timeout, a refused or reset connection,
100
+ * a non-OK status or an unparseable body). */
101
+ async function chromeIdentity(port: number): Promise<'chrome' | 'other' | 'unknown'> {
95
102
  try {
96
103
  const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
97
104
  signal: AbortSignal.timeout(1000),
98
105
  });
99
- if (!res.ok) return false;
106
+ if (!res.ok) return 'unknown';
100
107
  const info = (await res.json()) as { Browser?: string };
101
108
  // Edge's Browser field is "Edg/<version>" with no "chrome"/"chromium"
102
109
  // substring, even though it's Chromium-based and CDP-compatible —
103
110
  // accept it alongside Chrome/Chromium rather than rejecting a valid target.
104
- return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser);
111
+ return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser)
112
+ ? 'chrome'
113
+ : 'other';
105
114
  } catch {
106
- return false;
115
+ return 'unknown';
107
116
  }
108
117
  }
109
118
 
@@ -353,6 +362,17 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
353
362
  // process's pid overwriting the real winner's, corrupting the very map
354
363
  // closeBrowser()'s kill fallback depends on.
355
364
 
365
+ // With port 0 a refused or timed-out launch is not tracked under any port,
366
+ // so no closeBrowser() could ever reach it — stop it rather than leave it
367
+ // running.
368
+ const killUntracked = () => {
369
+ try {
370
+ if (child.pid) process.kill(child.pid, 'SIGKILL');
371
+ } catch {
372
+ /* already gone */
373
+ }
374
+ };
375
+
356
376
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
357
377
  const deadline = Date.now() + launchTimeout;
358
378
  while (Date.now() < deadline) {
@@ -361,10 +381,17 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
361
381
  if (earlyFailure) throw earlyFailure;
362
382
  const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
363
383
  if (boundPort !== null && (await isPortOpen(boundPort))) {
364
- if (await isChromeIdentity(boundPort)) {
384
+ const identity = await chromeIdentity(boundPort);
385
+ if (identity === 'chrome') {
365
386
  track(boundPort);
366
387
  return boundPort;
367
388
  }
389
+ // A Chrome that has only just opened its endpoint, on a loaded
390
+ // machine, can answer /json/list (no timeout) yet miss the identity
391
+ // check's 1 s timeout on /json/version. That says nothing about who is
392
+ // listening — keep polling rather than refuse our own Chrome.
393
+ if (identity === 'unknown') continue;
394
+ if (port === 0) killUntracked();
368
395
  throw new Error(
369
396
  `Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
370
397
  `Refusing to attach — pass a different port or free port ${boundPort}.`,
@@ -375,13 +402,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
375
402
  if (earlyFailure) throw earlyFailure;
376
403
 
377
404
  if (port === 0) {
378
- // Not tracked under any port yet, so no closeBrowser() could ever reach
379
- // it — stop it here rather than leave it running.
380
- try {
381
- if (child.pid) process.kill(child.pid, 'SIGKILL');
382
- } catch {
383
- /* already gone */
384
- }
405
+ killUntracked();
385
406
  throw new Error(
386
407
  `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
387
408
  );