@monoes/monobrowse 1.0.7 → 1.0.9

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.
Files changed (74) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +0 -0
  3. package/dist/src/__tests__/close-browser.test.js +78 -4
  4. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  5. package/dist/src/__tests__/open-url-navigate-error.test.d.ts +2 -0
  6. package/dist/src/__tests__/open-url-navigate-error.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/open-url-navigate-error.test.js +48 -0
  8. package/dist/src/__tests__/open-url-navigate-error.test.js.map +1 -0
  9. package/dist/src/browser/browser.d.ts +9 -0
  10. package/dist/src/browser/browser.d.ts.map +1 -1
  11. package/dist/src/browser/browser.js +51 -10
  12. package/dist/src/browser/browser.js.map +1 -1
  13. package/dist/src/browser/dashboard/ui.html +0 -0
  14. package/dist/src/cli.js +0 -0
  15. package/dist/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +9 -10
  17. package/src/__tests__/adapters.test.ts +0 -0
  18. package/src/__tests__/batch-eval-parsing.test.ts +0 -0
  19. package/src/__tests__/box-model.test.ts +0 -0
  20. package/src/__tests__/browser-launch.test.ts +0 -0
  21. package/src/__tests__/cdp-client.test.ts +0 -0
  22. package/src/__tests__/close-browser.test.ts +83 -4
  23. package/src/__tests__/console-log.test.ts +0 -0
  24. package/src/__tests__/find.test.ts +0 -0
  25. package/src/__tests__/frame-command.test.ts +0 -0
  26. package/src/__tests__/open-url-navigate-error.test.ts +58 -0
  27. package/src/__tests__/output.test.ts +0 -0
  28. package/src/__tests__/ref-cache.test.ts +0 -0
  29. package/src/__tests__/session-state.test.ts +0 -0
  30. package/src/__tests__/snapshot.test.ts +0 -0
  31. package/src/__tests__/storage-eval.test.ts +0 -0
  32. package/src/__tests__/tabs.test.ts +0 -0
  33. package/src/browser/action-builder/analyzer.ts +0 -0
  34. package/src/browser/action-builder/types.ts +0 -0
  35. package/src/browser/actions.ts +0 -0
  36. package/src/browser/adapters/gemini.ts +0 -0
  37. package/src/browser/adapters/google.ts +0 -0
  38. package/src/browser/adapters/index.ts +0 -0
  39. package/src/browser/adapters/instagram.ts +0 -0
  40. package/src/browser/adapters/linkedin.ts +0 -0
  41. package/src/browser/adapters/microsoft.ts +0 -0
  42. package/src/browser/adapters/x.ts +0 -0
  43. package/src/browser/batch.ts +0 -0
  44. package/src/browser/browser.ts +48 -10
  45. package/src/browser/cdp.ts +0 -0
  46. package/src/browser/console-log.ts +0 -0
  47. package/src/browser/dashboard/api-types.ts +0 -0
  48. package/src/browser/dashboard/server.ts +0 -0
  49. package/src/browser/dashboard/ui.html +0 -0
  50. package/src/browser/dialog.ts +0 -0
  51. package/src/browser/emulation.ts +0 -0
  52. package/src/browser/find.ts +0 -0
  53. package/src/browser/har.ts +0 -0
  54. package/src/browser/index.ts +0 -0
  55. package/src/browser/network.ts +0 -0
  56. package/src/browser/pdf.ts +0 -0
  57. package/src/browser/profiler.ts +0 -0
  58. package/src/browser/record.ts +0 -0
  59. package/src/browser/ref-cache.ts +0 -0
  60. package/src/browser/screenshot.ts +0 -0
  61. package/src/browser/session.ts +0 -0
  62. package/src/browser/snapshot.ts +0 -0
  63. package/src/browser/storage.ts +0 -0
  64. package/src/browser/tabs.ts +0 -0
  65. package/src/browser/trace.ts +0 -0
  66. package/src/browser/vitals.ts +0 -0
  67. package/src/browser/wait.ts +0 -0
  68. package/src/cli/action.ts +0 -0
  69. package/src/cli/commands.ts +0 -0
  70. package/src/cli/output.ts +0 -0
  71. package/src/cli/platform.ts +0 -0
  72. package/src/cli/types.ts +0 -0
  73. package/src/cli.ts +0 -0
  74. package/src/index.ts +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.7",
