Skip to content

Commit b38eaef

Browse files
committed
fix(browser): reuse last network snapshot for browser network --detail
The previous browser network flow made follow-up inspection unstable. `opencli browser network --detail <index>` could read a fresh capture batch instead of the list the user had just seen. That broke index alignment immediately after the first read, so users could not reliably inspect the request they selected from `browser network`. Keep this fix in the CLI only and leave daemon/extension window-idle behavior unchanged. - persist the last listed `browser network` snapshot in the browser cache directory - make `--detail` load that snapshot first so indexes stay stable across follow-up inspection - surface a usage error when the requested index is outside the cached snapshot - add unit coverage for cached detail lookup and out-of-range indexes Before: - `opencli browser network` listed requests - `opencli browser network --detail 3` could consume a different batch and make `3` point at the wrong request After: - `opencli browser network` lists requests and saves that snapshot - `opencli browser network --detail 3` inspects request #3 from the same snapshot
1 parent 44147e5 commit b38eaef

2 files changed

Lines changed: 147 additions & 4 deletions

File tree

src/cli.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
24
import * as path from 'node:path';
35
import type { IPage } from './types.js';
46

@@ -13,6 +15,9 @@ const {
1315
mockRenderCascadeResult,
1416
mockGetBrowserFactory,
1517
mockBrowserSession,
18+
mockBrowserConnect,
19+
mockBrowserClose,
20+
browserState,
1621
} = vi.hoisted(() => ({
1722
mockExploreUrl: vi.fn(),
1823
mockRenderExploreSummary: vi.fn(),
@@ -24,6 +29,9 @@ const {
2429
mockRenderCascadeResult: vi.fn(),
2530
mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })),
2631
mockBrowserSession: vi.fn(),
32+
mockBrowserConnect: vi.fn(),
33+
mockBrowserClose: vi.fn(),
34+
browserState: { page: null as IPage | null },
2735
}));
2836

