Skip to content
Closed
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
91 changes: 91 additions & 0 deletions src/browser/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,33 @@ describe('CDPPage network capture', () => {
expect(networkEnableCalls.length).toBe(1);
});

it('captures multiple requests in order', async () => {
const page = await bridge.connect();
await page.startNetworkCapture!();

const requestHandler = (bridge as any)._eventListeners.get('Network.requestWillBeSent');
const responseHandler = (bridge as any)._eventListeners.get('Network.responseReceived');

// Two requests
for (const fn of requestHandler) {
fn({ requestId: 'r1', request: { url: 'https://api.com/a', method: 'GET', headers: {} }, wallTime: 1 });
fn({ requestId: 'r2', request: { url: 'https://api.com/b', method: 'POST', headers: {} }, wallTime: 2 });
}
for (const fn of responseHandler) {
fn({ requestId: 'r1', response: { status: 200, mimeType: 'text/html', headers: {} } });
fn({ requestId: 'r2', response: { status: 404, mimeType: 'application/json', headers: {} } });
}

const entries = await page.readNetworkCapture!() as NetworkCaptureEntry[];
expect(entries.length).toBe(2);
expect(entries[0].url).toBe('https://api.com/a');
expect(entries[0].method).toBe('GET');
expect(entries[0].status).toBe(200);
expect(entries[1].url).toBe('https://api.com/b');
expect(entries[1].method).toBe('POST');
expect(entries[1].status).toBe(404);
});

it('skips response body for non-textual content types', async () => {
const page = await bridge.connect();

Expand Down Expand Up @@ -221,3 +248,67 @@ describe('CDPPage network capture', () => {
expect(entries[0].responseBody).toBeUndefined();
});
});

describe('CDPPage console messages', () => {
let bridge: CDPBridge;

beforeEach(async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
bridge = new CDPBridge();
vi.spyOn(bridge, 'send').mockResolvedValue({});
});

afterEach(async () => {
vi.unstubAllEnvs();
await bridge.close();
});

it('captures console.log and console.error via CDP events', async () => {
const page = await bridge.connect();

// First call to consoleMessages enables Runtime domain and registers listeners
await page.consoleMessages();

// Simulate console events
const consoleHandler = (bridge as any)._eventListeners.get('Runtime.consoleAPICalled');
expect(consoleHandler).toBeDefined();

for (const fn of consoleHandler) {
fn({ type: 'log', args: [{ type: 'string', value: 'hello world' }] });
fn({ type: 'error', args: [{ type: 'string', value: 'something broke' }] });
fn({ type: 'warning', args: [{ type: 'string', value: 'watch out' }] });
}

// All messages
const all = await page.consoleMessages('info') as Array<{ level: string; text: string }>;
expect(all.length).toBe(3);
expect(all[0]).toEqual({ level: 'log', text: 'hello world' });
expect(all[1]).toEqual({ level: 'error', text: 'something broke' });

// Error-level filter
const errors = await page.consoleMessages('error') as Array<{ level: string; text: string }>;
expect(errors.length).toBe(2); // error + warning
expect(errors.map(e => e.level)).toEqual(['error', 'warning']);
});

it('captures uncaught exceptions via Runtime.exceptionThrown', async () => {
const page = await bridge.connect();
await page.consoleMessages();

const exceptionHandler = (bridge as any)._eventListeners.get('Runtime.exceptionThrown');
for (const fn of exceptionHandler) {
fn({ exceptionDetails: { exception: { description: 'TypeError: x is not a function' } } });
}

const errors = await page.consoleMessages('error') as Array<{ level: string; text: string }>;
expect(errors.length).toBe(1);
expect(errors[0].text).toBe('TypeError: x is not a function');
expect(errors[0].level).toBe('error');
});

it('returns empty array before any console events', async () => {
const page = await bridge.connect();
const msgs = await page.consoleMessages('error');
expect(msgs).toEqual([]);
});
});
50 changes: 50 additions & 0 deletions src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,17 @@ const MAX_CAPTURE_ENTRIES = 200;
/** Maximum response body preview size. */
const MAX_RESPONSE_PREVIEW = 10_000;

/** Maximum console messages to buffer. */
const MAX_CONSOLE_MESSAGES = 100;

