diff --git a/src/cli.test.ts b/src/cli.test.ts index e1393985c..023d7577f 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'node:path'; import type { IPage } from './types.js'; @@ -13,6 +13,7 @@ const { mockRenderCascadeResult, mockGetBrowserFactory, mockBrowserSession, + mockBrowserBridgeConnect, } = vi.hoisted(() => ({ mockExploreUrl: vi.fn(), mockRenderExploreSummary: vi.fn(), @@ -24,6 +25,7 @@ const { mockRenderCascadeResult: vi.fn(), mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })), mockBrowserSession: vi.fn(), + mockBrowserBridgeConnect: vi.fn(), })); vi.mock('./explore.js', () => ({ @@ -51,6 +53,12 @@ vi.mock('./runtime.js', () => ({ browserSession: mockBrowserSession, })); +vi.mock('./browser/index.js', () => ({ + BrowserBridge: function BrowserBridgeMock() { + return { connect: mockBrowserBridgeConnect }; + }, +})); + import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js'; describe('built-in browser commands verbose wiring', () => { @@ -236,3 +244,87 @@ describe('findPackageRoot', () => { expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot); }); }); + +describe('browser cookies command', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + function mockPage(cookies: unknown[]) { + mockBrowserBridgeConnect.mockReset().mockResolvedValue({ + getCookies: vi.fn().mockResolvedValue(cookies), + } as unknown as IPage); + } + + function hasLogContaining(text: string) { + return consoleLogSpy.mock.calls.some((call) => typeof call[0] === 'string' && call[0].includes(text)); + } + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + }); + + afterAll(() => { + consoleLogSpy.mockRestore(); + }); + + it('outputs cookies in table format by default', async () => { + mockPage([ + { name: 'session', value: 'abc', domain: '.example.com', path: '/', secure: true, httpOnly: true, expirationDate: 1893456000 }, + ]); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'cookies']); + + expect(mockBrowserBridgeConnect).toHaveBeenCalledWith(expect.objectContaining({ workspace: 'browser:default' })); + expect(hasLogContaining('session')).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); + + it('filters by domain and url', async () => { + mockPage([]); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'cookies', '--domain', '.example.com', '--url', 'https://example.com']); + + const page = await mockBrowserBridgeConnect.mock.results[0].value; + expect(page.getCookies).toHaveBeenCalledWith({ domain: '.example.com', url: 'https://example.com' }); + }); + + it('filters by name client-side', async () => { + mockPage([ + { name: 'session', value: 'abc', domain: '.example.com' }, + { name: 'token', value: 'xyz', domain: '.example.com' }, + ]); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'cookies', '--name', 'token']); + + expect(hasLogContaining('token')).toBe(true); + expect(hasLogContaining('session')).toBe(false); + }); + + it('outputs json when --format json is passed', async () => { + mockPage([ + { name: 'session', value: 'abc', domain: '.example.com', path: '/', secure: true, httpOnly: true }, + ]); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'cookies', '--format', 'json']); + + const jsonCalls = consoleLogSpy.mock.calls + .map((call) => call[0]) + .filter((text) => typeof text === 'string' && text.includes('"name": "session"')); + expect(jsonCalls.length).toBeGreaterThan(0); + expect(jsonCalls[0]).toContain('"httpOnly": true'); + }); + + it('handles empty result gracefully', async () => { + mockPage([]); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'cookies']); + + expect(hasLogContaining('(no cookies)')).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 4ad939614..10dab94d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -582,6 +582,33 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command } })); + // ── Cookies ── + + browser.command('cookies') + .option('--domain ', 'Filter cookies by domain') + .option('--url ', 'Filter cookies by URL') + .option('--name ', 'Filter cookies by name (client-side)') + .option('-f, --format ', 'Output format: table, json, yaml, csv, md', 'table') + .description('List browser cookies (including HttpOnly)') + .action(browserAction(async (page, opts) => { + const cookies = await page.getCookies({ + domain: opts.domain, + url: opts.url, + }); + + let rows = cookies; + if (opts.name) { + rows = rows.filter((c) => c.name === opts.name); + } + + if (rows.length === 0) { + console.log(styleText('dim', '(no cookies)')); + return; + } + + renderOutput(rows, { fmt: opts.format, columns: ['name', 'value', 'domain', 'path', 'secure', 'httpOnly', 'expirationDate'] }); + })); + // ── Init (adapter scaffolding) ── browser.command('init')