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 });