Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { IPage } from './types.js';

Expand All @@ -13,6 +15,9 @@ const {
mockRenderCascadeResult,
mockGetBrowserFactory,
mockBrowserSession,
mockBrowserConnect,
mockBrowserClose,
browserState,
} = vi.hoisted(() => ({
mockExploreUrl: vi.fn(),
mockRenderExploreSummary: vi.fn(),
Expand All @@ -24,6 +29,9 @@ const {
mockRenderCascadeResult: vi.fn(),
mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })),
mockBrowserSession: vi.fn(),
mockBrowserConnect: vi.fn(),
mockBrowserClose: vi.fn(),
browserState: { page: null as IPage | null },
}));

vi.mock('./explore.js', () => ({
Expand Down Expand Up @@ -51,14 +59,26 @@ vi.mock('./runtime.js', () => ({
browserSession: mockBrowserSession,
}));

vi.mock('./browser/index.js', () => {
mockBrowserConnect.mockImplementation(async () => browserState.page as IPage);
return {
BrowserBridge: class {
connect = mockBrowserConnect;
close = mockBrowserClose;
},
};
});

import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js';

describe('built-in browser commands verbose wiring', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

beforeEach(() => {
delete process.env.OPENCLI_VERBOSE;
delete process.env.OPENCLI_CACHE_DIR;
process.exitCode = undefined;
vi.clearAllMocks();

mockExploreUrl.mockReset().mockResolvedValue({ ok: true });
mockRenderExploreSummary.mockReset().mockReturnValue('explore-summary');
Expand All @@ -69,13 +89,18 @@ describe('built-in browser commands verbose wiring', () => {
mockCascadeProbe.mockReset().mockResolvedValue({ ok: true });
mockRenderCascadeResult.mockReset().mockReturnValue('cascade-summary');
mockGetBrowserFactory.mockClear();
mockBrowserClose.mockReset().mockResolvedValue(undefined);
mockBrowserSession.mockReset().mockImplementation(async (_factory, fn) => {
const page = {
goto: vi.fn(),
wait: vi.fn(),
} as unknown as IPage;
return fn(page);
});
browserState.page = {
evaluate: vi.fn(),
readNetworkCapture: vi.fn().mockResolvedValue([]),
} as unknown as IPage;
});

it('enables OPENCLI_VERBOSE for explore via the real CLI command', async () => {
Expand Down Expand Up @@ -215,6 +240,66 @@ describe('resolveBrowserVerifyInvocation', () => {
});
});

describe('browser network snapshot caching', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

beforeEach(() => {
process.exitCode = undefined;
const tempCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-network-cache-'));
process.env.OPENCLI_CACHE_DIR = tempCacheDir;
consoleLogSpy.mockClear();
consoleErrorSpy.mockClear();
});

it('reuses the last listed snapshot for --detail without consuming a new capture batch', async () => {
const readNetworkCapture = vi.fn().mockResolvedValueOnce([
{
url: 'https://api.example.com/items',
method: 'GET',
responseStatus: 200,
responseContentType: 'application/json',
responsePreview: JSON.stringify({ items: [{ id: 1, title: 'cached item' }] }),
},
]);
browserState.page = {
evaluate: vi.fn(),
readNetworkCapture,
} as unknown as IPage;

await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network']);
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network', '--detail', '0']);

expect(readNetworkCapture).toHaveBeenCalledTimes(1);
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Showing cached request [0]');
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('https://api.example.com/items');
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('cached item');
});

it('reports an out-of-range detail index against the last listed snapshot', async () => {
const readNetworkCapture = vi.fn().mockResolvedValueOnce([
{
url: 'https://api.example.com/items',
method: 'GET',
responseStatus: 200,
responseContentType: 'application/json',
responsePreview: JSON.stringify({ ok: true }),
},
]);
browserState.page = {
evaluate: vi.fn(),
readNetworkCapture,
} as unknown as IPage;

await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network']);
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network', '--detail', '9']);

expect(readNetworkCapture).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBeDefined();
expect(consoleErrorSpy.mock.calls.flat().join('\n')).toContain('not found in the last "browser network" result');
});
});

describe('findPackageRoot', () => {
it('walks up from dist/src to the package root', () => {
const packageRoot = path.join('repo-root');
Expand Down
66 changes: 62 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
Expand All @@ -26,12 +27,47 @@ import { daemonStatus, daemonStop } from './commands/daemon.js';
import { log } from './logger.js';

const CLI_FILE = fileURLToPath(import.meta.url);
const DEFAULT_BROWSER_WORKSPACE = 'browser:default';

type BrowserNetworkItem = {
url: string;
method: string;
status: number;
size: number;
ct: string;
body: unknown;
};

function getBrowserNetworkCacheDir(): string {
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
}

function getBrowserNetworkCachePath(workspace: string = DEFAULT_BROWSER_WORKSPACE): string {
const safeWorkspace = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_');
return path.join(getBrowserNetworkCacheDir(), 'browser-network', `${safeWorkspace}.json`);
}

function loadBrowserNetworkCache(workspace: string = DEFAULT_BROWSER_WORKSPACE): BrowserNetworkItem[] | null {
try {
const raw = fs.readFileSync(getBrowserNetworkCachePath(workspace), 'utf-8');
const parsed = JSON.parse(raw) as { items?: BrowserNetworkItem[] } | null;
return Array.isArray(parsed?.items) ? parsed.items : null;
} catch {
return null;
}
}

function saveBrowserNetworkCache(items: BrowserNetworkItem[], workspace: string = DEFAULT_BROWSER_WORKSPACE): void {
const target = getBrowserNetworkCachePath(workspace);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify({ items, savedAt: new Date().toISOString() }), 'utf-8');
}

/** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
async function getBrowserPage(): Promise<import('./types.js').IPage> {
const { BrowserBridge } = await import('./browser/index.js');
const bridge = new BrowserBridge();
return bridge.connect({ timeout: 30, workspace: 'browser:default' });
return bridge.connect({ timeout: 30, workspace: DEFAULT_BROWSER_WORKSPACE });
}

function applyVerbose(opts: { verbose?: boolean }): void {
Expand Down Expand Up @@ -522,7 +558,26 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
.option('--all', 'Show all requests including static resources')
.description('Show captured network requests (auto-captured since last open)')
.action(browserAction(async (page, opts) => {
let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = [];
if (opts.detail !== undefined) {
const cached = loadBrowserNetworkCache();
const idx = parseInt(opts.detail, 10);
if (cached) {
const req = cached[idx];
if (!req) {
console.error(`Request #${idx} not found in the last "browser network" result. ${cached.length} requests were listed.`);
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
console.log(`Showing cached request [${idx}] from the last "browser network" result.\n`);
console.log(`${req.method} ${req.url}`);
console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
console.log('---');
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
return;
}
}

let items: BrowserNetworkItem[] = [];
if (page.readNetworkCapture) {
const raw = await page.readNetworkCapture();
// Normalize daemon/CDP capture entries to __opencli_net shape.
Expand All @@ -549,7 +604,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
var reqs = window.__opencli_net || [];
return JSON.stringify(reqs);
})()`) as string;
try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "browser open <url>" first.'); return; }
try { items = JSON.parse(requests) as BrowserNetworkItem[]; } catch { console.log('No network data captured. Run "browser open <url>" first.'); return; }
}

if (items.length === 0) { console.log('No requests captured.'); return; }
Expand All @@ -563,6 +618,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
);
}

if (items.length === 0) { console.log('No requests captured.'); return; }

if (opts.detail !== undefined) {
const idx = parseInt(opts.detail, 10);
const req = items[idx];
Expand All @@ -572,13 +629,14 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
console.log('---');
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
} else {
saveBrowserNetworkCache(items);
console.log(`Captured ${items.length} API requests:\n`);
items.forEach((r, i) => {
const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
if (bodyPreview) console.log(` ${bodyPreview}...`);
});
console.log(`\nUse --detail <index> to see full response body.`);
console.log(`\nUse --detail <index> to inspect the same snapshot.`);
}
}));

Expand Down
Loading