Skip to content
Merged
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
106 changes: 105 additions & 1 deletion src/diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildRepairContext, isDiagnosticEnabled, emitDiagnostic,
buildRepairContext, collectDiagnostic, isDiagnosticEnabled, emitDiagnostic,
truncate, redactUrl, redactText, resolveAdapterSourcePath, MAX_DIAGNOSTIC_BYTES,
type RepairContext,
} from './diagnostic.js';
import { SelectorError, CommandExecutionError } from './errors.js';
import type { InternalCliCommand } from './registry.js';
import type { IPage } from './types.js';

function makeCmd(overrides: Partial<InternalCliCommand> = {}): InternalCliCommand {
return {
Expand Down Expand Up @@ -252,3 +253,106 @@ describe('emitDiagnostic', () => {
expect(redactUrl('https://api.com/data?token=secret123')).toContain('[REDACTED]');
});
});

function makePage(overrides: Partial<IPage> = {}): IPage {
return {
goto: vi.fn(),
evaluate: vi.fn(),
getCookies: vi.fn(),
snapshot: vi.fn().mockResolvedValue('<div>...</div>'),
click: vi.fn(),
typeText: vi.fn(),
pressKey: vi.fn(),
scrollTo: vi.fn(),
getFormState: vi.fn(),
wait: vi.fn(),
tabs: vi.fn(),
selectTab: vi.fn(),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn(),
autoScroll: vi.fn(),
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn(),
screenshot: vi.fn(),
getCurrentUrl: vi.fn().mockResolvedValue('https://example.com/page'),
...overrides,
} as IPage;
}

describe('collectDiagnostic', () => {
it('keeps intercepted payloads in a dedicated capturedPayloads field', async () => {
const page = makePage({
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
getInterceptedRequests: vi.fn().mockResolvedValue([{ items: [{ id: 1 }] }]),
});

const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);

expect(ctx.page?.networkRequests).toEqual([
{ url: '/api/data', status: 200 },
]);
expect(ctx.page?.capturedPayloads).toEqual([
{ source: 'interceptor', responseBody: { items: [{ id: 1 }] } },
]);
});

it('preserves the previous network request output when interception is empty', async () => {
const page = makePage({
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
});

const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);

expect(ctx.page?.networkRequests).toEqual([{ url: '/api/data', status: 200 }]);
expect(ctx.page?.capturedPayloads).toEqual([]);
});

it('swallows intercepted request failures and still returns page state', async () => {
const page = makePage({
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
getInterceptedRequests: vi.fn().mockRejectedValue(new Error('interceptor unavailable')),
});

const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);

expect(ctx.page).toEqual({
url: 'https://example.com/page',
snapshot: '<div>...</div>',
networkRequests: [{ url: '/api/data', status: 200 }],
capturedPayloads: [],
consoleErrors: [],
});
});

it('redacts and truncates intercepted payloads recursively', async () => {
const page = makePage({
getInterceptedRequests: vi.fn().mockResolvedValue([{
token: 'token=abc123def456ghi789',
nested: {
cookie: 'cookie: session=super-secret-cookie-value',
body: 'x'.repeat(5000),
},
}]),
});

const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
const payload = ctx.page?.capturedPayloads?.[0] as Record<string, unknown>;
const body = ((payload.responseBody as Record<string, unknown>).nested as Record<string, unknown>).body as string;

expect(payload).toEqual({
source: 'interceptor',
responseBody: {
token: 'token=[REDACTED]',
nested: {
cookie: 'cookie: [REDACTED]',
body,
},
},
});
expect(body).toContain('[truncated,');
expect(body.length).toBeLessThan(5000);
});
});
70 changes: 68 additions & 2 deletions src/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ const MAX_SNAPSHOT_CHARS = 100_000;
const MAX_SOURCE_CHARS = 50_000;
/** Maximum number of network requests to include. */
const MAX_NETWORK_REQUESTS = 50;
/** Maximum number of captured interceptor payloads to include. */
const MAX_CAPTURED_PAYLOADS = 20;
/** Maximum characters for a single network request body. */
const MAX_REQUEST_BODY_CHARS = 4_000;
/** Maximum characters for error stack trace. */
const MAX_STACK_CHARS = 5_000;
/** Maximum nesting depth for arbitrary captured payloads. */
const MAX_CAPTURED_DEPTH = 4;
/** Maximum object keys or array items to keep per nesting level. */
const MAX_CAPTURED_CHILDREN = 20;

