/ src / cli.test.ts
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  });