@monoes/monobrowse 1.0.8 → 1.0.11

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.8",
3
+ "version": "1.0.11",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "src"
32
32
  ],
33
33
  "engines": {
34
- "node": ">=18.0.0"
34
+ "node": ">=22.12.0"
35
35
  },
36
36
  "keywords": [
37
37
  "browser-automation",
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15
15
  import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
16
+ import { setTimeout as realDelay } from 'timers/promises';
16
17
  import { join } from 'path';
17
18
  import { tmpdir } from 'os';
18
19
 
@@ -52,6 +53,10 @@ let tempDir: string;
52
53
  let killSpy: ReturnType<typeof vi.spyOn>;
53
54
 
54
55
  beforeEach(async () => {
56
+ // Clean env vars that can interfere with timer behavior in test environment
57
+ delete process.env.MONOMIND_SDK_AGENT;
58
+ delete process.env.MONOMIND_HOOK_QUIET;
59
+
55
60
  vi.useFakeTimers();
56
61
  lastSocket = null;
57
62
  tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-close-test-'));
@@ -71,6 +76,29 @@ async function writePersistedPort(port: number, pid: number, savedAt: number): P
71
76
  await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port, pid, launched: true, savedAt }), 'utf-8');
72
77
  }
73
78
 
79
+ /**
80
+ * Block (in REAL time) until closeBrowser()'s process-exit poll has actually
81
+ * started, observed via the poll's own first liveness probe — `kill(pid, 0)`
82
+ * is the first thing waitForProcessExit() does.
83
+ *
84
+ * Needed because closeBrowser() awaits the persisted-port read (real
85
+ * filesystem I/O) BEFORE that poll starts, while `advanceTimersByTimeAsync`
86
+ * only moves a FAKE clock and does not wait for real I/O. Advancing a fixed
87
+ * number of times therefore raced the read: every advance can complete in
88
+ * microseconds of real time while the read is still in flight, so on a loaded
89
+ * host the budget ran out with the poll never started — and since nothing
90
+ * then drives the fake clock again, the test did not merely run slow, it
91
+ * deadlocked on an unsettled promise until the test timeout fired.
92
+ *
93
+ * Once this returns, the remaining work is pure fake timers, so a single
94
+ * bounded advance carries it deterministically.
95
+ */
96
+ async function exitPollStarted(pid: number): Promise<void> {
97
+ while (!killSpy.mock.calls.some(([p, signal]) => p === pid && signal === 0)) {
98
+ await realDelay(0);
99
+ }
100
+ }
101
+
74
102
  async function connectedClient(autoAck = false) {
75
103
  vi.resetModules();
76
104
  const { CdpClient } = await import('../browser/cdp.js');
@@ -153,13 +181,15 @@ describe('#115 review follow-up: closeBrowser() cross-process PID-kill fallback'
153
181
 
154
182
  const client = await connectedClient(true);
155
183
  const closePromise = closeBrowser(client, port);
156
- // Still running a moment after the ack — close() must NOT have resolved.
157
- await vi.advanceTimersByTimeAsync(200);
158
184
  let settled = false;
159
185
  void closePromise.then(() => {
160
186
  settled = true;
161
187
  });
162
- await vi.advanceTimersByTimeAsync(0);
188
+
189
+ // Poll is running and the process is still alive — several poll ticks in,
190
+ // close() must NOT have resolved.
191
+ await exitPollStarted(22222);
192
+ await vi.advanceTimersByTimeAsync(200);
163
193
  expect(settled).toBe(false);
164
194
 
165
195
  alive = false; // Chrome finishes exiting
@@ -176,16 +206,19 @@ describe('#115 review follow-up: closeBrowser() cross-process PID-kill fallback'
176
206
 
177
207
  const client = await connectedClient(true);
178
208
  const closePromise = closeBrowser(client, port);
179
- // The exit poll schedules one timer per tick, each from the previous
180
- // tick's callback. How far a single advance carries through a chain like
181
- // that is a fake-timer implementation detail that differs by Node version
182
- // (runAllTimersAsync hung this test on Node 22 while passing on 26), so
183
- // step the clock until the promise settles instead of assuming.
184
209
  let settled = false;
185
210
  void closePromise.then(() => {
186
211
  settled = true;
187
212
  });
188
- for (let i = 0; i < 200 && !settled; i++) await vi.advanceTimersByTimeAsync(100);
213
+
214
+ // Wait for the poll to exist before driving it (see exitPollStarted) —
215
+ // every previous fix here guessed an advance budget instead, which is
216
+ // what made this test hang rather than merely run slow. From here on the
217
+ // poll is the only thing left and it is pure fake timers, so one advance
218
+ // past its PROCESS_EXIT_TIMEOUT_MS (5000ms) deadline settles it, with no
219
+ // dependence on how fast the host happens to be.
220
+ await exitPollStarted(33333);
221
+ await vi.advanceTimersByTimeAsync(6000);
189
222
  await closePromise;
190
223
 
191
224
  expect(settled).toBe(true);
@@ -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
+ });
@@ -276,11 +276,11 @@ describe('captureSnapshot — property extraction and rendering', () => {
276
276
  });
277
277
 
278
278
  describe('resolveRef', () => {
279
- it('returns the ref when present', () => {
279
+ it('returns the ref when present', async () => {
280
280
  const refs = new Map<string, ElementRef>([
281
281
  ['e1', { ref: 'e1', role: 'button', name: 'Go', nodeId: 1 }],
282
282
  ]);
283
- expect(resolveRef({} as CdpClient, 'S1', refs, 'e1')).resolves.toMatchObject({ ref: 'e1' });
283
+ await expect(resolveRef({} as CdpClient, 'S1', refs, 'e1')).resolves.toMatchObject({ ref: 'e1' });
284
284
  });
285
285
 
286
286
  it('throws a snapshot-first hint when the ref is unknown', async () => {
@@ -387,7 +387,12 @@ async function waitForProcessExit(pid: number): Promise<boolean> {
387
387
  export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
388
388
  // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
389
389
  if (url.length > 2_097_152) throw new Error('URL exceeds 2 MB limit');
390
- 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}`);
391
396
  await waitForNetworkIdle(client, sessionId, 500, 30_000);
392
397
  }
393
398