@monoes/monobrowse 1.0.4 → 1.0.5

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.4",
3
+ "version": "1.0.5",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unit tests for launchBrowser's port-scan/attach decisions (browser.ts).
3
+ * Deliberately scoped to branches reachable WITHOUT spawning a real Chrome —
4
+ * each scenario below resolves via attach or a thrown error before
5
+ * launchBrowser would ever exec a browser binary, so these run fast and
6
+ * don't depend on Chrome being installed in CI.
7
+ *
8
+ * Fixed port range (23470-23479) chosen to avoid colliding with real
9
+ * services; each test binds/tears down its own listeners.
10
+ */
11
+ import { describe, it, expect, afterEach } from 'vitest';
12
+ import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'net';
13
+ import { createServer as createHttpServer, type Server as HttpServer } from 'http';
14
+ import { launchBrowser } from '../browser/browser.js';
15
+
16
+ const BASE = 23470;
17
+
18
+ let servers: Array<TcpServer | HttpServer> = [];
19
+ let sockets: Socket[] = [];
20
+
21
+ afterEach(async () => {
22
+ // server.close() only stops accepting NEW connections — it waits for
23
+ // already-open ones (the fetches that hung until their AbortSignal fired)
24
+ // to close on their own, which can outlast the test. Destroy explicitly.
25
+ for (const sock of sockets) sock.destroy();
26
+ sockets = [];
27
+ await Promise.all(servers.map(s => new Promise<void>(resolve => s.close(() => resolve()))));
28
+ servers = [];
29
+ }, 5000);
30
+
31
+ /** Bind a bare TCP listener — accepts connections but speaks no HTTP/CDP. */
32
+ function occupyNonChrome(port: number): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ const s = createTcpServer(sock => { sockets.push(sock); /* accept and do nothing — no CDP response */ });
35
+ s.once('error', reject);
36
+ s.listen(port, '127.0.0.1', () => { servers.push(s); resolve(); });
37
+ });
38
+ }
39
+
40
+ /** Bind an HTTP server that answers /json/version like a real Chrome would. */
41
+ function occupyChrome(port: number): Promise<void> {
42
+ return new Promise((resolve, reject) => {
43
+ const s = createHttpServer((req, res) => {
44
+ if (req.url === '/json/version') {
45
+ res.writeHead(200, { 'Content-Type': 'application/json' });
46
+ res.end(JSON.stringify({ Browser: 'Chrome/999.0.0.0' }));
47
+ } else {
48
+ res.writeHead(404); res.end();
49
+ }
50
+ });
51
+ s.on('connection', sock => sockets.push(sock));
52
+ s.once('error', reject);
53
+ s.listen(port, '127.0.0.1', () => { servers.push(s); resolve(); });
54
+ });
55
+ }
56
+
57
+ describe('launchBrowser — port scan/attach decisions', () => {
58
+ it('attaches immediately when the EXACT requested port already identifies as Chrome', async () => {
59
+ const port = BASE + 0;
60
+ await occupyChrome(port);
61
+ await expect(launchBrowser({ port })).resolves.toBe(port);
62
+ });
63
+
64
+ it('strictPort: throws immediately on an occupied non-Chrome requested port, never scans', async () => {
65
+ const port = BASE + 1;
66
+ await occupyNonChrome(port);
67
+ await occupyChrome(port + 1); // would succeed if scanning happened — must not be reached
68
+ await expect(launchBrowser({ port, strictPort: true }))
69
+ .rejects.toThrow(/does not identify as Chrome/);
70
+ });
71
+
72
+ it('strictPort: attaches on an occupied Chrome requested port (identical to non-strict)', async () => {
73
+ const port = BASE + 2;
74
+ await occupyChrome(port);
75
+ await expect(launchBrowser({ port, strictPort: true })).resolves.toBe(port);
76
+ });
77
+
78
+ it('all candidates occupied by non-Chrome processes: throws a clear range error', async () => {
79
+ const port = BASE + 3;
80
+ // Occupy the full 10-port scan window with non-Chrome listeners.
81
+ for (let i = 0; i < 10; i++) await occupyNonChrome(port + i);
82
+ await expect(launchBrowser({ port })).rejects.toThrow(
83
+ new RegExp(`Ports ${port}-${port + 9} are all occupied`)
84
+ );
85
+ }, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
86
+
87
+ it('a Chrome instance on a SCANNED (not the originally requested) port is skipped, not attached to', async () => {
88
+ // Security-relevant case: the attach-if-Chrome shortcut must apply only
89
+ // to the exact port the caller asked for. A Chrome instance sitting on a
90
+ // later candidate the caller never named must not be silently attached
91
+ // to — occupied candidates (Chrome or not) beyond the first are simply
92
+ // skipped, so if every candidate is occupied the call still fails even
93
+ // though one of them is an attachable Chrome.
94
+ const port = BASE + 4;
95
+ await occupyNonChrome(port); // requested port: occupied, not Chrome
96
+ await occupyChrome(port + 1); // scanned candidate: IS Chrome — must be skipped, not attached
97
+ for (let i = 2; i < 10; i++) await occupyNonChrome(port + i); // remaining candidates: occupied
98
+ await expect(launchBrowser({ port })).rejects.toThrow(
99
+ new RegExp(`Ports ${port}-${port + 9} are all occupied`)
100
+ );
101
+ }, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
102
+ });
@@ -59,7 +59,7 @@ export async function isPortOpen(port: number): Promise<boolean> {
59
59
  */
60
60
  async function isChromeIdentity(port: number): Promise<boolean> {
61
61
  try {
62
- const res = await fetch(`http://127.0.0.1:${port}/json/version`);
62
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1000) });
63
63
  if (!res.ok) return false;
64
64
  const info = (await res.json()) as { Browser?: string };
65
65
  return typeof info.Browser === 'string' && /chrom(e|ium)/i.test(info.Browser);
@@ -83,27 +83,73 @@ function isTcpPortOpen(port: number, timeoutMs = 1000): Promise<boolean> {
83
83
  });
84
84
  }
