/ src / browser.test.ts
browser.test.ts
  1  import { describe, it, expect, vi } from 'vitest';
  2  import { BrowserBridge, generateStealthJs } from './browser/index.js';
  3  import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
  4  import { withTimeoutMs } from './runtime.js';
  5  import { __test__ as cdpTest } from './browser/cdp.js';
  6  import { classifyBrowserError } from './browser/errors.js';
  7  import * as daemonClient from './browser/daemon-client.js';
  8  
  9  describe('browser helpers', () => {
 10    it('extracts tab entries from string snapshots', () => {
 11      const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
 12  
 13      expect(entries).toEqual([
 14        { index: 0, identity: 'https://example.com' },
 15        { index: 1, identity: 'Chrome Extension' },
 16      ]);
 17    });
 18  
 19    it('extracts tab entries from MCP markdown format', () => {
 20      const entries = extractTabEntries(
 21        '- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
 22      );
 23  
 24      expect(entries).toEqual([
 25        { index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
 26        { index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
 27      ]);
 28    });
 29  
 30    it('closes only tabs that were opened during the session', () => {
 31      const tabsToClose = diffTabIndexes(
 32        ['https://example.com', 'Chrome Extension'],
 33        [
 34          { index: 0, identity: 'https://example.com' },
 35          { index: 1, identity: 'Chrome Extension' },
 36          { index: 2, identity: 'https://target.example/page' },
 37          { index: 3, identity: 'chrome-extension://bridge' },
 38        ],
 39      );
 40  
 41      expect(tabsToClose).toEqual([3, 2]);
 42    });
 43  
 44    it('keeps only the tail of stderr buffers', () => {
 45      expect(appendLimited('12345', '67890', 8)).toBe('34567890');
 46    });
 47  
 48    it('times out slow promises', async () => {
 49      await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
 50    });
 51  
 52    it('classifies browser errors with correct kind and retry advice', () => {
 53      // CDP target navigation — page-level settle retry
 54      const nav = classifyBrowserError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'));
 55      expect(nav.kind).toBe('target-navigation');
 56      expect(nav.delayMs).toBe(200);
 57  
 58      // Extension transient — daemon-client retry only, NOT page-level
 59      const ext = classifyBrowserError(new Error('Extension disconnected'));
 60      expect(ext.kind).toBe('extension-transient');
 61      expect(ext.delayMs).toBe(1500);
 62  
 63      // Non-transient errors — not retryable
 64      expect(classifyBrowserError(new Error('malformed exec payload')).kind).toBe('non-retryable');
 65      expect(classifyBrowserError(new Error('Permission denied')).kind).toBe('non-retryable');
 66    });
 67  
 68    it('prefers the real Electron app target over DevTools and blank pages', () => {
 69      const target = cdpTest.selectCDPTarget([
 70        {
 71          type: 'page',
 72          title: 'DevTools - localhost:9224',
 73          url: 'devtools://devtools/bundled/inspector.html',
 74          webSocketDebuggerUrl: 'ws://127.0.0.1:9224/devtools',
 75        },
 76        {
 77          type: 'page',
 78          title: '',
 79          url: 'about:blank',
 80          webSocketDebuggerUrl: 'ws://127.0.0.1:9224/blank',
 81        },
 82        {
 83          type: 'app',
 84          title: 'Antigravity',
 85          url: 'http://localhost:3000/',
 86          webSocketDebuggerUrl: 'ws://127.0.0.1:9224/app',
 87        },
 88      ]);
 89  
 90      expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9224/app');
 91    });
 92  
 93    it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
 94      vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
 95  
 96      const target = cdpTest.selectCDPTarget([
 97        {
 98          type: 'app',
 99          title: 'Cursor',
100          url: 'http://localhost:3000/cursor',
101          webSocketDebuggerUrl: 'ws://127.0.0.1:9226/cursor',
102        },
103        {
104          type: 'app',
105          title: 'OpenAI Codex',
106          url: 'http://localhost:3000/codex',
107          webSocketDebuggerUrl: 'ws://127.0.0.1:9226/codex',
108        },
109      ]);
110  
111      expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex');
112    });
113  });
114  
115  describe('BrowserBridge state', () => {
116    it('transitions to closed after close()', async () => {
117      const bridge = new BrowserBridge();
118  
119      expect(bridge.state).toBe('idle');
120  
121      await bridge.close();
122  
123      expect(bridge.state).toBe('closed');
124    });
125  
126    it('rejects connect() after the session has been closed', async () => {
127      const bridge = new BrowserBridge();
128      await bridge.close();
129  
130      await expect(bridge.connect()).rejects.toThrow('Session is closed');
131    });
132  
133    it('rejects connect() while already connecting', async () => {
134      const bridge = new BrowserBridge();
135      (bridge as unknown as { _state: string })._state = 'connecting';
136  
137      await expect(bridge.connect()).rejects.toThrow('Already connecting');
138    });
139  
140    it('rejects connect() while closing', async () => {
141      const bridge = new BrowserBridge();
142      (bridge as unknown as { _state: string })._state = 'closing';
143  
144      await expect(bridge.connect()).rejects.toThrow('Session is closing');
145    });
146  
147    it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
148      const { PKG_VERSION } = await import('./version.js');
149      vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
150        state: 'no-extension',
151        status: {
152          ok: true,
153          pid: 1,
154          uptime: 0,
155          daemonVersion: PKG_VERSION,
156          extensionConnected: false,
157          pending: 0,
158          memoryMB: 0,
159          port: 0,
160        },
161      });
162  
163      const bridge = new BrowserBridge();
164  
165      await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
166    });
167  
168    it('attempts stale daemon replacement when daemonVersion is missing', async () => {
169      vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
170        state: 'no-extension',
171        status: {
172          ok: true,
173          pid: 1,
174          uptime: 0,
175          extensionConnected: false,
176          pending: 0,
177          memoryMB: 0,
178          port: 0,
179        },
180      });
181      vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
182  
183      const bridge = new BrowserBridge();
184  
185      await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
186    });
187  
188    it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
189      vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
190        state: 'no-extension',
191        status: {
192          ok: true,
193          pid: 1,
194          uptime: 0,
195          daemonVersion: '0.0.1',
196          extensionConnected: false,
197          pending: 0,
198          memoryMB: 0,
199          port: 0,
200        },
201      });
202      vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
203  
204      const bridge = new BrowserBridge();
205  
206      await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
207    });
208  });
209  
210  describe('stealth anti-detection', () => {
211    it('generates non-empty JS string', () => {
212      const js = generateStealthJs();
213      expect(typeof js).toBe('string');
214      expect(js.length).toBeGreaterThan(100);
215    });
216  
217    it('contains all 7 anti-detection patches', () => {
218      const js = generateStealthJs();
219      // 1. webdriver
220      expect(js).toContain('navigator');
221      expect(js).toContain('webdriver');
222      // 2. chrome stub
223      expect(js).toContain('window.chrome');
224      // 3. plugins
225      expect(js).toContain('plugins');
226      expect(js).toContain('PDF Viewer');
227      // 4. languages
228      expect(js).toContain('languages');
229      // 5. permissions
230      expect(js).toContain('Permissions');
231      expect(js).toContain('notifications');
232      // 6. automation artifacts (dynamic cdc_ scan)
233      expect(js).toContain('__playwright');
234      expect(js).toContain('__puppeteer');
235      expect(js).toContain('getOwnPropertyNames');
236      expect(js).toContain('cdc_');
237      // 7. CDP stack trace cleanup
238      expect(js).toContain('Error.prototype');
239      expect(js).toContain('puppeteer_evaluation_script');
240      expect(js).toContain('getOwnPropertyDescriptor');
241    });
242  
243    it('includes guard flag to prevent double-injection', () => {
244      const js = generateStealthJs();
245      // Guard uses a non-enumerable property on a built-in prototype
246      expect(js).toContain("EventTarget.prototype");
247      // Guard should check early and return 'skipped'
248      expect(js).toContain("return 'skipped'");
249      // Normal path returns 'applied'
250      expect(js).toContain("return 'applied'");
251    });
252  
253    it('generates syntactically valid JS', () => {
254      const js = generateStealthJs();
255      // Should not throw when parsed
256      expect(() => new Function(js)).not.toThrow();
257    });
258  });