4
- "description": "Native browser automation via Chrome DevTools Protocol \u2014 the engine powering monomind browse",
3
+ "version": "1.0.9",
4
+ "description": "Native browser automation via Chrome DevTools Protocol the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "monobrowse": "./dist/src/cli.js"
@@ -33,13 +33,6 @@
33
33
  "engines": {
34
34
  "node": ">=18.0.0"
35
35
  },
36
- "scripts": {
37
- "build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html",
38
- "test": "vitest run",
39
- "test:coverage": "vitest run --coverage",
40
- "prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
41
- "prepublishOnly": "npm run build"
42
- },
43
36
  "keywords": [
44
37
  "browser-automation",
45
38
  "cdp",
@@ -66,5 +59,11 @@
66
59
  "devDependencies": {
67
60
  "typescript": "^7.0.2",
68
61
  "vitest": "^4.1.4"
62
+ },
63
+ "scripts": {
64
+ "build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html",
65
+ "test": "vitest run",
66
+ "test:coverage": "vitest run --coverage",
67
+ "prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo"
69
68
  }
70
- }
69
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -30,10 +30,15 @@ class FakeWs {
30
30
  emit(event: string, ...args: unknown[]): void {
31
31
  for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
32
32
  }
33
- send(_data: string, cb?: (err?: Error) => void): void {
34
- // Never call cb simulates a hung Browser.close so closeBrowser's own
35
- // BROWSER_CLOSE_TIMEOUT_MS race is what settles it (ungraceful path).
33
+ /** When true, send() acknowledges the CDP command by delivering a matching
34
+ * empty result the GRACEFUL path. Default false leaves it hanging so
35
+ * closeBrowser's own BROWSER_CLOSE_TIMEOUT_MS race settles it (ungraceful). */
36
+ autoAck = false;
37
+ send(data: string, cb?: (err?: Error) => void): void {
36
38
  void cb;
39
+ if (!this.autoAck) return;
40
+ const { id } = JSON.parse(data) as { id: number };
41
+ queueMicrotask(() => this.deliver({ id, result: {} }));
37
42
  }
38
43
  close(): void {}
39
44
  deliver(msg: unknown): void {
@@ -47,6 +52,10 @@ let tempDir: string;
47
52
  let killSpy: ReturnType<typeof vi.spyOn>;
48
53
 
49
54
  beforeEach(async () => {
55
+ // Clean env vars that can interfere with timer behavior in test environment
56
+ delete process.env.MONOMIND_SDK_AGENT;
57
+ delete process.env.MONOMIND_HOOK_QUIET;
58
+
50
59
  vi.useFakeTimers();
51
60
  lastSocket = null;
52
61
  tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-close-test-'));
@@ -66,11 +75,12 @@ async function writePersistedPort(port: number, pid: number, savedAt: number): P
66
75
  await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port, pid, launched: true, savedAt }), 'utf-8');
67
76
  }
68
77
 
69
- async function connectedClient() {
78
+ async function connectedClient(autoAck = false) {
70
79
  vi.resetModules();
71
80
  const { CdpClient } = await import('../browser/cdp.js');
72
81
  const client = new CdpClient();
73
82
  const p = client.connect('ws://127.0.0.1:9333/devtools/page/ABC');
83
+ lastSocket!.autoAck = autoAck;
74
84
  lastSocket!.emit('open');
75
85
  await p;
76
86
  return client;
@@ -128,6 +138,75 @@ describe('#115 review follow-up: closeBrowser() cross-process PID-kill fallback'
128
138
  expect(killSpy).not.toHaveBeenCalledWith(11111, 'SIGKILL');
129
139
  });
130
140
 
141
+ // The graceful path is what the monodesign detection driver actually takes,
142
+ // and it had no coverage: closeBrowser used to return on the Browser.close
143
+ // ack while Chrome was still exiting, holding its CDP port and its profile's
144
+ // singleton lock, with an unref'd 1s timer as the only force-kill (which
145
+ // never fired at all if the caller's process exited first).
146
+ it('graceful close waits for the process to actually exit, without killing it', async () => {
147
+ vi.resetModules();
148
+ const { closeBrowser } = await import('../browser/browser.js');
149
+ const port = 9337;
150
+ await writePersistedPort(port, 22222, Date.now());
151
+
152
+ let alive = true;
153
+ killSpy.mockImplementation((_pid: unknown, signal?: unknown) => {
154
+ if (signal === 0 && !alive) throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' });
155
+ return true as never;
156
+ });
157
+
158
+ const client = await connectedClient(true);
159
+ const closePromise = closeBrowser(client, port);
160
+ // Still running a moment after the ack — close() must NOT have resolved.
161
+ await vi.advanceTimersByTimeAsync(200);
162
+ let settled = false;
163
+ void closePromise.then(() => {
164
+ settled = true;
165
+ });
166
+ await vi.advanceTimersByTimeAsync(0);
167
+ expect(settled).toBe(false);
168
+
169
+ alive = false; // Chrome finishes exiting
170
+ await vi.advanceTimersByTimeAsync(200);
171
+ await closePromise;
172
+ expect(killSpy).not.toHaveBeenCalledWith(22222, 'SIGKILL');
173
+ });
174
+
175
+ it('graceful close force-kills a process that never exits', async () => {
176
+ vi.resetModules();
177
+ const { closeBrowser } = await import('../browser/browser.js');
178
+ const port = 9338;
179
+ await writePersistedPort(port, 33333, Date.now());
180
+
181
+ const client = await connectedClient(true);
182
+ const closePromise = closeBrowser(client, port);
183
+ // The exit poll schedules one timer per tick, each from the previous
184
+ // tick's callback. How far a single advance carries through a chain like
185
+ // that is a fake-timer implementation detail that differs by Node version
186
+ // (runAllTimersAsync hung this test on Node 22 while passing on 26), so
187
+ // step the clock until the promise settles instead of assuming.
188
+ //
189
+ // Each `advanceTimersByTimeAsync` call has real (non-fake) overhead of
190
+ // its own — a previous fix here just raised the iteration count at a
191
+ // fixed 100ms step (100ms x 600 = enough fake-time headroom, but up to
192
+ // 600 real await round-trips), which still intermittently timed out on a
193
+ // slow/busy CI runner because that overhead is per CALL, not per fake-ms
194
+ // advanced. Stepping by a much larger 2000ms per call reaches the same
195
+ // (and greater) fake-time coverage in a fraction of the calls — the exit
196
+ // poll's PROCESS_EXIT_TIMEOUT_MS deadline (5000ms) is comfortably inside
197
+ // even a handful of iterations — without giving up the settle-and-stop
198
+ // loop that avoids the Node 22 hang a single unbounded advance hit.
199
+ let settled = false;
200
+ void closePromise.then(() => {
201
+ settled = true;
202
+ });
203
+ for (let i = 0; i < 60 && !settled; i++) await vi.advanceTimersByTimeAsync(2000);
204
+ await closePromise;
205
+
206
+ expect(settled).toBe(true);
207
+ expect(killSpy).toHaveBeenCalledWith(33333, 'SIGKILL');
208
+ }, 20_000);
209
+
131
210
  it('no persisted port file at all — closeBrowser is a no-op, no kill attempted', async () => {
132
211
  vi.resetModules();
133
212
  const { closeBrowser } = await import('../browser/browser.js');
File without changes
File without changes
File without changes
@@ -0,0 +1,58 @@
1
+ /**
2
+ * openUrl() (browser.ts) previously ignored Page.navigate's own response and
3
+ * always fell through to waitForNetworkIdle, so a protocol-level navigation
4
+ * failure (e.g. a refused connection) settled on the chrome-error://
5
+ * chromewebdata/ page and `browse open` reported success with exit 0. CDP's
6
+ * Page.navigate response carries errorText when navigation itself failed —
7
+ * this checks that response before doing anything else.
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import type { CdpClient } from '../browser/cdp.js';
11
+ import { openUrl } from '../browser/browser.js';
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ /** Client stub that records calls and answers from a per-method table. */
18
+ function stubClient(table: Record<string, unknown> = {}): {
19
+ client: CdpClient;
20
+ calls: Array<{ method: string; params: unknown }>;
21
+ } {
22
+ const calls: Array<{ method: string; params: unknown }> = [];
23
+ const client = {
24
+ send: vi.fn(async (method: string, params: unknown) => {
25
+ calls.push({ method, params });
26
+ const entry = table[method];
27
+ if (entry instanceof Error) throw entry;
28
+ return entry ?? {};
29
+ }),
30
+ on: vi.fn(() => {
31
+ throw new Error('waitForNetworkIdle should not run once Page.navigate reports errorText');
32
+ }),
33
+ } as unknown as CdpClient;
34
+ return { client, calls };
35
+ }
36
+
37
+ describe('openUrl navigation-failure detection', () => {
38
+ it('throws when Page.navigate reports errorText, without waiting for network idle', async () => {
39
+ const { client, calls } = stubClient({
40
+ 'Page.navigate': { frameId: 'f1', errorText: 'net::ERR_CONNECTION_REFUSED' },
41
+ });
42
+
43
+ await expect(openUrl(client, 'sess-1', 'http://127.0.0.1:1/')).rejects.toThrow(
44
+ /net::ERR_CONNECTION_REFUSED/,
45
+ );
46
+ expect(calls).toEqual([
47
+ { method: 'Page.navigate', params: { url: 'http://127.0.0.1:1/' } },
48
+ ]);
49
+ });
50
+
51
+ it('rejects the 2 MB URL guard before ever calling Page.navigate', async () => {
52
+ const { client, calls } = stubClient();
53
+ const hugeUrl = `http://x/${'a'.repeat(2_097_152)}`;
54
+
55
+ await expect(openUrl(client, 'sess-1', hugeUrl)).rejects.toThrow(/2 MB/);
56
+ expect(calls).toHaveLength(0);
57
+ });
58
+ });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -14,6 +14,11 @@ const DEFAULT_PORT = 9222;
14
14
  const LAUNCH_TIMEOUT = 10_000;
15
15
  const POLL_INTERVAL = 200;
16
16
  const BROWSER_CLOSE_TIMEOUT_MS = 3000;
17
+ // How long closeBrowser() waits for the process to actually disappear before
18
+ // forcing it, and again after forcing. Bounded so a wedged Chrome cannot hang
19
+ // a caller indefinitely.
20
+ const PROCESS_EXIT_TIMEOUT_MS = 5000;
21
+ const PROCESS_EXIT_POLL_MS = 50;
17
22
  // A cross-process persisted PID older than this is not trusted for the
18
23
  // SIGKILL fallback in closeBrowser() — see the call site for why.
19
24
  const PERSISTED_PID_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -79,7 +84,10 @@ async function isChromeIdentity(port: number): Promise<boolean> {
79
84
  const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1000) });
80
85
  if (!res.ok) return false;
81
86
  const info = (await res.json()) as { Browser?: string };
82
- return typeof info.Browser === 'string' && /chrom(e|ium)/i.test(info.Browser);
87
+ // Edge's Browser field is "Edg/<version>" with no "chrome"/"chromium"
88
+ // substring, even though it's Chromium-based and CDP-compatible —
89
+ // accept it alongside Chrome/Chromium rather than rejecting a valid target.
90
+ return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser);
83
91
  } catch {
84
92
  return false;
85
93
  }