85
85
 
86
+ /** Ports scanned when the requested one is occupied by a non-Chrome process
87
+ * (e.g. another local tool that happens to reuse Chrome's conventional CDP
88
+ * default, 9222 — mirrors the auto-increment convention this monorepo's own
89
+ * dashboard server uses in bindServer/server.mjs). Only occupied-by-a-
90
+ * DIFFERENT-process is worked around; an already-attachable Chrome on the
91
+ * requested port is still returned as-is (existing "attach, don't relaunch"
92
+ * behavior). */
93
+ const LAUNCH_PORT_SCAN_TRIES = 10;
94
+
86
95
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
87
96
  const rawPort = config.port ?? DEFAULT_PORT;
88
97
  // Validate port is in a safe range for localhost CDP debugging
89
98
  if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
90
99
  throw new Error(`Invalid port: ${rawPort}. Must be an integer between 1024 and 65535.`);
91
100
  }
92
- const port = rawPort;
93
-
94
- if (await isPortOpen(port)) {
95
- // Something CDP-speaking is already there verify it's actually Chrome/Chromium
96
- // before attaching, so we don't silently take over an unrelated real browser
97
- // (e.g. the user's own personal Chrome) that happens to be on this port.
98
- if (await isChromeIdentity(port)) {
99
- return port;
101
+
102
+ // strictPort: fail fast on the exact requested port, matching the old
103
+ // behavior (Vite has the same escape hatch for the same reason) — for
104
+ // callers that treat the error as a signal ("this port is taken by
105
+ // something else, bail") rather than consuming the returned port.
106
+ if (config.strictPort) {
107
+ if (await isTcpPortOpen(rawPort)) {
108
+ if (await isChromeIdentity(rawPort)) return rawPort;
109
+ throw new Error(
110
+ `Port ${rawPort} is occupied by a process that does not identify as Chrome/Chromium. ` +
111
+ `Refusing to attach — pass a different port or free port ${rawPort}.`
112
+ );
100
113
  }
101
- throw new Error(
102
- `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
103
- `Refusing to attach — pass a different port or free port ${port}.`
104
- );
114
+ return launchOnFreePort(config, rawPort);
115
+ }
116
+
117
+ const candidates: number[] = [];
118
+ for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++) candidates.push(rawPort + i);
119
+
120
+ // Attach-if-already-Chrome only applies to the EXACT requested port — the
121
+ // original, deliberate, single-port risk ("don't silently take over an
122
+ // unrelated real browser that happens to be on this port"). Scanning past
123
+ // an occupied default must not let that same shortcut attach to a
124
+ // DIFFERENT Chrome instance the caller never named; forward candidates are
125
+ // launch-only (skip if anything is there, Chrome or not).
126
+ if (await isTcpPortOpen(rawPort)) {
127
+ if (await isChromeIdentity(rawPort)) return rawPort;
128
+ } else {
129
+ return launchOnFreePort(config, rawPort);
130
+ }
131
+
132
+ for (const candidate of candidates.slice(1)) {
133
+ // TCP-level check for "is anything at all listening" — isPortOpen()
134
+ // does a full CDP /json fetch, which returns false BOTH for a genuinely
135
+ // free port and for one occupied by a non-CDP process (that ambiguity is
136
+ // exactly what the post-spawn isTcpPortOpen fallback below exists to
137
+ // resolve, the hard way, after a 10s launch timeout). Checking the raw
138
+ // socket first tells free and occupied apart up front, so the scan can
139
+ // skip an occupied candidate instead of trying to spawn Chrome on top of
140
+ // it and only discovering the conflict after a timeout.
141
+ if (!(await isTcpPortOpen(candidate))) return launchOnFreePort(config, candidate);
142
+ // Occupied (by anything — not just non-Chrome, per the note above) —
143
+ // try the next candidate instead of failing outright, same as a normal
144
+ // EADDRINUSE retry would.
105
145
  }
146
+ throw new Error(
147
+ `Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
148
+ `isn't a Chrome/Chromium instance to attach to. Pass a different --port.`
149
+ );
150
+ }
106
151
 
152
+ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
107
153
  const chromePath = findChrome(config.executablePath);
108
154
  const userDataDir = config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}`);
109
155
 
@@ -1,5 +1,10 @@
1
1
  export interface BrowserConfig {
2
2
  port?: number;
3
+ /** Fail fast if `port` is occupied by anything other than an attachable
4
+ * Chrome, instead of scanning forward to the next free port. Default false
5
+ * (scan) — set true for callers that treat the occupied-port error as a
6
+ * signal rather than consuming launchBrowser's returned port. */
7
+ strictPort?: boolean;
3
8
  headless?: boolean;
4
9
  executablePath?: string;
5
10
  userDataDir?: string;