2937
vi.mock('./explore.js', () => ({
@@ -51,14 +59,26 @@ vi.mock('./runtime.js', () => ({
5159
browserSession: mockBrowserSession,
5260
}));
5361

62+
vi.mock('./browser/index.js', () => {
63+
mockBrowserConnect.mockImplementation(async () => browserState.page as IPage);
64+
return {
65+
BrowserBridge: class {
66+
connect = mockBrowserConnect;
67+
close = mockBrowserClose;
68+
},
69+
};
70+
});
71+
5472
import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js';
5573

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

5977
beforeEach(() => {
6078
delete process.env.OPENCLI_VERBOSE;
79+
delete process.env.OPENCLI_CACHE_DIR;
6180
process.exitCode = undefined;
81+
vi.clearAllMocks();
6282

6383
mockExploreUrl.mockReset().mockResolvedValue({ ok: true });
6484
mockRenderExploreSummary.mockReset().mockReturnValue('explore-summary');
@@ -69,13 +89,18 @@ describe('built-in browser commands verbose wiring', () => {
6989
mockCascadeProbe.mockReset().mockResolvedValue({ ok: true });
7090
mockRenderCascadeResult.mockReset().mockReturnValue('cascade-summary');
7191
mockGetBrowserFactory.mockClear();
92+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
7293
mockBrowserSession.mockReset().mockImplementation(async (_factory, fn) => {
7394
const page = {
7495
goto: vi.fn(),
7596
wait: vi.fn(),
7697
} as unknown as IPage;
7798
return fn(page);
7899
});
100+
browserState.page = {
101+
evaluate: vi.fn(),
102+
readNetworkCapture: vi.fn().mockResolvedValue([]),
103+
} as unknown as IPage;
79104
});
80105

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

243+
describe('browser network snapshot caching', () => {
244+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
245+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
246+
247+
beforeEach(() => {
248+
process.exitCode = undefined;
249+
const tempCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-network-cache-'));
250+
process.env.OPENCLI_CACHE_DIR = tempCacheDir;
251+
consoleLogSpy.mockClear();
252+
consoleErrorSpy.mockClear();
253+
});
254+
255+
it('reuses the last listed snapshot for --detail without consuming a new capture batch', async () => {
256+
const readNetworkCapture = vi.fn().mockResolvedValueOnce([
257+
{
258+
url: 'https://api.example.com/items',
259+
method: 'GET',
260+
responseStatus: 200,
261+
responseContentType: 'application/json',
262+
responsePreview: JSON.stringify({ items: [{ id: 1, title: 'cached item' }] }),
263+
},
264+
]);
265+
browserState.page = {
266+
evaluate: vi.fn(),
267+
readNetworkCapture,
268+
} as unknown as IPage;
269+
270+
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network']);
271+
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network', '--detail', '0']);
272+
273+
expect(readNetworkCapture).toHaveBeenCalledTimes(1);
274+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Showing cached request [0]');
275+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('https://api.example.com/items');
276+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('cached item');
277+
});
278+
279+
it('reports an out-of-range detail index against the last listed snapshot', async () => {
280+
const readNetworkCapture = vi.fn().mockResolvedValueOnce([
281+
{
282+
url: 'https://api.example.com/items',
283+
method: 'GET',
284+
responseStatus: 200,
285+
responseContentType: 'application/json',
286+
responsePreview: JSON.stringify({ ok: true }),
287+
},
288+
]);
289+
browserState.page = {
290+
evaluate: vi.fn(),
291+
readNetworkCapture,
292+
} as unknown as IPage;
293+
294+
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network']);
295+
await createProgram('', '').parseAsync(['node', 'opencli', 'browser', 'network', '--detail', '9']);
296+
297+
expect(readNetworkCapture).toHaveBeenCalledTimes(1);
298+
expect(process.exitCode).toBeDefined();
299+
expect(consoleErrorSpy.mock.calls.flat().join('\n')).toContain('not found in the last "browser network" result');
300+
});
301+
});
302+
218303
describe('findPackageRoot', () => {
219304
it('walks up from dist/src to the package root', () => {
220305
const packageRoot = path.join('repo-root');

src/cli.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import * as fs from 'node:fs';
9+
import * as os from 'node:os';
910
import * as path from 'node:path';
1011
import { fileURLToPath } from 'node:url';
1112
import { Command } from 'commander';
@@ -26,12 +27,47 @@ import { daemonStatus, daemonStop } from './commands/daemon.js';
2627
import { log } from './logger.js';
2728

2829
const CLI_FILE = fileURLToPath(import.meta.url);
30+
const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
31+
32+
type BrowserNetworkItem = {
33+
url: string;
34+
method: string;
35+
status: number;
36+
size: number;
37+
ct: string;
38+
body: unknown;
39+
};
40+
41+
function getBrowserNetworkCacheDir(): string {
42+
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
43+
}
44+
45+
function getBrowserNetworkCachePath(workspace: string = DEFAULT_BROWSER_WORKSPACE): string {
46+
const safeWorkspace = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_');
47+
return path.join(getBrowserNetworkCacheDir(), 'browser-network', `${safeWorkspace}.json`);
48+
}
49+
50+
function loadBrowserNetworkCache(workspace: string = DEFAULT_BROWSER_WORKSPACE): BrowserNetworkItem[] | null {
51+
try {
52+
const raw = fs.readFileSync(getBrowserNetworkCachePath(workspace), 'utf-8');
53+
const parsed = JSON.parse(raw) as { items?: BrowserNetworkItem[] } | null;
54+
return Array.isArray(parsed?.items) ? parsed.items : null;
55+
} catch {
56+
return null;
57+
}
58+
}
59+
60+
function saveBrowserNetworkCache(items: BrowserNetworkItem[], workspace: string = DEFAULT_BROWSER_WORKSPACE): void {
61+
const target = getBrowserNetworkCachePath(workspace);
62+
fs.mkdirSync(path.dirname(target), { recursive: true });
63+
fs.writeFileSync(target, JSON.stringify({ items, savedAt: new Date().toISOString() }), 'utf-8');
64+
}
2965

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

3773
function applyVerbose(opts: { verbose?: boolean }): void {
@@ -522,7 +558,26 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
522558
.option('--all', 'Show all requests including static resources')
523559
.description('Show captured network requests (auto-captured since last open)')
524560
.action(browserAction(async (page, opts) => {
525-
let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = [];
561+
if (opts.detail !== undefined) {
562+
const cached = loadBrowserNetworkCache();
563+
const idx = parseInt(opts.detail, 10);
564+
if (cached) {
565+
const req = cached[idx];
566+
if (!req) {
567+
console.error(`Request #${idx} not found in the last "browser network" result. ${cached.length} requests were listed.`);
568+
process.exitCode = EXIT_CODES.USAGE_ERROR;
569+
return;
570+
}
571+
console.log(`Showing cached request [${idx}] from the last "browser network" result.\n`);
572+
console.log(`${req.method} ${req.url}`);
573+
console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
574+
console.log('---');
575+
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
576+
return;
577+
}
578+
}
579+
580+
let items: BrowserNetworkItem[] = [];
526581
if (page.readNetworkCapture) {
527582
const raw = await page.readNetworkCapture();
528583
// Normalize daemon/CDP capture entries to __opencli_net shape.
@@ -549,7 +604,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
549604
var reqs = window.__opencli_net || [];
550605
return JSON.stringify(reqs);
551606
})()`) as string;
552-
try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "browser open <url>" first.'); return; }
607+
try { items = JSON.parse(requests) as BrowserNetworkItem[]; } catch { console.log('No network data captured. Run "browser open <url>" first.'); return; }
553608
}
554609

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

621+
if (items.length === 0) { console.log('No requests captured.'); return; }
622+
566623
if (opts.detail !== undefined) {
567624
const idx = parseInt(opts.detail, 10);
568625
const req = items[idx];
@@ -572,13 +629,14 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
572629
console.log('---');
573630
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
574631
} else {
632+
saveBrowserNetworkCache(items);
575633
console.log(`Captured ${items.length} API requests:\n`);
576634
items.forEach((r, i) => {
577635
const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
578636
console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
579637
if (bodyPreview) console.log(` ${bodyPreview}...`);
580638
});
581-
console.log(`\nUse --detail <index> to see full response body.`);
639+
console.log(`\nUse --detail <index> to inspect the same snapshot.`);
582640
}
583641
}));
584642

0 commit comments

Comments
 (0)