@@ -282,6 +290,15 @@ export async function connectToTarget(port: number, targetId?: string): Promise<
282
290
  * falls back to killing the tracked PID directly so a headed/interactive
283
291
  * browser window (e.g. one spawned for a login/CAPTCHA flow) never lingers
284
292
  * as a visible, authenticated, still-debuggable orphan process.
293
+ *
294
+ * On the graceful path this resolves only once the process is actually gone,
295
+ * or after force-killing it when it outlasts PROCESS_EXIT_TIMEOUT_MS. Chrome
296
+ * acknowledges `Browser.close` well before it exits, and this used to return
297
+ * on the ack with an unref'd 1s timer as the only force-kill — so it resolved
298
+ * while Chrome was still running and holding its port and its profile's
299
+ * singleton lock, and if the caller's process exited first the kill never
300
+ * happened at all. Callers reasonably read a resolved close() as "that
301
+ * browser is gone"; on the graceful path it now means that.
285
302
  */
286
303
  export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
287
304
  let gracefullyClosed = false;
@@ -338,23 +355,44 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
338
355
  return;
339
356
  }
340
357
 
341
- // Browser.close was acknowledged — give the process a moment to exit on
342
- // its own, then verify and force-kill as a safety net in case it hung.
343
- const t = setTimeout(() => {
358
+ // Browser.close was acknowledged — wait for the process to actually go away,
359
+ // then force-kill as a safety net in case it hung.
360
+ if (await waitForProcessExit(pid)) return;
361
+ try {
362
+ process.kill(pid, 'SIGKILL');
363
+ } catch {
364
+ /* raced us to exit — expected */
365
+ }
366
+ }
367
+
368
+ /** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
369
+ * whether it exited. `process.kill(pid, 0)` throws once the process is no
370
+ * longer there, on Windows as well as POSIX. */
371
+ async function waitForProcessExit(pid: number): Promise<boolean> {
372
+ const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS;
373
+ for (;;) {
344
374
  try {
345
- process.kill(pid, 0); // throws if the process is already gone
346
- process.kill(pid, 'SIGKILL');
375
+ process.kill(pid, 0);
347
376
  } catch {
348
- /* already exited — expected path */
377
+ return true;
349
378
  }
350
- }, 1000);
351
- t.unref?.();
379
+ if (Date.now() >= deadline) return false;
380
+ await new Promise((resolve) => {
381
+ const t = setTimeout(resolve, PROCESS_EXIT_POLL_MS);
382
+ t.unref?.();
383
+ });
384
+ }
352
385
  }
353
386
 
354
387
  export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
355
388
  // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
356
389
  if (url.length > 2_097_152) throw new Error('URL exceeds 2 MB limit');
357
- await client.send('Page.navigate', { url }, sessionId);
390
+ // Page.navigate reports a protocol-level failure (e.g. a refused connection)
391
+ // via errorText in its own response, before Chrome ever settles on the
392
+ // chrome-error://chromewebdata/ page — without this check a refused
393
+ // connection previously reported success against that error page.
394
+ const nav = await client.send<{ errorText?: string }>('Page.navigate', { url }, sessionId);
395
+ if (nav.errorText) throw new Error(`Navigation to ${url} failed: ${nav.errorText}`);
358
396
  await waitForNetworkIdle(client, sessionId, 500, 30_000);
359
397
  }
360
398
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/cli/action.ts CHANGED
File without changes
File without changes
package/src/cli/output.ts CHANGED
File without changes
File without changes
package/src/cli/types.ts CHANGED
File without changes
package/src/cli.ts CHANGED
File without changes
package/src/index.ts CHANGED
File without changes