class CDPPage extends BasePage {
private _pageEnabled = false;
private _captureEntries: NetworkCaptureEntry[] = [];
private _captureActive = false;
private _captureRequestMap = new Map<string, { index: number; method: string; url: string; headers: Record<string, string>; timestamp: number }>();
private _captureHandlers: Array<{ event: string; handler: (params: unknown) => void }> = [];
private _consoleMessages: Array<{ level: string; text: string }> = [];
private _consoleListening = false;

constructor(private bridge: CDPBridge) {
super();
Expand Down Expand Up @@ -300,6 +305,51 @@ class CDPPage extends BasePage {
return entries;
}

// ── Console message capture ────────────────────────────────────────────

private async ensureConsoleCapture(): Promise<void> {
if (this._consoleListening) return;
this._consoleListening = true;

await this.bridge.send('Runtime.enable').catch(() => {});

this.bridge.on('Runtime.consoleAPICalled', (params: unknown) => {
const p = params as Record<string, unknown>;
const type = String(p.type ?? 'log');
const args = Array.isArray(p.args) ? p.args : [];
const text = args
.map((a: Record<string, unknown>) => {
if (typeof a.value === 'string') return a.value;
if (a.description) return String(a.description);
if (a.value !== undefined) return String(a.value);
return a.type === 'undefined' ? 'undefined' : '[object]';
})
.join(' ');
if (this._consoleMessages.length < MAX_CONSOLE_MESSAGES) {
this._consoleMessages.push({ level: type, text });
}
});

this.bridge.on('Runtime.exceptionThrown', (params: unknown) => {
const p = params as Record<string, unknown>;
const detail = p.exceptionDetails as Record<string, unknown> | undefined;
const text = detail?.text
?? (detail?.exception as Record<string, unknown> | undefined)?.description
?? 'Unknown exception';
if (this._consoleMessages.length < MAX_CONSOLE_MESSAGES) {
this._consoleMessages.push({ level: 'error', text: String(text) });
}
});
}

override async consoleMessages(level: string = 'info'): Promise<unknown[]> {
await this.ensureConsoleCapture();
if (level === 'error') {
return this._consoleMessages.filter(m => m.level === 'error' || m.level === 'warning');
}
return [...this._consoleMessages];
}

async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
Expand Down
44 changes: 36 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,16 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command

operate.command('open').argument('<url>').description('Open URL in automation window')
.action(operateAction(async (page, url) => {
// Start session-level capture before navigation (captures initial requests)
if (typeof page.startNetworkCapture === 'function') {
try { await page.startNetworkCapture(); } catch { /* non-fatal */ }
}
await page.goto(url);
await page.wait(2);
// Auto-inject network interceptor for API discovery
try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
// Fallback: inject JS interceptor for environments without CDP capture
if (typeof page.startNetworkCapture !== 'function') {
try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
}
console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`);
}));

Expand Down Expand Up @@ -505,13 +511,35 @@ 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(operateAction(async (page, opts) => {
const requests = await page.evaluate(`(function(){
var reqs = window.__opencli_net || [];
return JSON.stringify(reqs);
})()`) as string;

// Prefer session-level capture; fallback to JS interceptor
let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = [];
try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open <url>" first.'); return; }

if (typeof page.readNetworkCapture === 'function') {
try {
const captured = await page.readNetworkCapture() as Array<Record<string, unknown>>;
if (captured.length > 0) {
items = captured.map(e => ({
url: String(e.url ?? ''),
method: String(e.method ?? 'GET'),
// Handle both CDPPage (status) and daemon/extension (responseStatus) shapes
status: typeof e.status === 'number' ? e.status : (typeof e.responseStatus === 'number' ? e.responseStatus : 0),
size: typeof e.size === 'number' ? e.size : 0,
ct: String(e.responseContentType ?? ''),
// Handle both CDPPage (responseBody) and daemon/extension (responsePreview) shapes
body: e.responseBody ?? e.responsePreview,
}));
}
} catch { /* fallback */ }
}

if (items.length === 0) {
// Fallback: read from JS interceptor
const requests = await page.evaluate(`(function(){
var reqs = window.__opencli_net || [];
return JSON.stringify(reqs);
})()`) as string;
try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open <url>" first.'); return; }
}

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

Expand Down
18 changes: 17 additions & 1 deletion src/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,30 @@ export function isDiagnosticEnabled(): boolean {
return process.env.OPENCLI_DIAGNOSTIC === '1';
}

/**
* Read network requests from the best available source.
* Priority: session-level capture (rich data) → performance API (fallback).
*/
async function readNetworkData(page: IPage): Promise<unknown[]> {
// Prefer session-level passive capture (has method/status/headers/body)
if (typeof page.readNetworkCapture === 'function') {
try {
const captured = await page.readNetworkCapture();
if (Array.isArray(captured) && captured.length > 0) return captured;
} catch { /* fallback */ }
}
// Fallback: performance API (URL + timing only)
return page.networkRequests().catch(() => []);
}

/** Safely collect page diagnostic state with redaction, size caps, and timeout. */
async function collectPageState(page: IPage): Promise<RepairContext['page'] | undefined> {
const collect = async (): Promise<RepairContext['page'] | undefined> => {
try {
const [url, snapshot, networkRequests, consoleErrors] = await Promise.all([
page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
page.snapshot().catch(() => '(snapshot unavailable)'),
page.networkRequests().catch(() => []),
readNetworkData(page),
page.consoleMessages('error').catch(() => []),
]);

Expand Down
20 changes: 12 additions & 8 deletions src/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,18 @@ function parseNetworkRequests(raw: unknown): NetworkEntry[] {
return entries;
}
if (Array.isArray(raw)) {
return raw.filter(e => e && typeof e === 'object').map(e => ({
method: (e.method ?? 'GET').toUpperCase(),
url: String(e.url ?? e.request?.url ?? e.requestUrl ?? ''),
status: e.status ?? e.statusCode ?? null,
contentType: e.contentType ?? e.responseContentType ?? e.response?.contentType ?? '',
responseBody: e.responseBody ? (typeof e.responseBody === 'string' ? tryParseJson(e.responseBody) : e.responseBody) : undefined,
requestHeaders: e.requestHeaders,
}));
return raw.filter(e => e && typeof e === 'object').map(e => {
// Handle both CDPPage (status/responseBody) and daemon/extension (responseStatus/responsePreview) shapes
const bodyRaw = e.responseBody ?? e.responsePreview;
return {
method: (e.method ?? 'GET').toUpperCase(),
url: String(e.url ?? e.request?.url ?? e.requestUrl ?? ''),
status: e.status ?? e.responseStatus ?? e.statusCode ?? null,
contentType: e.contentType ?? e.responseContentType ?? e.response?.contentType ?? '',
responseBody: bodyRaw ? (typeof bodyRaw === 'string' ? tryParseJson(bodyRaw) : bodyRaw) : undefined,
requestHeaders: e.requestHeaders,
};
});
}
return [];
}
Expand Down