cli.test.ts
1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 import * as fs from 'node:fs'; 3 import * as os from 'node:os'; 4 import * as path from 'node:path'; 5 import type { IPage } from './types.js'; 6 import { TargetError } from './browser/target-errors.js'; 7 8 const { 9 mockBrowserConnect, 10 mockBrowserClose, 11 browserState, 12 } = vi.hoisted(() => ({ 13 mockBrowserConnect: vi.fn(), 14 mockBrowserClose: vi.fn(), 15 browserState: { page: null as IPage | null }, 16 })); 17 18 vi.mock('./browser/index.js', () => { 19 mockBrowserConnect.mockImplementation(async () => browserState.page as IPage); 20 return { 21 BrowserBridge: class { 22 connect = mockBrowserConnect; 23 close = mockBrowserClose; 24 }, 25 }; 26 }); 27 28 import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation } from './cli.js'; 29 30 describe('resolveBrowserVerifyInvocation', () => { 31 it('prefers the built entry declared in package metadata', () => { 32 const projectRoot = path.join('repo-root'); 33 const exists = new Set([ 34 path.join(projectRoot, 'dist', 'src', 'main.js'), 35 ]); 36 37 expect(resolveBrowserVerifyInvocation({ 38 projectRoot, 39 readFile: () => JSON.stringify({ bin: { opencli: 'dist/src/main.js' } }), 40 fileExists: (candidate) => exists.has(candidate), 41 })).toEqual({ 42 binary: process.execPath, 43 args: [path.join(projectRoot, 'dist', 'src', 'main.js')], 44 cwd: projectRoot, 45 }); 46 }); 47 48 it('falls back to compatibility built-entry candidates when package metadata is unavailable', () => { 49 const projectRoot = path.join('repo-root'); 50 const exists = new Set([ 51 path.join(projectRoot, 'dist', 'src', 'main.js'), 52 ]); 53 54 expect(resolveBrowserVerifyInvocation({ 55 projectRoot, 56 readFile: () => { throw new Error('no package json'); }, 57 fileExists: (candidate) => exists.has(candidate), 58 })).toEqual({ 59 binary: process.execPath, 60 args: [path.join(projectRoot, 'dist', 'src', 'main.js')], 61 cwd: projectRoot, 62 }); 63 }); 64 65 it('falls back to the local tsx binary in source checkouts on Windows', () => { 66 const projectRoot = path.join('repo-root'); 67 const exists = new Set([ 68 path.join(projectRoot, 'src', 'main.ts'), 69 path.join(projectRoot, 'node_modules', '.bin', 'tsx.cmd'), 70 ]); 71 72 expect(resolveBrowserVerifyInvocation({ 73 projectRoot, 74 platform: 'win32', 75 fileExists: (candidate) => exists.has(candidate), 76 })).toEqual({ 77 binary: path.join(projectRoot, 'node_modules', '.bin', 'tsx.cmd'), 78 args: [path.join(projectRoot, 'src', 'main.ts')], 79 cwd: projectRoot, 80 shell: true, 81 }); 82 }); 83 84 it('falls back to npx tsx when local tsx is unavailable', () => { 85 const projectRoot = path.join('repo-root'); 86 const exists = new Set([ 87 path.join(projectRoot, 'src', 'main.ts'), 88 ]); 89 90 expect(resolveBrowserVerifyInvocation({ 91 projectRoot, 92 platform: 'linux', 93 fileExists: (candidate) => exists.has(candidate), 94 })).toEqual({ 95 binary: 'npx', 96 args: ['tsx', path.join(projectRoot, 'src', 'main.ts')], 97 cwd: projectRoot, 98 }); 99 }); 100 }); 101 102 describe('browser tab targeting commands', () => { 103 const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 104 const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); 105 106 function getBrowserStateFile(cacheDir: string): string { 107 return path.join(cacheDir, 'browser-state', 'browser_default.json'); 108 } 109 110 beforeEach(() => { 111 process.exitCode = undefined; 112 process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tab-state-')); 113 consoleLogSpy.mockClear(); 114 stderrSpy.mockClear(); 115 mockBrowserConnect.mockClear(); 116 mockBrowserClose.mockReset().mockResolvedValue(undefined); 117 118 browserState.page = { 119 goto: vi.fn().mockResolvedValue(undefined), 120 wait: vi.fn().mockResolvedValue(undefined), 121 setActivePage: vi.fn(), 122 getActivePage: vi.fn().mockReturnValue('tab-1'), 123 getCurrentUrl: vi.fn().mockResolvedValue('https://one.example'), 124 startNetworkCapture: vi.fn().mockResolvedValue(true), 125 getCookies: vi.fn().mockResolvedValue([]), 126 evaluate: vi.fn().mockResolvedValue({ ok: true }), 127 tabs: vi.fn().mockResolvedValue([ 128 { index: 0, page: 'tab-1', url: 'https://one.example', title: 'one', active: true }, 129 { index: 1, page: 'tab-2', url: 'https://two.example', title: 'two', active: false }, 130 ]), 131 selectTab: vi.fn().mockResolvedValue(undefined), 132 newTab: vi.fn().mockResolvedValue('tab-3'), 133 closeTab: vi.fn().mockResolvedValue(undefined), 134 frames: vi.fn().mockResolvedValue([ 135 { index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' }, 136 ]), 137 evaluateInFrame: vi.fn().mockResolvedValue('inside frame'), 138 readNetworkCapture: vi.fn().mockResolvedValue([]), 139 } as unknown as IPage; 140 }); 141 142 function lastJsonLog(): any { 143 const calls = consoleLogSpy.mock.calls; 144 if (calls.length === 0) throw new Error('Expected at least one console.log call'); 145 const last = calls[calls.length - 1][0]; 146 if (typeof last !== 'string') throw new Error(`Expected string arg to console.log, got ${typeof last}`); 147 return JSON.parse(last); 148 } 149 150 it('binds browser commands to an explicit target tab via --tab', async () => { 151 const program = createProgram('', ''); 152 153 await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-2', 'document.title']); 154 155 expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2'); 156 expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); 157 }); 158 159 it('rejects an explicit --tab target that is no longer in the current session', async () => { 160 browserState.page = { 161 setActivePage: vi.fn(), 162 getActivePage: vi.fn(), 163 tabs: vi.fn().mockResolvedValue([]), 164 evaluate: vi.fn(), 165 } as unknown as IPage; 166 167 const program = createProgram('', ''); 168 await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-stale', 'document.title']); 169 170 expect(process.exitCode).toBeDefined(); 171 expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); 172 expect(browserState.page?.evaluate).not.toHaveBeenCalled(); 173 expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session'); 174 }); 175 176 it('lists tabs with target IDs via browser tab list', async () => { 177 const program = createProgram('', ''); 178 179 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'list']); 180 181 expect(browserState.page?.tabs).toHaveBeenCalledTimes(1); 182 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"'); 183 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-2"'); 184 }); 185 186 it('creates a new tab and prints its target ID', async () => { 187 const program = createProgram('', ''); 188 189 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']); 190 191 expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example'); 192 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-3"'); 193 }); 194 195 it('prints the resolved target ID when browser open creates or navigates a tab', async () => { 196 const program = createProgram('', ''); 197 198 await program.parseAsync(['node', 'opencli', 'browser', 'open', 'https://example.com']); 199 200 expect(browserState.page?.goto).toHaveBeenCalledWith('https://example.com'); 201 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://one.example"'); 202 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"'); 203 }); 204 205 it('lists cross-origin frames via browser frames', async () => { 206 const program = createProgram('', ''); 207 208 await program.parseAsync(['node', 'opencli', 'browser', 'frames']); 209 210 expect(browserState.page?.frames).toHaveBeenCalledTimes(1); 211 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"frameId": "frame-1"'); 212 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://x.example/embed"'); 213 }); 214 215 it('routes browser eval --frame through frame-targeted evaluation', async () => { 216 const program = createProgram('', ''); 217 218 await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--frame', '0', 'document.title']); 219 220 expect(browserState.page?.evaluateInFrame).toHaveBeenCalledWith('document.title', 0); 221 expect(browserState.page?.evaluate).not.toHaveBeenCalled(); 222 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('inside frame'); 223 }); 224 225 it('does not promote a newly created tab to the persisted default target', async () => { 226 const program = createProgram('', ''); 227 228 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']); 229 await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); 230 231 expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example'); 232 expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); 233 expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); 234 }); 235 236 it('persists an explicitly selected tab as the default target for later untargeted commands', async () => { 237 const program = createProgram('', ''); 238 239 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); 240 await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); 241 242 expect(browserState.page?.selectTab).toHaveBeenCalledWith('tab-2'); 243 expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2'); 244 expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); 245 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"selected": "tab-2"'); 246 }); 247 248 it('clears a saved default target when it is no longer present in the current session', async () => { 249 const cacheDir = String(process.env.OPENCLI_CACHE_DIR); 250 const program = createProgram('', ''); 251 252 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); 253 expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(true); 254 255 browserState.page = { 256 setActivePage: vi.fn(), 257 getActivePage: vi.fn(), 258 tabs: vi.fn().mockResolvedValue([]), 259 evaluate: vi.fn().mockResolvedValue({ ok: true }), 260 readNetworkCapture: vi.fn().mockResolvedValue([]), 261 } as unknown as IPage; 262 263 await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); 264 265 expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); 266 expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); 267 expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(false); 268 }); 269 270 it('clears the persisted default target when that tab is closed', async () => { 271 const program = createProgram('', ''); 272 273 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); 274 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']); 275 vi.mocked(browserState.page?.setActivePage as any).mockClear(); 276 vi.mocked(browserState.page?.evaluate as any).mockClear(); 277 278 await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); 279 280 expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2'); 281 expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); 282 expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); 283 }); 284 285 it('closes a tab by target ID', async () => { 286 const program = createProgram('', ''); 287 288 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']); 289 290 expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2'); 291 expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"closed": "tab-2"'); 292 }); 293 294 it('rejects closing a stale tab target ID that is no longer in the current session', async () => { 295 browserState.page = { 296 tabs: vi.fn().mockResolvedValue([]), 297 closeTab: vi.fn(), 298 } as unknown as IPage; 299 300 const program = createProgram('', ''); 301 await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-stale']); 302 303 expect(process.exitCode).toBeDefined(); 304 expect(browserState.page?.closeTab).not.toHaveBeenCalled(); 305 expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session'); 306 }); 307 308 it('browser analyze merges HttpOnly cookie names from page.getCookies and drains stale capture before verdict', async () => { 309 browserState.page = { 310 goto: vi.fn().mockResolvedValue(undefined), 311 wait: vi.fn().mockResolvedValue(undefined), 312 setActivePage: vi.fn(), 313 getActivePage: vi.fn().mockReturnValue('tab-1'), 314 getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'), 315 startNetworkCapture: vi.fn().mockResolvedValue(true), 316 getCookies: vi.fn().mockResolvedValue([{ name: 'cf_clearance', value: 'x', domain: '.target.example' }]), 317 evaluate: vi.fn().mockResolvedValue({ 318 cookieNames: [], 319 initialState: { 320 __INITIAL_STATE__: false, 321 __NUXT__: false, 322 __NEXT_DATA__: false, 323 __APOLLO_STATE__: false, 324 }, 325 title: 'Target', 326 finalUrl: 'https://target.example/', 327 }), 328 tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]), 329 readNetworkCapture: vi.fn() 330 .mockResolvedValueOnce([ 331 { 332 url: 'https://stale.example/api/old', 333 method: 'GET', 334 responseStatus: 200, 335 responseContentType: 'application/json', 336 responsePreview: '{"stale":true}', 337 }, 338 ]) 339 .mockResolvedValueOnce([ 340 { 341 url: 'https://target.example/waf', 342 method: 'GET', 343 responseStatus: 403, 344 responseContentType: 'text/html', 345 responsePreview: 'Cloudflare Ray ID', 346 }, 347 ]), 348 } as unknown as IPage; 349 350 const program = createProgram('', ''); 351 await program.parseAsync(['node', 'opencli', 'browser', 'analyze', 'https://target.example/']); 352 353 const out = lastJsonLog(); 354 expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2); 355 expect(out.anti_bot.vendor).toBe('cloudflare'); 356 expect(out.anti_bot.evidence).toContain('cookie:cf_clearance'); 357 }); 358 359 it('browser analyze falls back to interceptor buffer when network capture is unsupported', async () => { 360 let bufferReads = 0; 361 browserState.page = { 362 goto: vi.fn().mockResolvedValue(undefined), 363 wait: vi.fn().mockResolvedValue(undefined), 364 setActivePage: vi.fn(), 365 getActivePage: vi.fn().mockReturnValue('tab-1'), 366 getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'), 367 startNetworkCapture: vi.fn().mockResolvedValue(false), 368 getCookies: vi.fn().mockResolvedValue([{ name: 'cf_clearance', value: 'x', domain: '.target.example' }]), 369 evaluate: vi.fn().mockImplementation(async (arg: string) => { 370 if (typeof arg === 'string' && arg.includes('document.cookie')) { 371 return { 372 cookieNames: [], 373 initialState: { 374 __INITIAL_STATE__: false, 375 __NUXT__: false, 376 __NEXT_DATA__: false, 377 __APOLLO_STATE__: false, 378 }, 379 title: 'Target', 380 finalUrl: 'https://target.example/', 381 }; 382 } 383 if (typeof arg === 'string' && arg.includes('window.__opencli_net = []')) { 384 bufferReads += 1; 385 if (bufferReads === 1) { 386 return JSON.stringify([ 387 { 388 url: 'https://stale.example/api/old', 389 method: 'GET', 390 status: 200, 391 size: 12, 392 ct: 'application/json', 393 body: { stale: true }, 394 }, 395 ]); 396 } 397 return JSON.stringify([ 398 { 399 url: 'https://target.example/waf', 400 method: 'GET', 401 status: 403, 402 size: 17, 403 ct: 'text/html', 404 body: 'Cloudflare Ray ID', 405 }, 406 ]); 407 } 408 return undefined; 409 }), 410 tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]), 411 readNetworkCapture: vi.fn().mockResolvedValue([]), 412 } as unknown as IPage; 413 414 const program = createProgram('', ''); 415 await program.parseAsync(['node', 'opencli', 'browser', 'analyze', 'https://target.example/']); 416 417 const out = lastJsonLog(); 418 expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2); 419 expect(bufferReads).toBe(2); 420 expect(out.anti_bot.vendor).toBe('cloudflare'); 421 expect(out.anti_bot.evidence).toContain('cookie:cf_clearance'); 422 expect(out.anti_bot.evidence).toContain('body:https://target.example/waf'); 423 }); 424 425 it('browser wait xhr starts capture, injects interceptor on fallback, and ignores stale ring entries', async () => { 426 browserState.page = { 427 goto: vi.fn().mockResolvedValue(undefined), 428 wait: vi.fn().mockResolvedValue(undefined), 429 setActivePage: vi.fn(), 430 getActivePage: vi.fn().mockReturnValue('tab-1'), 431 getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'), 432 startNetworkCapture: vi.fn().mockResolvedValue(false), 433 evaluate: vi.fn().mockResolvedValue(undefined), 434 tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]), 435 readNetworkCapture: vi.fn() 436 .mockResolvedValueOnce([ 437 { 438 url: 'https://stale.example/api/old', 439 method: 'GET', 440 responseStatus: 200, 441 responseContentType: 'application/json', 442 responsePreview: '{"stale":true}', 443 }, 444 ]) 445 .mockResolvedValueOnce([ 446 { 447 url: 'https://target.example/api/target', 448 method: 'GET', 449 responseStatus: 200, 450 responseContentType: 'application/json', 451 responsePreview: '{"ok":true}', 452 }, 453 ]), 454 } as unknown as IPage; 455 456 const program = createProgram('', ''); 457 await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'xhr', '/api/target', '--timeout', '900']); 458 459 const out = lastJsonLog(); 460 expect(browserState.page?.startNetworkCapture).toHaveBeenCalledTimes(1); 461 expect(browserState.page?.evaluate).toHaveBeenCalledWith(expect.stringContaining('window.__opencli_net')); 462 expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2); 463 expect(out.matched.url).toBe('https://target.example/api/target'); 464 }); 465 466 it('browser wait xhr reads interceptor buffer when network capture is unsupported', async () => { 467 let bufferReads = 0; 468 browserState.page = { 469 goto: vi.fn().mockResolvedValue(undefined), 470 wait: vi.fn().mockResolvedValue(undefined), 471 setActivePage: vi.fn(), 472 getActivePage: vi.fn().mockReturnValue('tab-1'), 473 getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'), 474 startNetworkCapture: vi.fn().mockResolvedValue(false), 475 evaluate: vi.fn().mockImplementation(async (arg: string) => { 476 if (typeof arg === 'string' && arg.includes('window.__opencli_net = []')) { 477 bufferReads += 1; 478 if (bufferReads === 1) { 479 return JSON.stringify([ 480 { 481 url: 'https://stale.example/api/old', 482 method: 'GET', 483 status: 200, 484 size: 12, 485 ct: 'application/json', 486 body: { stale: true }, 487 }, 488 ]); 489 } 490 return JSON.stringify([ 491 { 492 url: 'https://target.example/api/target', 493 method: 'GET', 494 status: 200, 495 size: 11, 496 ct: 'application/json', 497 body: { ok: true }, 498 }, 499 ]); 500 } 501 return undefined; 502 }), 503 tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]), 504 readNetworkCapture: vi.fn().mockResolvedValue([]), 505 } as unknown as IPage; 506 507 const program = createProgram('', ''); 508 await program.parseAsync(['node', 'opencli', 'browser', 'wait', 'xhr', '/api/target', '--timeout', '900']); 509 510 const out = lastJsonLog(); 511 expect(browserState.page?.startNetworkCapture).toHaveBeenCalledTimes(1); 512 expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2); 513 expect(bufferReads).toBe(2); 514 expect(out.matched.url).toBe('https://target.example/api/target'); 515 }); 516 }); 517 518 describe('browser network command', () => { 519 const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 520 521 function getNetworkCachePath(cacheDir: string): string { 522 return path.join(cacheDir, 'browser-network', 'browser_default.json'); 523 } 524 525 function lastJsonLog(): any { 526 const calls = consoleLogSpy.mock.calls; 527 if (calls.length === 0) throw new Error('Expected at least one console.log call'); 528 const last = calls[calls.length - 1][0]; 529 if (typeof last !== 'string') throw new Error(`Expected string arg to console.log, got ${typeof last}`); 530 return JSON.parse(last); 531 } 532 533 beforeEach(() => { 534 process.exitCode = undefined; 535 process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-net-')); 536 consoleLogSpy.mockClear(); 537 mockBrowserConnect.mockClear(); 538 mockBrowserClose.mockReset().mockResolvedValue(undefined); 539 540 browserState.page = { 541 setActivePage: vi.fn(), 542 getActivePage: vi.fn().mockReturnValue('tab-1'), 543 tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), 544 evaluate: vi.fn().mockResolvedValue(''), 545 readNetworkCapture: vi.fn().mockResolvedValue([ 546 { 547 url: 'https://x.com/i/api/graphql/qid/UserTweets?v=1', 548 method: 'GET', 549 responseStatus: 200, 550 responseContentType: 'application/json', 551 responsePreview: JSON.stringify({ data: { user: { rest_id: '42' } } }), 552 }, 553 { 554 url: 'https://cdn.example.com/app.js', 555 method: 'GET', 556 responseStatus: 200, 557 responseContentType: 'application/javascript', 558 responsePreview: '// js', 559 }, 560 ]), 561 } as unknown as IPage; 562 }); 563 564 it('emits JSON with shape previews and persists the capture to disk', async () => { 565 const cacheDir = String(process.env.OPENCLI_CACHE_DIR); 566 const program = createProgram('', ''); 567 568 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 569 570 const out = lastJsonLog(); 571 expect(out.count).toBe(1); 572 expect(out.filtered_out).toBe(1); 573 expect(out.entries[0].key).toBe('UserTweets'); 574 expect(out.entries[0].shape['$.data.user.rest_id']).toBe('string'); 575 expect(out.entries[0]).not.toHaveProperty('body'); 576 expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(true); 577 }); 578 579 it('--all includes static resources that the default filter drops', async () => { 580 const program = createProgram('', ''); 581 582 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--all']); 583 584 const out = lastJsonLog(); 585 expect(out.count).toBe(2); 586 expect(out.entries.map((e: any) => e.key)).toContain('UserTweets'); 587 expect(out.entries.map((e: any) => e.key)).toContain('GET cdn.example.com/app.js'); 588 }); 589 590 it('--raw emits full bodies inline for every entry', async () => { 591 const program = createProgram('', ''); 592 593 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--raw']); 594 595 const out = lastJsonLog(); 596 expect(out.entries[0].body).toEqual({ data: { user: { rest_id: '42' } } }); 597 }); 598 599 it('--detail <key> returns the full body for the requested entry', async () => { 600 const program = createProgram('', ''); 601 602 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 603 consoleLogSpy.mockClear(); 604 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserTweets']); 605 606 const out = lastJsonLog(); 607 expect(out.key).toBe('UserTweets'); 608 expect(out.body).toEqual({ data: { user: { rest_id: '42' } } }); 609 expect(out.shape['$.data.user.rest_id']).toBe('string'); 610 }); 611 612 it('--detail reports key_not_found with the list of available keys', async () => { 613 const program = createProgram('', ''); 614 615 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 616 consoleLogSpy.mockClear(); 617 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'NopeOp']); 618 619 const out = lastJsonLog(); 620 expect(out.error.code).toBe('key_not_found'); 621 expect(out.error.available_keys).toContain('UserTweets'); 622 expect(process.exitCode).toBeDefined(); 623 }); 624 625 it('--detail reports cache_missing when no capture has been persisted yet', async () => { 626 const program = createProgram('', ''); 627 628 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserTweets']); 629 630 const out = lastJsonLog(); 631 expect(out.error.code).toBe('cache_missing'); 632 expect(process.exitCode).toBeDefined(); 633 }); 634 635 it('emits capture_failed when readNetworkCapture throws', async () => { 636 (browserState.page!.readNetworkCapture as any) = vi.fn().mockRejectedValue(new Error('CDP disconnected')); 637 const program = createProgram('', ''); 638 639 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 640 641 const out = lastJsonLog(); 642 expect(out.error.code).toBe('capture_failed'); 643 expect(out.error.message).toContain('CDP disconnected'); 644 expect(process.exitCode).toBeDefined(); 645 }); 646 647 it('surfaces cache_warning in the envelope when persistence fails', async () => { 648 const cacheDir = String(process.env.OPENCLI_CACHE_DIR); 649 // Pre-create the target path as a file where a directory is expected, 650 // forcing the mkdir inside saveNetworkCache to throw. 651 const clashDir = path.join(cacheDir, 'browser-network'); 652 fs.writeFileSync(clashDir, 'not-a-directory'); 653 654 const program = createProgram('', ''); 655 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 656 657 const out = lastJsonLog(); 658 expect(out.cache_warning).toMatch(/Could not persist capture cache/); 659 expect(out.count).toBe(1); 660 expect(process.exitCode).toBeUndefined(); 661 }); 662 663 describe('--filter', () => { 664 function apiResponse(url: string, body: unknown): Record<string, unknown> { 665 return { 666 url, 667 method: 'GET', 668 responseStatus: 200, 669 responseContentType: 'application/json', 670 responsePreview: JSON.stringify(body), 671 }; 672 } 673 674 beforeEach(() => { 675 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 676 apiResponse( 677 'https://x.com/i/api/graphql/qid/UserTweets?v=1', 678 { data: { items: [{ author: 'a', text: 't', likes: 1 }] } }, 679 ), 680 apiResponse( 681 'https://x.com/i/api/graphql/qid/UserProfile?v=1', 682 { data: { user: { id: 'u1', followers: 10 } } }, 683 ), 684 apiResponse( 685 'https://x.com/i/api/graphql/qid/Settings?v=1', 686 { config: { theme: 'dark' } }, 687 ), 688 ]); 689 }); 690 691 it('narrows entries to those whose shape has ALL named fields', async () => { 692 const program = createProgram('', ''); 693 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,text,likes']); 694 695 const out = lastJsonLog(); 696 expect(out.count).toBe(1); 697 expect(out.filter).toEqual(['author', 'text', 'likes']); 698 expect(out.filter_dropped).toBe(2); 699 expect(out.entries[0].key).toBe('UserTweets'); 700 }); 701 702 it('matches container segments too, not just leaf names (any-segment rule)', async () => { 703 const program = createProgram('', ''); 704 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'data,items']); 705 706 const out = lastJsonLog(); 707 expect(out.count).toBe(1); 708 expect(out.entries[0].key).toBe('UserTweets'); 709 }); 710 711 it('drops entries that are missing any required field (AND semantics)', async () => { 712 const program = createProgram('', ''); 713 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,followers']); 714 715 const out = lastJsonLog(); 716 expect(out.count).toBe(0); 717 expect(out.entries).toEqual([]); 718 expect(out.filter).toEqual(['author', 'followers']); 719 expect(out.filter_dropped).toBe(3); 720 }); 721 722 it('returns empty entries (not an error) when nothing matches', async () => { 723 const program = createProgram('', ''); 724 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'nonexistent_field']); 725 726 const out = lastJsonLog(); 727 expect(out.count).toBe(0); 728 expect(out.entries).toEqual([]); 729 expect(out).not.toHaveProperty('error'); 730 expect(process.exitCode).toBeUndefined(); 731 }); 732 733 it('is case-sensitive so agents do not conflate `Id` with `id`', async () => { 734 const program = createProgram('', ''); 735 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'Data']); 736 737 const out = lastJsonLog(); 738 expect(out.count).toBe(0); 739 }); 740 741 it('persists the full (unfiltered) capture so --detail lookups still find filtered-out keys', async () => { 742 const program = createProgram('', ''); 743 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author,text,likes']); 744 consoleLogSpy.mockClear(); 745 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'UserProfile']); 746 747 const out = lastJsonLog(); 748 expect(out.key).toBe('UserProfile'); 749 expect(out.body).toEqual({ data: { user: { id: 'u1', followers: 10 } } }); 750 }); 751 752 it('composes with --raw: entries keep full bodies, filter still narrows', async () => { 753 const program = createProgram('', ''); 754 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author', '--raw']); 755 756 const out = lastJsonLog(); 757 expect(out.count).toBe(1); 758 expect(out.entries[0].body).toEqual({ data: { items: [{ author: 'a', text: 't', likes: 1 }] } }); 759 }); 760 761 it('reports invalid_filter for empty value', async () => { 762 const program = createProgram('', ''); 763 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', '']); 764 765 const out = lastJsonLog(); 766 expect(out.error.code).toBe('invalid_filter'); 767 expect(process.exitCode).toBeDefined(); 768 }); 769 770 it('reports invalid_filter for commas-only value', async () => { 771 const program = createProgram('', ''); 772 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', ',,,']); 773 774 const out = lastJsonLog(); 775 expect(out.error.code).toBe('invalid_filter'); 776 expect(process.exitCode).toBeDefined(); 777 }); 778 779 it('rejects --filter combined with --detail as invalid_args', async () => { 780 const program = createProgram('', ''); 781 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--filter', 'author', '--detail', 'UserTweets']); 782 783 const out = lastJsonLog(); 784 expect(out.error.code).toBe('invalid_args'); 785 expect(out.error.message).toContain('--filter'); 786 expect(out.error.message).toContain('--detail'); 787 expect(process.exitCode).toBeDefined(); 788 }); 789 }); 790 791 describe('body truncation signals', () => { 792 it('flags body_truncated in list view when the capture layer capped the body', async () => { 793 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 794 { 795 url: 'https://api.example.com/huge', 796 method: 'GET', 797 responseStatus: 200, 798 responseContentType: 'application/json', 799 responsePreview: '{"data":"x"}', 800 responseBodyFullSize: 99_999_999, 801 responseBodyTruncated: true, 802 }, 803 ]); 804 const program = createProgram('', ''); 805 806 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 807 808 const out = lastJsonLog(); 809 expect(out.body_truncated_count).toBe(1); 810 expect(out.entries[0].body_truncated).toBe(true); 811 expect(out.entries[0].size).toBe(99_999_999); 812 }); 813 814 it('--detail surfaces body_truncated + body_full_size when capture had to cap the body', async () => { 815 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 816 { 817 url: 'https://api.example.com/huge', 818 method: 'GET', 819 responseStatus: 200, 820 responseContentType: 'application/json', 821 responsePreview: 'truncated-prefix-not-valid-json', 822 responseBodyFullSize: 50_000_000, 823 responseBodyTruncated: true, 824 }, 825 ]); 826 const program = createProgram('', ''); 827 828 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 829 consoleLogSpy.mockClear(); 830 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--detail', 'GET api.example.com/huge']); 831 832 const out = lastJsonLog(); 833 expect(out.body_truncated).toBe(true); 834 expect(out.body_full_size).toBe(50_000_000); 835 expect(out.body_truncation_reason).toBe('capture-limit'); 836 }); 837 838 it('--max-body caps the emitted body and marks body_truncation_reason = max-body', async () => { 839 const longString = 'x'.repeat(5000); 840 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 841 { 842 url: 'https://api.example.com/plain', 843 method: 'GET', 844 responseStatus: 200, 845 responseContentType: 'text/plain', 846 responsePreview: longString, 847 }, 848 ]); 849 const program = createProgram('', ''); 850 851 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 852 consoleLogSpy.mockClear(); 853 await program.parseAsync([ 854 'node', 'opencli', 'browser', 'network', 855 '--detail', 'GET api.example.com/plain', 856 '--max-body', '100', 857 ]); 858 859 const out = lastJsonLog(); 860 expect(typeof out.body).toBe('string'); 861 expect(out.body).toHaveLength(100); 862 expect(out.body_truncated).toBe(true); 863 expect(out.body_truncation_reason).toBe('max-body'); 864 expect(out.body_full_size).toBe(5000); 865 }); 866 867 it('--max-body leaves parsed JSON bodies untouched (no mid-object cut)', async () => { 868 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 869 { 870 url: 'https://api.example.com/json', 871 method: 'GET', 872 responseStatus: 200, 873 responseContentType: 'application/json', 874 responsePreview: JSON.stringify({ data: { user: { rest_id: 'u1' } } }), 875 }, 876 ]); 877 const program = createProgram('', ''); 878 879 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 880 consoleLogSpy.mockClear(); 881 await program.parseAsync([ 882 'node', 'opencli', 'browser', 'network', 883 '--detail', 'GET api.example.com/json', 884 '--max-body', '10', 885 ]); 886 887 const out = lastJsonLog(); 888 // JSON body already parsed at capture time — --max-body only applies to 889 // string bodies (which is where the agent-visible hazard lives). 890 expect(out.body).toEqual({ data: { user: { rest_id: 'u1' } } }); 891 expect(out).not.toHaveProperty('body_truncated'); 892 }); 893 894 it('rejects non-numeric --max-body with invalid_max_body', async () => { 895 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 896 { 897 url: 'https://api.example.com/x', 898 method: 'GET', 899 responseStatus: 200, 900 responseContentType: 'application/json', 901 responsePreview: '{"a":1}', 902 }, 903 ]); 904 const program = createProgram('', ''); 905 906 await program.parseAsync(['node', 'opencli', 'browser', 'network']); 907 consoleLogSpy.mockClear(); 908 await program.parseAsync([ 909 'node', 'opencli', 'browser', 'network', 910 '--detail', 'GET api.example.com/x', 911 '--max-body', 'abc', 912 ]); 913 914 expect(lastJsonLog().error.code).toBe('invalid_max_body'); 915 expect(process.exitCode).toBeDefined(); 916 }); 917 918 it('--raw emits snake_case body_truncated / body_full_size, matching non-raw + detail', async () => { 919 browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([ 920 { 921 url: 'https://api.example.com/huge', 922 method: 'GET', 923 responseStatus: 200, 924 responseContentType: 'application/json', 925 responsePreview: 'truncated-prefix', 926 responseBodyFullSize: 20_000_000, 927 responseBodyTruncated: true, 928 }, 929 ]); 930 const program = createProgram('', ''); 931 932 await program.parseAsync(['node', 'opencli', 'browser', 'network', '--raw']); 933 934 const out = lastJsonLog(); 935 expect(out.entries).toHaveLength(1); 936 const entry = out.entries[0]; 937 expect(entry.body_truncated).toBe(true); 938 expect(entry.body_full_size).toBe(20_000_000); 939 // Internal camelCase must not leak into the agent-facing envelope. 940 expect(entry).not.toHaveProperty('bodyTruncated'); 941 expect(entry).not.toHaveProperty('bodyFullSize'); 942 }); 943 }); 944 }); 945 946 describe('browser get html command', () => { 947 const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 948 949 function lastLogArg(): unknown { 950 const calls = consoleLogSpy.mock.calls; 951 if (calls.length === 0) throw new Error('expected console.log call'); 952 return calls[calls.length - 1][0]; 953 } 954 function lastJsonLog(): any { 955 const arg = lastLogArg(); 956 if (typeof arg !== 'string') throw new Error(`expected string arg, got ${typeof arg}`); 957 return JSON.parse(arg); 958 } 959 960 beforeEach(() => { 961 process.exitCode = undefined; 962 process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-html-')); 963 consoleLogSpy.mockClear(); 964 mockBrowserConnect.mockClear(); 965 mockBrowserClose.mockReset().mockResolvedValue(undefined); 966 967 browserState.page = { 968 setActivePage: vi.fn(), 969 getActivePage: vi.fn().mockReturnValue('tab-1'), 970 tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), 971 evaluate: vi.fn(), 972 } as unknown as IPage; 973 }); 974 975 it('returns full outerHTML by default with no truncation', async () => { 976 const big = '<div>' + 'x'.repeat(100_000) + '</div>'; 977 (browserState.page!.evaluate as any).mockResolvedValueOnce({ kind: 'ok', html: big }); 978 const program = createProgram('', ''); 979 980 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html']); 981 982 expect(lastLogArg()).toBe(big); 983 }); 984 985 it('caps output with --max and prepends a visible truncation marker', async () => { 986 const big = '<div>' + 'x'.repeat(500) + '</div>'; 987 (browserState.page!.evaluate as any).mockResolvedValueOnce({ kind: 'ok', html: big }); 988 const program = createProgram('', ''); 989 990 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '100']); 991 992 const out = String(lastLogArg()); 993 expect(out.startsWith('<!-- opencli: truncated 100 of')).toBe(true); 994 expect(out.length).toBeGreaterThan(100); 995 expect(out.length).toBeLessThan(big.length); 996 }); 997 998 it('rejects negative --max with invalid_max error', async () => { 999 const program = createProgram('', ''); 1000 1001 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '-1']); 1002 1003 expect(lastJsonLog().error.code).toBe('invalid_max'); 1004 expect(process.exitCode).toBeDefined(); 1005 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1006 }); 1007 1008 it('rejects fractional --max with invalid_max error', async () => { 1009 const program = createProgram('', ''); 1010 1011 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '1.5']); 1012 1013 expect(lastJsonLog().error.code).toBe('invalid_max'); 1014 expect(process.exitCode).toBeDefined(); 1015 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1016 }); 1017 1018 it('rejects non-numeric --max (e.g. "10abc") with invalid_max error', async () => { 1019 const program = createProgram('', ''); 1020 1021 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--max', '10abc']); 1022 1023 expect(lastJsonLog().error.code).toBe('invalid_max'); 1024 expect(process.exitCode).toBeDefined(); 1025 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1026 }); 1027 1028 it('--as json returns structured tree envelope', async () => { 1029 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1030 selector: '.hero', 1031 matched: 1, 1032 tree: { tag: 'div', attrs: { class: 'hero' }, text: 'Hi', children: [] }, 1033 }); 1034 const program = createProgram('', ''); 1035 1036 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.hero', '--as', 'json']); 1037 1038 const out = lastJsonLog(); 1039 expect(out.matched).toBe(1); 1040 expect(out.tree.tag).toBe('div'); 1041 expect(out.tree.attrs.class).toBe('hero'); 1042 }); 1043 1044 it('--as json emits selector_not_found when matched is 0', async () => { 1045 (browserState.page!.evaluate as any).mockResolvedValueOnce({ selector: '.missing', matched: 0, tree: null }); 1046 const program = createProgram('', ''); 1047 1048 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.missing', '--as', 'json']); 1049 1050 expect(lastJsonLog().error.code).toBe('selector_not_found'); 1051 expect(process.exitCode).toBeDefined(); 1052 }); 1053 1054 it('raw mode emits selector_not_found when the selector matches nothing', async () => { 1055 (browserState.page!.evaluate as any).mockResolvedValueOnce({ kind: 'ok', html: null }); 1056 const program = createProgram('', ''); 1057 1058 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '.missing']); 1059 1060 expect(lastJsonLog().error.code).toBe('selector_not_found'); 1061 expect(process.exitCode).toBeDefined(); 1062 }); 1063 1064 it('raw mode emits invalid_selector when the page rejects the selector syntax', async () => { 1065 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1066 kind: 'invalid_selector', 1067 reason: "'##$@@' is not a valid selector", 1068 }); 1069 const program = createProgram('', ''); 1070 1071 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '##$@@']); 1072 1073 const err = lastJsonLog().error; 1074 expect(err.code).toBe('invalid_selector'); 1075 expect(err.message).toContain('##$@@'); 1076 expect(err.message).toContain('not a valid selector'); 1077 expect(process.exitCode).toBeDefined(); 1078 }); 1079 1080 it('--as json emits invalid_selector when the page rejects the selector syntax', async () => { 1081 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1082 selector: '##$@@', 1083 invalidSelector: true, 1084 reason: "'##$@@' is not a valid selector", 1085 }); 1086 const program = createProgram('', ''); 1087 1088 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--selector', '##$@@', '--as', 'json']); 1089 1090 const err = lastJsonLog().error; 1091 expect(err.code).toBe('invalid_selector'); 1092 expect(err.message).toContain('##$@@'); 1093 expect(process.exitCode).toBeDefined(); 1094 }); 1095 1096 it('rejects unknown --as format with invalid_format error', async () => { 1097 const program = createProgram('', ''); 1098 1099 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'html', '--as', 'yaml']); 1100 1101 expect(lastJsonLog().error.code).toBe('invalid_format'); 1102 expect(process.exitCode).toBeDefined(); 1103 }); 1104 }); 1105 1106 // Shared helper for the selector-first describe blocks below. 1107 // Each block spies console.log, mocks the IPage surface it touches, and 1108 // parses the last stringified call to inspect the JSON envelope — the 1109 // canonical agent-facing contract for the selector-first commands. 1110 function installSelectorFirstTestHarness(label: string, pageOverrides: () => Partial<IPage>) { 1111 const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 1112 1113 function lastLogArg(): unknown { 1114 const calls = consoleLogSpy.mock.calls; 1115 if (calls.length === 0) throw new Error('expected console.log call'); 1116 return calls[calls.length - 1][0]; 1117 } 1118 function lastJsonLog(): any { 1119 const arg = lastLogArg(); 1120 if (typeof arg !== 'string') throw new Error(`expected string arg, got ${typeof arg}`); 1121 return JSON.parse(arg); 1122 } 1123 1124 beforeEach(() => { 1125 process.exitCode = undefined; 1126 process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `opencli-${label}-`)); 1127 consoleLogSpy.mockClear(); 1128 mockBrowserConnect.mockClear(); 1129 mockBrowserClose.mockReset().mockResolvedValue(undefined); 1130 1131 browserState.page = { 1132 setActivePage: vi.fn(), 1133 getActivePage: vi.fn().mockReturnValue('tab-1'), 1134 tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), 1135 ...pageOverrides(), 1136 } as unknown as IPage; 1137 }); 1138 1139 return { lastJsonLog }; 1140 } 1141 1142 describe('browser find command', () => { 1143 const { lastJsonLog } = installSelectorFirstTestHarness('find', () => ({ 1144 evaluate: vi.fn(), 1145 })); 1146 1147 it('returns a {matches_n, entries} envelope for a matching selector', async () => { 1148 // `find` always returns numeric refs (existing on snapshot-tagged elements, 1149 // allocated on the spot for fresh matches) — see reviewer contract in 1150 // #opencli-browser msg 52c51eb6. 1151 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1152 matches_n: 2, 1153 entries: [ 1154 { nth: 0, ref: 5, tag: 'button', role: '', text: 'OK', attrs: { class: 'btn' }, visible: true }, 1155 { nth: 1, ref: 17, tag: 'button', role: '', text: 'Cancel', attrs: { class: 'btn' }, visible: true }, 1156 ], 1157 }); 1158 const program = createProgram('', ''); 1159 1160 await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn']); 1161 1162 const out = lastJsonLog(); 1163 expect(out.matches_n).toBe(2); 1164 expect(out.entries).toHaveLength(2); 1165 expect(out.entries[0].ref).toBe(5); 1166 expect(out.entries[1].ref).toBe(17); 1167 expect(process.exitCode).toBeUndefined(); 1168 }); 1169 1170 it('forwards --limit / --text-max into the generated JS', async () => { 1171 (browserState.page!.evaluate as any).mockResolvedValueOnce({ matches_n: 0, entries: [] }); 1172 const program = createProgram('', ''); 1173 1174 await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn', '--limit', '3', '--text-max', '20']); 1175 1176 const js = (browserState.page!.evaluate as any).mock.calls[0][0] as string; 1177 expect(js).toContain('LIMIT = 3'); 1178 expect(js).toContain('TEXT_MAX = 20'); 1179 }); 1180 1181 it('emits invalid_selector envelope when the page rejects selector syntax', async () => { 1182 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1183 error: { code: 'invalid_selector', message: 'Invalid CSS selector: ">>>"', hint: 'Check the selector syntax.' }, 1184 }); 1185 const program = createProgram('', ''); 1186 1187 await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '>>>']); 1188 1189 expect(lastJsonLog().error.code).toBe('invalid_selector'); 1190 expect(process.exitCode).toBeDefined(); 1191 }); 1192 1193 it('emits selector_not_found envelope when the selector matches nothing', async () => { 1194 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1195 error: { code: 'selector_not_found', message: 'CSS selector ".missing" matched 0 elements', hint: 'Use browser state to inspect the page.' }, 1196 }); 1197 const program = createProgram('', ''); 1198 1199 await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.missing']); 1200 1201 expect(lastJsonLog().error.code).toBe('selector_not_found'); 1202 expect(process.exitCode).toBeDefined(); 1203 }); 1204 1205 it('rejects missing --css with usage_error (no evaluate call)', async () => { 1206 const program = createProgram('', ''); 1207 1208 await program.parseAsync(['node', 'opencli', 'browser', 'find']); 1209 1210 expect(lastJsonLog().error.code).toBe('usage_error'); 1211 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1212 expect(process.exitCode).toBeDefined(); 1213 }); 1214 1215 it('rejects malformed --limit with usage_error (no evaluate call)', async () => { 1216 const program = createProgram('', ''); 1217 1218 await program.parseAsync(['node', 'opencli', 'browser', 'find', '--css', '.btn', '--limit', 'abc']); 1219 1220 expect(lastJsonLog().error.code).toBe('usage_error'); 1221 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1222 expect(process.exitCode).toBeDefined(); 1223 }); 1224 }); 1225 1226 describe('browser get text/value/attributes commands', () => { 1227 const { lastJsonLog } = installSelectorFirstTestHarness('get-sel', () => ({ 1228 evaluate: vi.fn(), 1229 })); 1230 1231 it('emits {value, matches_n, match_level} envelope for a numeric ref', async () => { 1232 const evalMock = browserState.page!.evaluate as any; 1233 // 1st call: resolveTargetJs -> { ok: true, matches_n: 1, match_level: 'exact' } 1234 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' }); 1235 // 2nd call: getTextResolvedJs -> the element's text 1236 evalMock.mockResolvedValueOnce('Hello world'); 1237 const program = createProgram('', ''); 1238 1239 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '7']); 1240 1241 expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' }); 1242 }); 1243 1244 it('reports matches_n on multi-match CSS (read path: first match wins)', async () => { 1245 const evalMock = browserState.page!.evaluate as any; 1246 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' }); 1247 evalMock.mockResolvedValueOnce('first'); 1248 const program = createProgram('', ''); 1249 1250 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.btn']); 1251 1252 expect(lastJsonLog()).toEqual({ value: 'first', matches_n: 3, match_level: 'exact' }); 1253 }); 1254 1255 it('parses the attributes payload back into a real object', async () => { 1256 const evalMock = browserState.page!.evaluate as any; 1257 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' }); 1258 // getAttributesResolvedJs returns a JSON-encoded string — the CLI must parse it 1259 evalMock.mockResolvedValueOnce(JSON.stringify({ id: 'nav', class: 'hero' })); 1260 const program = createProgram('', ''); 1261 1262 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'attributes', '#nav']); 1263 1264 const out = lastJsonLog(); 1265 expect(out.matches_n).toBe(1); 1266 expect(out.match_level).toBe('exact'); 1267 expect(out.value).toEqual({ id: 'nav', class: 'hero' }); 1268 }); 1269 1270 it('propagates selector_not_found from the resolver as an error envelope', async () => { 1271 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1272 ok: false, 1273 code: 'selector_not_found', 1274 message: 'CSS selector ".missing" matched 0 elements', 1275 hint: 'Try a less specific selector.', 1276 }); 1277 const program = createProgram('', ''); 1278 1279 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.missing']); 1280 1281 expect(lastJsonLog().error.code).toBe('selector_not_found'); 1282 expect(process.exitCode).toBeDefined(); 1283 }); 1284 1285 it('forwards --nth into the resolver opts and reports matches_n', async () => { 1286 const evalMock = browserState.page!.evaluate as any; 1287 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 4, match_level: 'exact' }); 1288 evalMock.mockResolvedValueOnce('second'); 1289 const program = createProgram('', ''); 1290 1291 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'value', '.btn', '--nth', '1']); 1292 1293 const resolveJs = evalMock.mock.calls[0][0] as string; 1294 // resolveTargetJs embeds nth as a raw number literal; look for the binding 1295 expect(resolveJs).toContain('const nth = 1'); 1296 expect(lastJsonLog()).toEqual({ value: 'second', matches_n: 4, match_level: 'exact' }); 1297 }); 1298 1299 it('rejects malformed --nth with usage_error before touching the page', async () => { 1300 const program = createProgram('', ''); 1301 1302 await program.parseAsync(['node', 'opencli', 'browser', 'get', 'text', '.btn', '--nth', 'abc']); 1303 1304 expect(lastJsonLog().error.code).toBe('usage_error'); 1305 expect(browserState.page!.evaluate).not.toHaveBeenCalled(); 1306 expect(process.exitCode).toBeDefined(); 1307 }); 1308 }); 1309 1310 describe('browser click/type commands', () => { 1311 const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({ 1312 evaluate: vi.fn().mockResolvedValue(false), 1313 click: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }), 1314 typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }), 1315 wait: vi.fn().mockResolvedValue(undefined), 1316 })); 1317 1318 it('emits {clicked, target, matches_n, match_level} on success', async () => { 1319 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' }); 1320 const program = createProgram('', ''); 1321 1322 await program.parseAsync(['node', 'opencli', 'browser', 'click', '#save']); 1323 1324 expect(browserState.page!.click).toHaveBeenCalledWith('#save', {}); 1325 expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' }); 1326 }); 1327 1328 it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => { 1329 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' }); 1330 const program = createProgram('', ''); 1331 1332 await program.parseAsync(['node', 'opencli', 'browser', 'click', '7']); 1333 1334 expect(lastJsonLog()).toEqual({ clicked: true, target: '7', matches_n: 1, match_level: 'stable' }); 1335 }); 1336 1337 it('forwards --nth as ResolveOptions.nth to page.click', async () => { 1338 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 3, match_level: 'exact' }); 1339 const program = createProgram('', ''); 1340 1341 await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', '2']); 1342 1343 expect(browserState.page!.click).toHaveBeenCalledWith('.btn', { nth: 2 }); 1344 expect(lastJsonLog()).toEqual({ clicked: true, target: '.btn', matches_n: 3, match_level: 'exact' }); 1345 }); 1346 1347 it('surfaces selector_ambiguous from page.click as an error envelope', async () => { 1348 (browserState.page!.click as any).mockRejectedValueOnce(new TargetError({ 1349 code: 'selector_ambiguous', 1350 message: 'CSS selector ".btn" matched 3 elements; clicks require a unique target.', 1351 hint: 'Pass --nth <n> to pick one (0-based).', 1352 matches_n: 3, 1353 })); 1354 const program = createProgram('', ''); 1355 1356 await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn']); 1357 1358 const err = lastJsonLog().error; 1359 expect(err.code).toBe('selector_ambiguous'); 1360 expect(err.matches_n).toBe(3); 1361 expect(process.exitCode).toBeDefined(); 1362 }); 1363 1364 it('surfaces selector_nth_out_of_range from page.click as an error envelope', async () => { 1365 (browserState.page!.click as any).mockRejectedValueOnce(new TargetError({ 1366 code: 'selector_nth_out_of_range', 1367 message: '--nth 99 is out of range for CSS selector ".btn" (matches_n=3).', 1368 hint: 'Pick an index in [0, 2].', 1369 matches_n: 3, 1370 })); 1371 const program = createProgram('', ''); 1372 1373 await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', '99']); 1374 1375 expect(lastJsonLog().error.code).toBe('selector_nth_out_of_range'); 1376 expect(process.exitCode).toBeDefined(); 1377 }); 1378 1379 it('rejects malformed --nth on click with usage_error before touching the page', async () => { 1380 const program = createProgram('', ''); 1381 1382 await program.parseAsync(['node', 'opencli', 'browser', 'click', '.btn', '--nth', 'abc']); 1383 1384 expect(lastJsonLog().error.code).toBe('usage_error'); 1385 expect(browserState.page!.click).not.toHaveBeenCalled(); 1386 expect(process.exitCode).toBeDefined(); 1387 }); 1388 1389 it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => { 1390 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' }); 1391 (browserState.page!.typeText as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' }); 1392 (browserState.page!.evaluate as any).mockResolvedValueOnce(false); // isAutocomplete 1393 const program = createProgram('', ''); 1394 1395 await program.parseAsync(['node', 'opencli', 'browser', 'type', '#q', 'hello']); 1396 1397 expect(browserState.page!.click).toHaveBeenCalledWith('#q', {}); 1398 expect(browserState.page!.wait).toHaveBeenCalledWith(0.3); 1399 expect(browserState.page!.typeText).toHaveBeenCalledWith('#q', 'hello', {}); 1400 expect(lastJsonLog()).toEqual({ 1401 typed: true, text: 'hello', target: '#q', matches_n: 1, match_level: 'exact', autocomplete: false, 1402 }); 1403 }); 1404 1405 it('type: waits an extra 0.4s when the input reports autocomplete=true', async () => { 1406 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' }); 1407 (browserState.page!.typeText as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' }); 1408 (browserState.page!.evaluate as any).mockResolvedValueOnce(true); 1409 const program = createProgram('', ''); 1410 1411 await program.parseAsync(['node', 'opencli', 'browser', 'type', '#q', 'hi']); 1412 1413 const waitCalls = (browserState.page!.wait as any).mock.calls; 1414 expect(waitCalls).toContainEqual([0.3]); 1415 expect(waitCalls).toContainEqual([0.4]); 1416 expect(lastJsonLog().autocomplete).toBe(true); 1417 expect(lastJsonLog().match_level).toBe('exact'); 1418 }); 1419 1420 it('type: surfaces match_level=reidentified when ref had to be reidentified by fingerprint', async () => { 1421 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' }); 1422 (browserState.page!.typeText as any).mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' }); 1423 (browserState.page!.evaluate as any).mockResolvedValueOnce(false); 1424 const program = createProgram('', ''); 1425 1426 await program.parseAsync(['node', 'opencli', 'browser', 'type', '9', 'hi']); 1427 1428 // The typeText call is the authoritative match_level source for the `type` envelope. 1429 expect(lastJsonLog().match_level).toBe('reidentified'); 1430 }); 1431 1432 it('type: forwards --nth to both click and typeText', async () => { 1433 (browserState.page!.click as any).mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' }); 1434 (browserState.page!.typeText as any).mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' }); 1435 const program = createProgram('', ''); 1436 1437 await program.parseAsync(['node', 'opencli', 'browser', 'type', '.field', 'x', '--nth', '3']); 1438 1439 expect(browserState.page!.click).toHaveBeenCalledWith('.field', { nth: 3 }); 1440 expect(browserState.page!.typeText).toHaveBeenCalledWith('.field', 'x', { nth: 3 }); 1441 }); 1442 }); 1443 1444 describe('browser select command', () => { 1445 const { lastJsonLog } = installSelectorFirstTestHarness('select', () => ({ 1446 evaluate: vi.fn(), 1447 })); 1448 1449 it('emits {selected, target, matches_n, match_level} on success', async () => { 1450 const evalMock = browserState.page!.evaluate as any; 1451 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' }); 1452 evalMock.mockResolvedValueOnce({ selected: 'US' }); 1453 const program = createProgram('', ''); 1454 1455 await program.parseAsync(['node', 'opencli', 'browser', 'select', '#country', 'US']); 1456 1457 expect(lastJsonLog()).toEqual({ selected: 'US', target: '#country', matches_n: 1, match_level: 'exact' }); 1458 }); 1459 1460 it('maps "Not a <select>" to a not_a_select error envelope', async () => { 1461 const evalMock = browserState.page!.evaluate as any; 1462 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' }); 1463 evalMock.mockResolvedValueOnce({ error: 'Not a <select>' }); 1464 const program = createProgram('', ''); 1465 1466 await program.parseAsync(['node', 'opencli', 'browser', 'select', '#not-select', 'US']); 1467 1468 const err = lastJsonLog().error; 1469 expect(err.code).toBe('not_a_select'); 1470 expect(err.matches_n).toBe(1); 1471 expect(process.exitCode).toBeDefined(); 1472 }); 1473 1474 it('maps missing-option failures to an option_not_found envelope with available list', async () => { 1475 const evalMock = browserState.page!.evaluate as any; 1476 evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' }); 1477 evalMock.mockResolvedValueOnce({ error: 'Option "XX" not found', available: ['US', 'CA'] }); 1478 const program = createProgram('', ''); 1479 1480 await program.parseAsync(['node', 'opencli', 'browser', 'select', '#country', 'XX']); 1481 1482 const err = lastJsonLog().error; 1483 expect(err.code).toBe('option_not_found'); 1484 expect(err.available).toEqual(['US', 'CA']); 1485 expect(process.exitCode).toBeDefined(); 1486 }); 1487 1488 it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => { 1489 (browserState.page!.evaluate as any).mockResolvedValueOnce({ 1490 ok: false, 1491 code: 'selector_ambiguous', 1492 message: 'CSS selector ".dropdown" matched 2 elements.', 1493 hint: 'Pass --nth <n>.', 1494 matches_n: 2, 1495 }); 1496 const program = createProgram('', ''); 1497 1498 await program.parseAsync(['node', 'opencli', 'browser', 'select', '.dropdown', 'US']); 1499 1500 expect(lastJsonLog().error.code).toBe('selector_ambiguous'); 1501 // The select payload JS must not fire when resolution fails 1502 expect((browserState.page!.evaluate as any).mock.calls).toHaveLength(1); 1503 expect(process.exitCode).toBeDefined(); 1504 }); 1505 }); 1506 1507 describe('findPackageRoot', () => { 1508 it('walks up from dist/src to the package root', () => { 1509 const packageRoot = path.join('repo-root'); 1510 const cliFile = path.join(packageRoot, 'dist', 'src', 'cli.js'); 1511 const exists = new Set([ 1512 path.join(packageRoot, 'package.json'), 1513 ]); 1514 1515 expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot); 1516 }); 1517 1518 it('walks up from src to the package root', () => { 1519 const packageRoot = path.join('repo-root'); 1520 const cliFile = path.join(packageRoot, 'src', 'cli.ts'); 1521 const exists = new Set([ 1522 path.join(packageRoot, 'package.json'), 1523 ]); 1524 1525 expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot); 1526 }); 1527 }); 1528 1529 describe('normalizeVerifyRows', () => { 1530 it('returns an empty array for null / primitives', () => { 1531 expect(normalizeVerifyRows(null)).toEqual([]); 1532 expect(normalizeVerifyRows(undefined)).toEqual([]); 1533 expect(normalizeVerifyRows('hello')).toEqual([]); 1534 }); 1535 1536 it('passes through array-of-objects', () => { 1537 const rows = [{ a: 1 }, { a: 2 }]; 1538 expect(normalizeVerifyRows(rows)).toEqual(rows); 1539 }); 1540 1541 it('wraps array-of-primitives as { value } rows', () => { 1542 expect(normalizeVerifyRows([1, 'two', null])).toEqual([ 1543 { value: 1 }, { value: 'two' }, { value: null }, 1544 ]); 1545 }); 1546 1547 it('unwraps common envelope shapes', () => { 1548 expect(normalizeVerifyRows({ rows: [{ a: 1 }] })).toEqual([{ a: 1 }]); 1549 expect(normalizeVerifyRows({ items: [{ b: 2 }] })).toEqual([{ b: 2 }]); 1550 expect(normalizeVerifyRows({ data: [{ c: 3 }] })).toEqual([{ c: 3 }]); 1551 expect(normalizeVerifyRows({ results: [{ d: 4 }] })).toEqual([{ d: 4 }]); 1552 }); 1553 1554 it('wraps a single object as a one-row array', () => { 1555 expect(normalizeVerifyRows({ ok: true })).toEqual([{ ok: true }]); 1556 }); 1557 }); 1558 1559 describe('renderVerifyPreview', () => { 1560 it('emits a placeholder for empty rows', () => { 1561 expect(renderVerifyPreview([])).toContain('no rows'); 1562 }); 1563 1564 it('prints column headers followed by row cells', () => { 1565 const out = renderVerifyPreview([{ a: 'x', b: 1 }, { a: 'y', b: 2 }]); 1566 const lines = out.split('\n'); 1567 expect(lines[0]).toContain('a'); 1568 expect(lines[0]).toContain('b'); 1569 expect(lines.some((l) => l.includes('x') && l.includes('1'))).toBe(true); 1570 expect(lines.some((l) => l.includes('y') && l.includes('2'))).toBe(true); 1571 }); 1572 1573 it('truncates long cells and reports hidden rows / columns', () => { 1574 const rows = Array.from({ length: 15 }, (_, i) => ({ 1575 a: i, b: 'x'.repeat(100), c: i, d: i, e: i, f: i, g: i, h: i, 1576 })); 1577 const out = renderVerifyPreview(rows, { maxRows: 5, maxCols: 3, cellMax: 10 }); 1578 expect(out).toContain('and 10 more row'); 1579 expect(out).toContain('more column'); 1580 // cell gets truncated 1581 expect(out).toContain('xxxxxxxxxx'); 1582 expect(out).not.toContain('xxxxxxxxxxx'); // never 11 consecutive 1583 }); 1584 });