// ── Sensitive data patterns ──────────────────────────────────────────────────

Expand Down Expand Up @@ -80,6 +86,7 @@ export interface RepairContext {
url: string;
snapshot: string;
networkRequests: unknown[];
capturedPayloads?: unknown[];
consoleErrors: unknown[];
};
timestamp: string;
Expand Down Expand Up @@ -119,6 +126,41 @@ function redactHeaders(headers: Record<string, string> | undefined): Record<stri
return result;
}

/** Recursively sanitize arbitrary captured response content for diagnostic output. */
function sanitizeCapturedValue(value: unknown, depth: number = 0): unknown {
if (typeof value === 'string') {
return redactText(truncate(value, MAX_REQUEST_BODY_CHARS));
}
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (depth >= MAX_CAPTURED_DEPTH) {
return '[truncated: max depth reached]';
}
if (Array.isArray(value)) {
const items = value
.slice(0, MAX_CAPTURED_CHILDREN)
.map(item => sanitizeCapturedValue(item, depth + 1));
if (value.length > MAX_CAPTURED_CHILDREN) {
items.push(`[truncated, ${value.length - MAX_CAPTURED_CHILDREN} items omitted]`);
}
return items;
}
if (!value || typeof value !== 'object') {
return value;
}

const entries = Object.entries(value);
const result: Record<string, unknown> = {};
for (const [key, child] of entries.slice(0, MAX_CAPTURED_CHILDREN)) {
result[key] = sanitizeCapturedValue(child, depth + 1);
}
if (entries.length > MAX_CAPTURED_CHILDREN) {
result.__truncated__ = `[${entries.length - MAX_CAPTURED_CHILDREN} fields omitted]`;
}
return result;
}

/** Redact sensitive data from a single network request entry. */
function redactNetworkRequest(req: unknown): unknown {
if (!req || typeof req !== 'object') return req;
Expand All @@ -145,6 +187,12 @@ function redactNetworkRequest(req: unknown): unknown {
if (typeof redacted.body === 'string') {
redacted.body = truncate(redacted.body, MAX_REQUEST_BODY_CHARS);
}
if ('responseBody' in redacted) {
redacted.responseBody = sanitizeCapturedValue(redacted.responseBody);
}
if ('responsePreview' in redacted) {
redacted.responsePreview = sanitizeCapturedValue(redacted.responsePreview);
}

return redacted;
}
Expand Down Expand Up @@ -214,24 +262,34 @@ export function isDiagnosticEnabled(): boolean {
return process.env.OPENCLI_DIAGNOSTIC === '1';
}

function normalizeInterceptedRequests(interceptedRequests: unknown[]): unknown[] {
return interceptedRequests.slice(0, MAX_CAPTURED_PAYLOADS).map(responseBody => ({
source: 'interceptor',
responseBody: sanitizeCapturedValue(responseBody),
}));
}

/** 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([
const [url, snapshot, networkRequests, interceptedRequests, consoleErrors] = await Promise.all([
page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
page.snapshot().catch(() => '(snapshot unavailable)'),
page.networkRequests().catch(() => []),
page.getInterceptedRequests().catch(() => []),
page.consoleMessages('error').catch(() => []),
]);

const rawUrl = url ?? 'unknown';
const capturedResponses = normalizeInterceptedRequests(interceptedRequests as unknown[]);
return {
url: redactUrl(rawUrl),
snapshot: redactText(truncate(snapshot, MAX_SNAPSHOT_CHARS)),
networkRequests: (networkRequests as unknown[])
.slice(0, MAX_NETWORK_REQUESTS)
.map(redactNetworkRequest),
capturedPayloads: capturedResponses,
consoleErrors: (consoleErrors as unknown[])
.slice(0, 50)
.map(e => typeof e === 'string' ? redactText(e) : e),
Expand Down Expand Up @@ -298,7 +356,15 @@ export function emitDiagnostic(ctx: RepairContext): void {

// Enforce total output budget — drop page state (largest section) first if over budget
if (json.length > MAX_DIAGNOSTIC_BYTES && ctx.page) {
const trimmed = { ...ctx, page: { ...ctx.page, snapshot: '[omitted: over size budget]', networkRequests: [] } };
const trimmed = {
...ctx,
page: {
...ctx.page,
snapshot: '[omitted: over size budget]',
networkRequests: [],
capturedPayloads: [],
},
};
json = JSON.stringify(trimmed);
}
// If still over budget, drop page entirely
Expand Down