diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d21d4f3..80ebe73a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,7 +157,7 @@ jobs: - name: Run browser input readiness regression env: BSK_CLICK_CHROME: google-chrome - run: pnpm --filter @browser-skill/extension exec vitest run src/tools/__tests__/click.browser.test.ts + run: pnpm --filter @browser-skill/extension exec vitest run src/tools/__tests__/click.browser.test.ts src/debug/__tests__/debug.browser.test.ts node-scripts: name: Node script tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d928347..8c079944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. +## [Unreleased] + +### Added + +- [Task-scoped website debugging](docs/website-debugging.md): bounded request/body + capture, operation-linked console and page context, browser-local history and JSON export, + CLI and DSH entry points, and an extension evidence workspace. Existing popup + controls are preserved, with an additive current-task card. +- Debug operation cards now include manual inputs, field-change chains, delayed + evidence, source-aware noise filtering and explicit capture gaps. + ## [0.3.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index e8d471b5..81b9e565 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,12 @@ through the [plugin](#deepseek-harness-plugin): the agent calls injected ## For Developers +[Website debugging](docs/website-debugging.md) connects request details, console +and page context to agent actions, with bounded browser-local history and JSON export. +Records remain available after tasks end; users and agents can analyze them later. +Start capture before reproducing; the extension home keeps its existing controls +and adds a current-task card linking to the evidence workspace. + The [scroll-to primitive reference](docs/scroll-to.md) covers its CLI, protocol and plugin entry points, visible bounds and interruption behavior. diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 2779da70..66f0e645 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -534,6 +534,16 @@ export class ChromiumCdp { } } + /** Debug body reads must never resurrect a detached / returned tab. */ + async sendAttached( + target: CdpDebuggee & { tabId: number }, + method: string, + params?: object, + ): Promise { + if (!this.attachedTabs.has(target.tabId)) throw new Error("debugger detached"); + return this.api.sendCommand(target, method, params) as Promise; + } + /** Subscribe to all CDP events. Returned disposable removes the listener. */ onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): { dispose(): void; @@ -1022,7 +1032,7 @@ function truncateDialogField(value: string): string { return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`; } -function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null { +export function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null { const raw = (params ?? {}) as Record; const args = Array.isArray(raw.args) ? raw.args : []; const text = args.map(remoteObjectToText).filter(Boolean).join(" "); @@ -1040,7 +1050,7 @@ function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null { }); } -function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null { +export function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null { const raw = (params ?? {}) as Record; const details = (raw.exceptionDetails ?? {}) as Record; const exception = (details.exception ?? {}) as Record; @@ -1060,7 +1070,7 @@ function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null { }); } -function parseLogEntry(params: unknown): ParsedConsoleEntry | null { +export function parseLogEntry(params: unknown): ParsedConsoleEntry | null { const raw = (params ?? {}) as Record; const entry = (raw.entry ?? {}) as Record; return makeConsoleEntry({ diff --git a/apps/extension/src/debug/__tests__/analysis.test.ts b/apps/extension/src/debug/__tests__/analysis.test.ts new file mode 100644 index 00000000..ae2e444f --- /dev/null +++ b/apps/extension/src/debug/__tests__/analysis.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { analyzeRecording } from "../analysis"; +import { budgetResult } from "../query"; +import type { DebugParams, DebugRecording, DebugRequest } from "../types"; + +const request = (id: number, patch: Partial = {}): DebugRequest => ({ + id: `d:n${id}`, + run_id: "d", + sequence: id, + started_at: id * 100, + finished_at: id * 100 + 200, + method: "GET", + url: "https://site.test/api/name", + resource_type: "Fetch", + state: "complete", + status: 200, + duration_ms: 200, + frame_id: "f1", + loader_id: "l1", + request_body: { state: "empty" }, + response_body: { state: "available", text: '{"ok":true}' }, + ...patch, +}); +const recording = (requests: DebugRequest[]): DebugRecording => + ({ + version: 1, + saved_at: 2000, + run: { id: "d", session_id: "s", next_since: 100, dropped_requests: 0, coverage: [] }, + requests, + operations: [], + console: [], + pages: [], + }) as unknown as DebugRecording; +const read = ( + requests: DebugRequest[], + action: "aggregate" | "duplicates", + params: Partial = {}, +) => analyzeRecording(recording(requests), { session_id: "s", action, ...params }); + +describe("recording analysis", () => { + it("groups exact method/origin/path and uses only captured timing and transfer samples", () => { + const source = [ + request(1, { url: "https://site.test/api/name?q=1", duration_ms: 10, transfer_bytes: 40 }), + request(2, { url: "https://site.test/api/name?q=2", duration_ms: 2000, status: 503 }), + request(3, { duration_ms: undefined, state: "pending", status: undefined }), + request(4, { method: "POST", state: "failed", duration_ms: 0 }), + request(5, { url: "https://site.test/api/name/123" }), + request(6, { url: "https://site.test/api/name/456" }), + request(7, { url: "data:image/png,abc", resource_type: "Image" }), + request(8, { url: "http://[invalid" }), + ]; + const before = JSON.stringify(source); + const result = read(source, "aggregate"); + expect(result.aggregates).toHaveLength(4); + expect(result.aggregates![0]).toMatchObject({ + count: 3, + http_errors: 1, + pending: 1, + failed: 0, + timing_samples: 2, + transfer_bytes: 40, + transfer_samples: 1, + slow: 1, + duration_ms: { min: 10, mean: 1005, p50: 10, p95: 2000, max: 2000, total: 2010 }, + request_ids: ["d:n2", "d:n1", "d:n3"], + statuses: { 200: 1, 503: 1 }, + }); + expect(JSON.stringify(source)).toBe(before); + expect(result.analysis?.semantics).toContain("not business success"); + }); + it("filters before summarizing and excludes controlled/replayed experiments by default", () => { + const source = [ + request(1), + request(2, { replay_id: "replay" }), + request(3, { status: 503 }), + request(4, { resource_type: "Image", url: "https://site.test/a.png" }), + ]; + expect(read(source, "aggregate").analysis).toMatchObject({ + retained: 4, + matched: 3, + included: 2, + excluded_controlled: 1, + }); + expect(read(source, "aggregate", { status: 503 }).aggregates![0].count).toBe(1); + expect(read(source, "aggregate", { include_controlled: true }).aggregates![0]).toMatchObject({ + count: 3, + replayed: 1, + }); + expect(read(source, "aggregate", { kind: "all", slow_ms: 0 }).analysis?.included).toBe(3); + }); + it("detects suspected duplicates only within a fixed window and same frame/document/body/URL", () => { + const source = [ + request(1, { started_at: 0, finished_at: 150, status: 503 }), + request(2, { started_at: 100, finished_at: 250 }), + request(3, { started_at: 1000 }), + request(4, { started_at: 1100 }), + request(5, { started_at: 2000 }), + request(6, { loader_id: "l2" }), + request(7, { frame_id: "f2" }), + request(8, { url: "https://site.test/api/name?q=1" }), + request(9, { request_body: { state: "available", text: "different" } }), + ]; + const groups = read(source, "duplicates").duplicates!; + expect(groups.map((g) => g.request_ids)).toEqual([ + ["d:n1", "d:n2", "d:n3"], + ["d:n4", "d:n5"], + ]); + expect(groups[0]).toMatchObject({ extra_requests: 2, possible_retry: true, overlap_count: 1 }); + expect(read(source, "duplicates").analysis?.suspected_extra_requests).toBe(3); + }); + it("does not claim equality for redacted, truncated, missing or identity-less requests", () => { + const source = [ + request(1, { request_body: { state: "truncated", text: "x" } }), + request(2, { request_body: { state: "available", text: '{"token":"[redacted]"}' } }), + request(3, { url: "https://site.test/api?token=%5Bredacted%5D" }), + request(4, { loader_id: undefined }), + request(5, { request_body: { state: "available" } }), + ]; + const result = read(source, "duplicates"); + expect(result.duplicates).toEqual([]); + expect(result.analysis).toMatchObject({ + uncomparable: 5, + coverage: expect.arrayContaining(["missing_document_identity"]), + }); + }); + it("links operation evidence and bounds reference lists without losing totals", () => { + const record = recording(Array.from({ length: 60 }, (_, i) => request(i, { started_at: i }))); + record.operations = [ + { id: "d:o1", started_at: 0, window_end: 1000 }, + ] as DebugRecording["operations"]; + const result = analyzeRecording(record, { session_id: "s", action: "duplicates" }); + expect(result.duplicates![0]).toMatchObject({ + count: 60, + extra_requests: 59, + refs_truncated: true, + operation_ids: ["d:o1"], + }); + expect(result.duplicates![0].request_ids).toHaveLength(50); + expect(read(record.requests, "aggregate").aggregates![0].request_ids).toHaveLength(50); + }); + it("keeps every group reachable under output-budget pagination", () => { + const record = recording( + Array.from({ length: 70 }, (_, i) => request(i, { url: `https://site.test/api/${i}` })), + ); + let offset = 0; + const ids: string[] = []; + do { + const params = { + session_id: "s", + action: "aggregate", + limit: 100, + budget: 4096, + offset, + } as const; + const page = budgetResult(analyzeRecording(record, params), params); + expect(new TextEncoder().encode(JSON.stringify(page, null, 2)).length).toBeLessThanOrEqual( + 4096, + ); + ids.push(...page.aggregates!.map((g) => g.id)); + offset = page.next_offset ?? 70; + } while (offset < 70); + expect(new Set(ids).size).toBe(70); + expect(ids).toHaveLength(70); + }); +}); diff --git a/apps/extension/src/debug/__tests__/archive.browser.test.ts b/apps/extension/src/debug/__tests__/archive.browser.test.ts new file mode 100644 index 00000000..641ed0d4 --- /dev/null +++ b/apps/extension/src/debug/__tests__/archive.browser.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment node +// Native IndexedDB coverage; uses an isolated Chrome profile, never the user's extension. + +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +describe.skipIf(!process.env.BSK_CLICK_CHROME)("browser-local debug history", () => { + it("survives reload, recovers interrupted checkpoints, expires and bounds records, and deletes atomically", async () => { + const scripts = new Map( + [ + "archive", + "journal", + "query", + "capabilities", + "evidence-model", + "performance", + "redact", + "json-source", + ].map((name) => [ + name, + ts.transpileModule(readFileSync(new URL(`../${name}.ts`, import.meta.url), "utf8"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, + }).outputText, + ]), + ); + const server = createServer((request, response) => { + const script = scripts.get(request.url?.slice(1).replace(/\.js$/, "") ?? ""); + response.setHeader("Content-Type", script ? "text/javascript" : "text/html"); + response.end(script ?? "Debug history storage"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + await withChrome( + { + executable: process.env.BSK_CLICK_CHROME, + deviceScale: 1, + zoom: 1, + startupTimeout: 30000, + }, + async (send: (method: string, params?: object, sessionId?: string) => Promise) => { + const { targetId } = await send("Target.createTarget", { url }); + const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true }); + await send("Page.enable", {}, sessionId); + await send("Page.navigate", { url }, sessionId); + const evaluate = async (expression: string) => { + const value = await send( + "Runtime.evaluate", + { expression, awaitPromise: true, returnByValue: true }, + sessionId, + ); + expect(value.exceptionDetails).toBeUndefined(); + return value.result.value; + }; + const result = await evaluate(`(async () => { + const { LocalDebugArchive, HISTORY_AGE_MS } = await import('/archive.js'); + const { redactBody } = await import('/redact.js'); + const now = Date.now(); + // Upgrade a real v1 database without deleting its old recording stores. + const legacy = await new Promise((resolve,reject)=>{const r=indexedDB.open('bsk-debug-history',1);r.onupgradeneeded=()=>{r.result.createObjectStore('runs',{keyPath:'run.id'});r.result.createObjectStore('recordings',{keyPath:'run.id'});};r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);}); + const record = (id, at = now, state = 'stopped') => ({ version: 1, saved_at: now, + run: { id, session_id: 'old', tab_id: 7, name: 'Retained', url: 'https://site.test', started_at: at, stopped_at: at, state, requests: 1, operations: 1, errors: 0, dropped_requests: 0, dropped_operations: 0, dropped_console: 0, coverage: [], next_since: 1, saved_at: now }, + requests: [{ id: id+':n1', run_id: id, state: 'pending', request_body: {state:'available',text:'{"name":"Alice"}'}, response_body:{state:'pending'}, request_headers:{'x-legacy':'retained'}, timing:{receiveHeadersEnd:42} }], + operations: [{ id:id+':a1', state:'running' }], console:[], pages:[] }); + await new Promise((resolve,reject)=>{const tx=legacy.transaction(['runs','recordings'],'readwrite');const old=record('dlegacy');tx.objectStore('runs').put({run:old.run,bytes:100});tx.objectStore('recordings').put(old);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}); + legacy.close(); + const archive = new LocalDebugArchive(undefined, () => now); + if (!(await archive.get('dlegacy'))?.requests[0].request_body.text.includes('Alice')) throw Error('v1 history migration failed'); + const legacyMetadata = await archive.get('dlegacy', false); + if (legacyMetadata.requests[0].request_body.text !== undefined) throw Error('metadata read loaded bodies'); + const legacyDetail = await archive.request('dlegacy', 'dlegacy:n1'); + if (!legacyDetail?.request_body.text.includes('Alice') || legacyDetail.request_headers['x-legacy'] !== 'retained' || legacyDetail.timing.receiveHeadersEnd !== 42) throw Error('v1 request detail failed'); + if (await archive.request('dlegacy', 'other:n1')) throw Error('cross-record request lookup'); + const active = record('dactive', now, 'capturing'); + active.performance = [{id:'dactive:p1',sequence:1,document_key:'1000:0',time_origin:1000,started_at:1000,observed_at:now,url:'https://site.test',navigation:'navigate',state:'capturing',early:true,scope:'main_frame',metrics:{cls:{value:0.2,state:'provisional',reasons:[]}},visibility:[],visibility_truncated:false,long_tasks:[],long_tasks_truncated:false,coverage:[]}]; + await archive.put(active); + const recovered = new LocalDebugArchive(undefined, () => now); + const saved = await recovered.get('dactive'); + if (saved.run.state !== 'stopped' || saved.run.stop_reason !== 'browser_restarted' || saved.requests[0].response_body.state !== 'unavailable' || saved.operations[0].state !== 'interrupted') throw Error('checkpoint recovery failed'); + if (saved.performance[0].state !== 'interrupted' || saved.performance[0].metrics.cls.state !== 'partial' || saved.performance[0].metrics.cls.value !== 0.2) throw Error('performance recovery failed'); + await recovered.put(record('dexpired', now - HISTORY_AGE_MS - 1)); + if (await recovered.get('dexpired')) throw Error('expiry failed'); + for (let i=0;i<52;i++) await recovered.put(record('d'+i, now+i)); + const retained = await recovered.list(); + if (retained.length !== 50 || await recovered.get('d0')) throw Error('count bound failed'); + await recovered.delete('d51'); + if (await recovered.get('d51') || (await recovered.list()).some(r => r.id === 'd51')) throw Error('deletion failed'); + const journalRun = record('djournal', now+90).run; + await recovered.put({...record('djournal', now+90), requests:[]}); + const entry = (n, text='saved-'+n) => ({ id: 'djournal:n'+n, run_id:'djournal', sequence:n+1, started_at:now+n, method:'GET', url:'https://site.test/api/'+n, resource_type:'Fetch', status:200, state:'complete', request_body:{state:'empty'}, response_body:{state:'available',text}, request_headers:{'x-test':'retained'} }); + await recovered.retain(journalRun, [entry(0), {...entry(-1),status:503}]); + await recovered.pin('djournal', 'djournal:n0', true); + await recovered.retain(journalRun, [{...entry(0), response_body:{state:'evicted',reason:'memory_limit'}}]); + for (let i=1; i<310; i+=20) await recovered.retain(journalRun, Array.from({length:20}, (_,j)=>entry(i+j,'x'.repeat(8192)))); + await recovered.put({...record('djournal', now+90), requests:[]}); + const kept = await recovered.get('djournal'); + if (kept.requests.length < 300 || kept.requests.find(r=>r.id==='djournal:n0').response_body.text !== 'saved-0') throw Error('journal evidence erased by checkpoint/cache eviction'); + const page = await recovered.query('djournal',{action:'requests', session_id:'old', limit:3, url:'/api/2'}); + if (page.requests.length!==3 || page.requests.some(r=>r.response_body.text || r.request_headers)) throw Error('index query leaked bodies or failed filtering'); + const next = await recovered.query('djournal',{action:'requests', session_id:'old', limit:3, url:'/api/2', since:page.next_since}); + if (next.requests.some(r=>page.requests.some(p=>p.id===r.id))) throw Error('query cursor duplicated rows'); + for (let i=330; i<2100; i+=50) await recovered.retain(journalRun, Array.from({length:50}, (_,j)=>entry(i+j))); + const bounded = await recovered.get('djournal'); + if (bounded.requests.length>2000 || bounded.run.storage.dropped===0 || !(await recovered.request('djournal','djournal:n0')).pinned) throw Error('journal count/pin bounds failed'); + for (let i=2200; i<2400; i+=10) await recovered.retain(journalRun, Array.from({length:10}, (_,j)=>entry(i+j,'中'.repeat(32768)))); + const byteBounded = await recovered.get('djournal'); + if (!(await recovered.request('djournal','djournal:n-1')) || byteBounded.run.storage.bytes>8*1024*1024 || !(await recovered.request('djournal','djournal:n0')).pinned || !byteBounded.run.coverage.includes('evidence_storage_limit')) throw Error('journal byte/pin bounds failed'); + await recovered.delete('djournal'); + if (await recovered.request('djournal','djournal:n0')) throw Error('journal deletion failed'); + const reloadRecord = {...record('dreload', now+100, 'capturing'), requests:[]}; + await recovered.put(reloadRecord); + await recovered.retain(reloadRecord.run, [{...entry(1), id:'dreload:n1',run_id:'dreload',request_body:{state:'available',...redactBody('{"orderId":9007199254740993,"user[password]":"private"}','application/json')},integrity:{url:'complete',metadata:'complete'}}]); + return { records: (await recovered.list()).length, recovered: saved.run.stop_reason }; + })()`); + expect(result).toEqual({ records: 50, recovered: "browser_restarted" }); + await send("Page.reload", {}, sessionId); + // Navigation completion: importing after reload also verifies the database survives a new page context. + let reloaded: unknown; + for (let attempt = 0; attempt < 20; attempt++) { + try { + reloaded = await evaluate( + `import('/archive.js').then(async ({LocalDebugArchive}) => new LocalDebugArchive().get('dreload').then(record => record?.run.storage?.requests === 1 && record.run.state === 'stopped' && record.requests[0].request_body.text))`, + ); + if (reloaded) break; + } catch { + /* old execution context may disappear during the first probe */ + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(reloaded).toBe('{"orderId":9007199254740993,"user[password]":"[redacted]"}'); + }, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 60000); +}); diff --git a/apps/extension/src/debug/__tests__/archive.test.ts b/apps/extension/src/debug/__tests__/archive.test.ts new file mode 100644 index 00000000..fe12df46 --- /dev/null +++ b/apps/extension/src/debug/__tests__/archive.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + expiredHistory, + HISTORY_AGE_MS, + HISTORY_BYTES, + HISTORY_LIMIT, + interrupted, +} from "../archive"; +import type { DebugRecording, DebugRun } from "../types"; + +const now = 2 * HISTORY_AGE_MS; +function run(id: string, at: number, state: "capturing" | "stopped" = "stopped"): DebugRun { + return { + id, + session_id: "s1", + tab_id: 7, + name: "App", + url: "https://site.test", + started_at: at, + stopped_at: at, + state, + requests: 0, + operations: 0, + errors: 0, + dropped_requests: 0, + dropped_operations: 0, + dropped_console: 0, + coverage: [], + next_since: 0, + }; +} + +describe("debug history retention", () => { + it("reserves count and bytes for active captures, evicting older stopped records first", () => { + const values = Array.from({ length: HISTORY_LIMIT }, (_, i) => ({ + run: run(`d${i}`, now + i), + bytes: 100, + })); + values.push({ run: run("dactive", now - HISTORY_AGE_MS - 1, "capturing"), bytes: 100 }); + expect(expiredHistory(values, now)).toEqual(["d0"]); + expect( + expiredHistory( + [ + { run: run("dactive", now - 2, "capturing"), bytes: HISTORY_BYTES - 100 }, + { run: run("dold", now - 1), bytes: 100 }, + { run: run("dnew", now), bytes: 100 }, + ], + now, + ), + ).toEqual(["dold"]); + }); + + it("expires stopped records by their stop time rather than the time they were opened", () => { + expect( + expiredHistory( + [ + { run: run("dold", now - HISTORY_AGE_MS - 1), bytes: 100 }, + { run: { ...run("drecent", now - HISTORY_AGE_MS - 1), stopped_at: now }, bytes: 100 }, + ], + now, + ), + ).toEqual(["dold"]); + }); + + it("preserves completed data while marking only interrupted work unavailable", () => { + const record: DebugRecording = { + version: 1, + saved_at: now, + run: run("d1", now - 1000, "capturing"), + pages: [], + console: [], + operations: [], + rules: [ + { + id: "d1:r1", + match: { url: "https://site.test/pending" }, + effect: { type: "mock", status: 200, body: "mock" }, + times: 0, + state: "enabled", + hits: 1, + failures: 0, + created_at: now - 1000, + }, + ], + replays: [{ id: "replay-1", key: "attempt-1", source_request_id: "d1:n1", state: "running" }], + requests: [ + { + id: "d1:n1", + run_id: "d1", + sequence: 1, + started_at: now - 1000, + method: "GET", + url: "https://site.test", + state: "complete", + request_body: { state: "empty" }, + response_body: { state: "available", text: "saved" }, + }, + { + id: "d1:n2", + run_id: "d1", + sequence: 2, + started_at: now - 100, + method: "GET", + url: "https://site.test/pending", + state: "pending", + intervention: { rule_id: "d1:r1", type: "mock", state: "pending" }, + request_body: { state: "empty" }, + response_body: { state: "pending" }, + }, + ], + }; + const recovered = interrupted(record); + expect(recovered.run.stopped_at).toBe(now); + expect(recovered.run.coverage).toContain("interrupted_checkpoint"); + expect(recovered.run.active_rules).toBe(0); + expect(recovered.rules?.[0].state).toBe("stopped"); + expect(recovered.replays?.[0].state).toBe("interrupted"); + expect(recovered.requests[1].intervention?.state).toBe("cancelled"); + expect(recovered.requests[0].response_body.text).toBe("saved"); + expect(recovered.requests[1].state).toBe("interrupted"); + expect(recovered.requests[1].response_body).toEqual({ + state: "unavailable", + reason: "browser_restarted", + }); + expect(interrupted(recovered)).toBe(recovered); + }); +}); diff --git a/apps/extension/src/debug/__tests__/bridge.test.ts b/apps/extension/src/debug/__tests__/bridge.test.ts new file mode 100644 index 00000000..26322689 --- /dev/null +++ b/apps/extension/src/debug/__tests__/bridge.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { attachDebugBridge, DEBUG_MESSAGE, isDebugPage } from "../bridge"; +import type { DebugManager } from "../manager"; + +afterEach(() => vi.unstubAllGlobals()); +describe("debug evidence access", () => { + it("accepts only this extension's popup and evidence page, never a content script", () => { + vi.stubGlobal("chrome", { + runtime: { id: "own", getURL: (path: string) => `chrome-extension://own${path}` }, + }); + expect(isDebugPage({ id: "own", url: "https://site.test" })).toBe(false); + expect(isDebugPage({ id: "other", url: "chrome-extension://own/debug.html" })).toBe(false); + expect(isDebugPage({ id: "own", url: "chrome-extension://own/other.html" })).toBe(false); + expect(isDebugPage({ id: "own", url: "chrome-extension://own/popup.html" })).toBe(true); + expect(isDebugPage({ id: "own", url: "chrome-extension://own/debug.html?session=s1" })).toBe( + true, + ); + }); + + it("serves archived records without a task and rejects website access before reading history", async () => { + const addListener = vi.fn(); + vi.stubGlobal("chrome", { + runtime: { + id: "own", + getURL: (path: string) => `chrome-extension://own${path}`, + onMessage: { addListener }, + }, + }); + const debug = { + history: vi.fn(async () => ({ runs: [{ id: "d1" }] })), + readHistory: vi.fn(async () => ({ recording: { version: 1 } })), + deleteHistory: vi.fn(async () => {}), + }; + // No SessionManager is needed for these extension-only, offline reads. + attachDebugBridge(undefined as never, debug as unknown as DebugManager); + const listener = addListener.mock.calls[0][0]; + const sender = { id: "own", url: "chrome-extension://own/debug.html" }; + const send = (message: object, source = sender) => + new Promise((resolve) => listener({ kind: DEBUG_MESSAGE, ...message }, source, resolve)); + expect(await send({ action: "history" }, { ...sender, url: "https://site.test" })).toEqual({ + ok: false, + error: "forbidden", + }); + expect(debug.history).not.toHaveBeenCalled(); + expect(await send({ action: "history" })).toEqual({ + ok: true, + data: { runs: [{ id: "d1" }] }, + }); + const params = { action: "export", session_id: "", run_id: "d1" }; + expect(await send({ action: "record", params })).toEqual({ + ok: true, + data: { recording: { version: 1 } }, + }); + expect(debug.readHistory).toHaveBeenCalledWith(params); + expect(await send({ action: "delete", run_id: "d1" })).toEqual({ ok: true, data: {} }); + expect(debug.deleteHistory).toHaveBeenCalledWith("d1"); + }); +}); diff --git a/apps/extension/src/debug/__tests__/control-model.test.ts b/apps/extension/src/debug/__tests__/control-model.test.ts new file mode 100644 index 00000000..c4e8f728 --- /dev/null +++ b/apps/extension/src/debug/__tests__/control-model.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + editRequest, + publicRule, + replayRequest, + urlMatcher, + validateReplay, + validateRule, +} from "../control-model"; +import { redactBody, redactRequestUrl } from "../redact"; +import type { DebugRequest } from "../types"; + +const request = { + url: "https://site.test/save", + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer secret" }, + postData: '{"displayName":"张三","keep":1}', +}; +const source = { + run_id: "d", + sequence: 1, + started_at: 0, + state: "complete", + response_body: { state: "empty" }, + id: "d:n1", + url: request.url, + method: request.method, + integrity: { url: "complete", metadata: "complete" }, + request_headers: { + "content-type": "application/json", + cookie: "[redacted]", + authorization: "[redacted]", + }, + request_body: { state: "available", replay_safe: true, text: '{"name":"张三"}' }, +} as DebugRequest; +describe("bounded network rule inputs", () => { + it("preserves untouched numeric tokens when editing or renaming other JSON fields", () => { + const input = { + ...request, + postData: + '{"id":9007199254740993,"nested":{"v":1.234567890123456789},"name":"Alice","zero":-0}', + }; + const result = editRequest(input, { + json: { rename: { id: "orderId" }, set: { name: "Bob" }, remove: ["zero"] }, + }); + expect(result.postData).toBe( + '{"nested":{"v":1.234567890123456789},"name":"Bob","orderId":9007199254740993}', + ); + expect(() => + editRequest({ ...request, postData: '{"id":1,"id":2}' }, { json: { set: { name: "Bob" } } }), + ).toThrow("unique top-level keys"); + expect(() => + validateRule({ + match: { url: request.url }, + effect: { type: "modify", json: { set: { nested: { orderId: 9007199254740992 } } } }, + }), + ).toThrow("exact text body replacement"); + }); + it("edits the live JSON without touching other fields or leaking prototype mutations", () => { + const result = editRequest(request, { + json: { rename: { displayName: "name" }, set: JSON.parse('{"__proto__":{"polluted":true}}') }, + }); + expect(JSON.parse(result.postData!)).toEqual( + JSON.parse('{"keep":1,"name":"张三","__proto__":{"polluted":true}}'), + ); + expect({}).not.toHaveProperty("polluted"); + expect(request.postData).toContain("displayName"); + expect(result.headers.authorization).toBe("Bearer secret"); + expect(() => editRequest(request, { json: { rename: { displayName: "keep" } } })).toThrow( + "already exists", + ); + }); + it("matches URLs literally except for explicit path wildcards", () => { + const rule = validateRule({ + match: { url: "https://site.test/api/*?a=1" }, + effect: { type: "block" }, + }); + expect(urlMatcher(rule.match.url).test("https://site.test/api/save?a=1")).toBe(true); + expect(urlMatcher(rule.match.url).test("https://siteXtest/api/saveXa=1")).toBe(false); + for (const input of [ + { match: { url: "https://*.test/*" }, effect: { type: "block" } }, + { + match: { url: "https://site.test/*" }, + effect: { type: "modify", url: "https://other.test/save" }, + }, + { match: { url: request.url }, effect: { type: "mock", status: 302, body: "" } }, + { match: { url: request.url }, effect: { type: "modify", headers: { "x-foo": "a\r\nb" } } }, + { match: { url: request.url }, effect: { type: "modify", body: "[redacted]" } }, + { match: { url: request.url }, effect: { type: "block" }, times: -1 }, + { match: { url: request.url }, effect: { type: "block" }, typo: true }, + ]) + expect(() => validateRule(input)).toThrow(); + }); + it("exports only redacted rule values while preserving the executable definition", () => { + const rule = validateRule({ + match: { url: request.url }, + effect: { + type: "modify", + headers: { Authorization: "Bearer top-secret" }, + json: { set: { password: "private", name: "visible" } }, + }, + }); + const snapshot = JSON.stringify(publicRule(rule)); + expect(snapshot).not.toContain("top-secret"); + expect(snapshot).not.toContain("private"); + expect(snapshot).toContain("visible"); + expect(JSON.stringify(rule)).toContain("top-secret"); + }); +}); +describe("replay preparation", () => { + const options = { key: "one", headers: { authorization: null } }; + it("replays the original 64-bit ID exactly and requires replacements for changed or legacy bodies", () => { + const original = '{"orderId":9007199254740993,"action":"cancel"}'; + const retained = { + ...source, + request_body: { state: "available" as const, ...redactBody(original, "application/json") }, + }; + expect(replayRequest(retained, options, "https://site.test").postData).toBe(original); + for (const body of [ + { state: "available" as const, text: '{"orderId":9007199254740992}' }, + { + state: "available" as const, + ...redactBody('{"id":1,"password":"hidden"}', "application/json"), + }, + { state: "available" as const, text: "changed", redacted: true, replay_safe: true }, + ]) { + const entry = { ...source, request_body: body }; + expect(() => replayRequest(entry, options, "https://site.test")).toThrow( + "complete replacement body", + ); + expect( + replayRequest(entry, { ...options, body: original }, "https://site.test").postData, + ).toBe(original); + } + }); + + it("requires a full replacement URL after truncation and still rejects incomplete headers", () => { + const original = `https://site.test/save?q=${"x".repeat(2200)}&mode=dry-run`; + const url = redactRequestUrl(original); + expect(url.state).toBe("truncated"); + expect(url.text).toHaveLength(2048); + const entry: DebugRequest = { + ...source, + url: url.text, + truncated: true, + integrity: { url: url.state, metadata: "complete" }, + }; + expect(() => replayRequest(entry, options, "https://site.test")).toThrow("replacement URL"); + const replacement = validateReplay({ ...options, url: original }); + expect(replayRequest(entry, replacement, "https://site.test").url).toBe(original); + expect(() => + replayRequest( + { ...entry, integrity: { ...entry.integrity!, metadata: "truncated" } }, + replacement, + "https://site.test", + ), + ).toThrow("source request is incomplete"); + expect(() => + replayRequest( + { ...entry, integrity: undefined, truncated: false }, + options, + "https://site.test", + ), + ).toThrow("replacement URL"); + }); + it("requires missing secrets to be supplied, but lets the browser supply cookies", () => { + expect(() => replayRequest(source, { key: "one" }, "https://site.test")).toThrow( + "redacted header", + ); + const replay = replayRequest( + source, + { key: "one", headers: { authorization: null } }, + "https://site.test/#/profile", + ); + expect(replay.headers).not.toHaveProperty("cookie"); + expect(replay.headers).not.toHaveProperty("authorization"); + expect(replay.postData).toContain("张三"); + }); + it("rejects incomplete bodies, foreign origins, unsafe headers and unknown options", () => { + expect(() => + replayRequest( + { ...source, request_body: { state: "truncated" } }, + { key: "one", headers: { authorization: null } }, + "https://site.test", + ), + ).toThrow("complete replacement"); + expect(() => + replayRequest(source, { key: "one", url: "https://other.test/save" }, "https://site.test"), + ).toThrow("share an origin"); + expect(() => validateReplay({ key: "one", headers: { Host: "other.test" } })).toThrow( + "unsupported header", + ); + expect(() => validateReplay({ key: "one", json: { set: { name: "Bob" } } })).toThrow("unknown"); + expect(() => validateReplay({})).toThrow("key"); + }); +}); diff --git a/apps/extension/src/debug/__tests__/debug.browser.test.ts b/apps/extension/src/debug/__tests__/debug.browser.test.ts new file mode 100644 index 00000000..14f681ed --- /dev/null +++ b/apps/extension/src/debug/__tests__/debug.browser.test.ts @@ -0,0 +1,473 @@ +// @vitest-environment node +// Opt in with BSK_CLICK_CHROME. Owns its browser, profile and local fixture server. + +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { type CdpDebuggee, type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import { SessionManager } from "@/session-manager/manager"; +import { handleClick } from "@/tools/interaction"; +import { DebugManager } from "../manager"; + +type Send = >( + method: string, + params?: object, + sessionId?: string, +) => Promise; +type Event = { sessionId?: string; method: string; params?: Record }; +type Listener = (source: CdpDebuggee, method: string, params: unknown) => void; + +describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser website debugging", () => { + it("captures and verifies an HTTP 200 business failure, redirects, iframe requests and cleanup", async () => { + let fixed = false; + const server = createServer((request, response) => { + const route = request.url?.split("?")[0]; + if (route === "/slow") { + response.setHeader("Content-Type", "application/json"); + setTimeout(() => response.end('{"name":"Bob","success":true}'), 2000); + return; + } + if (route === "/api/save") { + response.setHeader("Content-Type", "application/json"); + response.setHeader("Set-Cookie", "session_id=private; Path=/"); + response.end( + JSON.stringify({ + ok: fixed, + code: fixed ? "SAVED" : "VALIDATION_FAILED", + token: "private-response", + }), + ); + return; + } + if (route === "/redirect") { + response.writeHead(302, { Location: "/api/save" }); + response.end(); + return; + } + if (route === "/frame-api") { + response.setHeader("Content-Type", "application/json"); + response.end('{"frame":true}'); + return; + } + if (route === "/frame") { + response.setHeader("Content-Type", "text/html"); + response.end("

Frame ready

"); + return; + } + if (route === "/large") { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ value: "x".repeat(80000) })); + return; + } + if (route === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + response.setHeader("Content-Type", "text/html"); + response.end(`Debug fixture

Ready

`); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + let onEvent: ((event: Event) => void) | undefined; + await withChrome( + { + executable: process.env.BSK_CLICK_CHROME, + deviceScale: 1, + zoom: 1, + startupTimeout: 30000, + onEvent: (event: Event) => onEvent?.(event), + }, + async (send: Send) => { + const { targetId } = await send<{ targetId: string }>("Target.createTarget", { + url: "about:blank", + }); + const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId, + flatten: true, + }); + const listeners = new Set(); + const children = new Set(); + onEvent = (event) => { + if ( + event.sessionId !== sessionId && + (!event.sessionId || !children.has(event.sessionId)) + ) + return; + if (event.method === "Target.attachedToTarget") + children.add(event.params?.sessionId as string); + const source = { + tabId: 7, + ...(event.sessionId !== sessionId ? { sessionId: event.sessionId } : {}), + }; + for (const listener of listeners) listener(source, event.method, event.params); + }; + const api: CdpDebuggerApi = { + attach: async () => {}, + detach: async () => { + await send("Target.detachFromTarget", { sessionId }); + }, + sendCommand: (target, method, params) => + send(method, params, target.sessionId ?? sessionId), + onEvent: { + addListener: (fn: Listener) => listeners.add(fn), + removeListener: (fn: Listener) => listeners.delete(fn), + } as unknown as CdpDebuggerApi["onEvent"], + onDetach: { + addListener: () => {}, + removeListener: () => {}, + } as unknown as CdpDebuggerApi["onDetach"], + }; + const cdp = new ChromiumCdp(api); + const sessions = new SessionManager({ + agentWindow: { + create: async () => ({ windowId: 100, initialTabIds: [7] }), + remove: async () => {}, + ensureActiveTab: async () => 7, + }, + }); + await sessions.start("website-debug"); + const tab = { + id: 7, + windowId: 100, + active: true, + url, + title: "Debug fixture", + } as chrome.tabs.Tab; + const tabs = { get: async () => tab, query: async () => [tab] }; + const debug = new DebugManager(sessions, cdp, tabs); + const evaluate = async (expression: string, targetSession = sessionId) => { + const value = await send<{ result: { value?: unknown }; exceptionDetails?: unknown }>( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true }, + targetSession, + ); + expect(value.exceptionDetails).toBeUndefined(); + return value.result.value; + }; + try { + await debug.start("website-debug", 7, "Save fails"); + const navigation = await debug.before({ + id: "nav", + method: "tool.navigate", + params: { session_id: "website-debug", tab_id: 7 }, + }); + await cdp.send(7, "Page.navigate", { url }); + debug.after(navigation); + await vi.waitFor( + async () => expect(await evaluate("!!document.querySelector('#save')")).toBe(true), + { timeout: 5000 }, + ); + const click = async (id: string) => { + const ticket = await debug.before({ + id, + method: "tool.click", + params: { session_id: "website-debug", tab_id: 7, selector: "#save" }, + }); + const result = await handleClick( + sessions, + { session_id: "website-debug", tab_id: 7, selector: "#save" }, + { cdp, tabsApi: tabs }, + ); + expect(result, JSON.stringify(result)).not.toHaveProperty("code"); + debug.after(ticket); + return ticket!; + }; + const first = await click("before"); + await vi.waitFor( + async () => { + const result = await debug.read({ + action: "operation", + session_id: "website-debug", + id: first.operation.id, + }); + expect(result.operation?.after?.text).toContain("Save failed"); + expect(result.console?.length).toBeGreaterThan(0); + expect( + result.requests?.some( + (entry) => + entry.url.includes("/api/save") && entry.response_body.state === "available", + ), + ).toBe(true); + }, + { timeout: 5000 }, + ); + const firstEvidence = await debug.read({ + action: "operation", + session_id: "website-debug", + id: first.operation.id, + }); + const request = firstEvidence.requests!.find((entry) => + entry.url.includes("/api/save"), + )!; + expect(request.status).toBe(200); + expect(request.url).not.toContain("private-query"); + const body = await debug.read({ + action: "request", + session_id: "website-debug", + id: request.id, + part: "response", + pointer: "/ok", + }); + expect(body.request?.response_body.text).toBe("false"); + const headers = await debug.read({ + action: "request", + session_id: "website-debug", + id: request.id, + part: "headers", + }); + expect(headers.request?.request_headers?.authorization).toBe("[redacted]"); + expect(headers.request?.response_headers?.["set-cookie"]).toBe("[redacted]"); + expect( + ( + await debug.read({ + action: "request", + session_id: "website-debug", + id: request.id, + part: "request", + }) + ).request?.request_body.text, + ).toContain('"name":"Alice"'); + fixed = true; + const second = await click("after"); + await vi.waitFor( + async () => + expect( + ( + await debug.read({ + action: "operation", + session_id: "website-debug", + id: second.operation.id, + }) + ).operation?.after?.text, + ).toContain("Saved"), + { timeout: 5000 }, + ); + const recording = (await debug.read({ action: "export", session_id: "website-debug" })) + .recording!; + expect(recording.version).toBe(1); + expect( + recording.operations.find((item) => item.id === first.operation.id)?.after?.text, + ).toContain("Save failed"); + expect( + recording.operations.find((item) => item.id === second.operation.id)?.after?.text, + ).toContain("Saved"); + expect( + recording.requests.some((item) => + item.response_body.text?.includes("VALIDATION_FAILED"), + ), + ).toBe(true); + await evaluate("Promise.all([fetch('/redirect'),fetch('/large')]).then(()=>true)"); + await vi.waitFor( + async () => { + const result = await debug.read({ + action: "requests", + session_id: "website-debug", + limit: 100, + }); + expect( + result.requests?.some( + (entry) => entry.state === "redirected" && entry.status === 302, + ), + ).toBe(true); + expect( + result.requests?.find((entry) => entry.url.endsWith("/large"))?.response_body + .state, + ).toBe("truncated"); + }, + { timeout: 5000 }, + ); + await vi.waitFor(() => expect(children.size).toBeGreaterThan(0), { timeout: 5000 }); + await evaluate("fetch('/frame-api').then(r=>r.json())", [...children][0]); + await vi.waitFor( + async () => + expect( + ( + await debug.read({ + action: "requests", + session_id: "website-debug", + limit: 100, + }) + ).requests?.some( + (entry) => + entry.url.includes("/frame-api") && entry.response_body.state === "available", + ), + ).toBe(true), + { timeout: 5000 }, + ); + // Native input without DebugManager.before models a person reproducing a bug. + const beforeManual = ( + await debug.read({ action: "operations", session_id: "website-debug" }) + ).operations!; + expect(beforeManual.every((item) => item.source === "agent")).toBe(true); + await evaluate( + "document.querySelector('#name').focus();document.querySelector('#name').select();true", + ); + await send("Input.insertText", { text: "Bob" }, sessionId); + await handleClick( + sessions, + { session_id: "website-debug", tab_id: 7, selector: "#save" }, + { cdp, tabsApi: tabs }, + ); + let manualId = ""; + await vi.waitFor( + async () => { + const operations = ( + await debug.read({ action: "operations", session_id: "website-debug" }) + ).operations!; + expect( + operations.some((item) => item.source === "human" && item.method === "tool.fill"), + ).toBe(true); + manualId = operations.findLast( + (item) => item.source === "human" && item.method === "tool.click", + )!.id; + const detail = await debug.read({ + action: "operation", + session_id: "website-debug", + id: manualId, + }); + expect( + detail.evidence?.fields.find((field) => field.label === "Nickname"), + ).toMatchObject({ + before: { value: "Alice" }, + input: { value: "Bob" }, + submitted: [{ value: "Bob" }], + response: [{ value: "Bob" }], + }); + expect(detail.evidence?.links.some((link) => link.relation === "delayed")).toBe( + true, + ); + expect(detail.operation?.after?.text).toContain("Delayed saved"); + }, + { timeout: 9000 }, + ); + await send("Page.reload", {}, sessionId); + await vi.waitFor( + async () => { + const detail = await debug.read({ + action: "operation", + session_id: "website-debug", + id: manualId, + }); + expect( + detail.evidence?.fields.find((field) => field.label === "Nickname")?.later, + JSON.stringify( + (await debug.read({ action: "export", session_id: "website-debug" })).recording + ?.pages, + ), + ).toMatchObject({ value: "Alice", source: "page:reload" }); + }, + { timeout: 5000 }, + ); + const exported = await debug.read({ action: "export", session_id: "website-debug" }); + expect(JSON.stringify(exported)).not.toContain("manual-private"); + expect( + exported.recording?.operations.some( + (item) => item.source === "human" && item.method === "tool.reload", + ), + ).toBe(true); + await evaluate( + "document.querySelector('#name').focus();document.querySelector('#name').select();true", + ); + await send("Input.insertText", { text: "Pending edit" }, sessionId); + await debug.read({ action: "stop", session_id: "website-debug" }); + const finalRecord = ( + await debug.read({ action: "export", session_id: "website-debug" }) + ).recording!; + expect(finalRecord.performance?.length).toBeGreaterThan(0); + const performance = await debug.read({ + action: "performance", + session_id: "website-debug", + }); + expect(performance.performance).toEqual(finalRecord.performance); + expect(performance.performance?.some((p) => p.metrics.load_ms.value! > 0)).toBe(true); + expect(performance.performance?.every((p) => p.state !== "capturing")).toBe(true); + expect(finalRecord.requests.some((r) => r.loader_id && r.frame_id)).toBe(true); + const summary = await debug.read({ + action: "aggregate", + session_id: "website-debug", + url: "/api/save", + }); + expect(summary.aggregates?.[0].count).toBeGreaterThan(0); + expect( + summary.aggregates?.[0].request_ids.every((id) => + finalRecord.requests.some((r) => r.id === id), + ), + ).toBe(true); + expect(finalRecord.operations.at(-1)).toMatchObject({ + source: "human", + method: "tool.fill", + state: "completed", + }); + expect( + finalRecord.operations.at(-1)?.after?.fields?.find((field) => field.name === "name") + ?.value, + ).toBe("Pending edit"); + if (process.env.BSK_DEBUG_RECORD_PATH) { + const details = await Promise.all( + finalRecord.operations.map((operation) => + debug.read({ + action: "operation", + session_id: "website-debug", + id: operation.id, + }), + ), + ); + await writeFile( + process.env.BSK_DEBUG_RECORD_PATH, + JSON.stringify({ recording: finalRecord, details, selectedOperation: manualId }), + ); + } + const stopped = (await debug.read({ action: "status", session_id: "website-debug" })) + .runs![0]; + await evaluate("fetch('/frame-api?after-stop').then(r=>r.text())"); + expect( + (await debug.read({ action: "status", session_id: "website-debug" })).runs![0] + .requests, + ).toBe(stopped.requests); + debug.releaseSession("website-debug"); + expect( + (await debug.read({ action: "status", session_id: "website-debug" })).runs, + ).toEqual([]); + console.log( + "WEBSITE-DEBUG", + JSON.stringify({ + businessFailure: request.id, + before: first.operation.id, + after: second.operation.id, + requests: stopped.requests, + iframeTargets: children.size, + }), + ); + } finally { + debug.dispose(); + cdp.dispose(); + onEvent = undefined; + } + }, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 60_000); +}); diff --git a/apps/extension/src/debug/__tests__/evidence-model.test.ts b/apps/extension/src/debug/__tests__/evidence-model.test.ts new file mode 100644 index 00000000..482339ef --- /dev/null +++ b/apps/extension/src/debug/__tests__/evidence-model.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; +import { consoleSource, operationContext, operationEvidence, requestKind } from "../evidence-model"; +import { sanitizeFields } from "../observer"; +import { redactBody } from "../redact"; +import type { DebugOperation, DebugPage, DebugRecording, DebugRequest } from "../types"; + +const page = (at: number, value: string): DebugPage => ({ + at, + state: "available", + text: value, + fields: [ + { + key: "form|name:displayName", + name: "displayName", + label: "Nickname", + state: "available", + value, + }, + ], +}); +const input: DebugOperation = { + id: "d:a1", + run_id: "d", + sequence: 1, + method: "tool.fill", + source: "human", + started_at: 100, + finished_at: 200, + state: "completed", + before: page(100, "Alice"), + after: page(200, "Bob"), + request_ids: [], + console_ids: [], + truncated: false, +}; +const save: DebugOperation = { + ...input, + id: "d:a2", + method: "tool.click", + started_at: 300, + finished_at: 310, + window_end: 1810, + observation_end: 15310, + before: page(300, "Bob"), + after: page(400, "Bob"), +}; +const request: DebugRequest = { + id: "d:n1", + run_id: "d", + sequence: 2, + started_at: 320, + finished_at: 9000, + method: "POST", + url: "https://app.test/save", + resource_type: "Fetch", + state: "complete", + status: 200, + request_body: { state: "available", text: '{"displayName":"Bob"}' }, + response_body: { state: "available", text: '{"success":false,"error":"name is required"}' }, +}; +const fixture = (): DebugRecording => ({ + version: 1, + saved_at: 20000, + run: { + id: "d", + session_id: "s", + tab_id: 1, + name: "Save", + url: "https://app.test", + started_at: 0, + stopped_at: 20000, + state: "stopped", + requests: 1, + operations: 2, + errors: 0, + dropped_requests: 0, + dropped_operations: 0, + dropped_console: 0, + next_since: 2, + coverage: [], + }, + operations: [input, save], + requests: [request], + pages: [{ ...page(10000, "Alice"), navigation: "reload" }], + console: [], +}); + +describe("operation evidence", () => { + it("uses one window for request and console links, including running and delayed evidence", () => { + const record = fixture(); + record.requests.push( + { ...request, id: "late", started_at: 2500 }, + { ...request, id: "next", started_at: 3000 }, + ); + record.console = [320, 2500, 3000].map((at, index) => ({ + id: `c${index}`, + at, + last_at: at, + level: "log", + text: "log", + count: 1, + })); + record.operations.push({ ...save, id: "d:a3", started_at: 3000 }); + const context = operationContext(record, save); + expect(context.operation.request_ids).toEqual([request.id, "late"]); + expect(context.operation.console_ids).toEqual(["c0", "c1"]); + expect(context.console.map((entry) => entry.relation)).toEqual(["window", "delayed"]); + expect(context.operation.truncated).toBe(false); + record.run.dropped_requests = 1; + expect(operationContext(record, save).operation.truncated).toBe(true); + record.operations = [{ ...input, state: "running", finished_at: undefined }]; + record.saved_at = 2000; + expect(operationContext(record, record.operations[0]).operation.request_ids).toEqual([ + request.id, + ]); + }); + it("does not retain credentials embedded in server-rendered input values", () => { + for (const html of [ + '', + '', + "", + "", + ]) { + expect(redactBody(html, "text/html").text).not.toContain("priv"); + } + expect(redactBody('', "text/html").text).toContain( + "Alice", + ); + }); + it("traces actual values through a slow request and reload without inventing a response value", () => { + const evidence = operationEvidence(fixture(), save); + expect(evidence.fields[0]).toMatchObject({ + before: { value: "Alice" }, + input: { value: "Bob" }, + submitted: [{ value: "Bob", source: "d:n1 /displayName" }], + response: [], + later: { value: "Alice", source: "page:reload" }, + }); + expect(evidence.payloads).toContainEqual({ + request_id: "d:n1", + part: "response", + path: "/success", + value: "false", + }); + expect(evidence.links).toEqual([{ request_id: "d:n1", relation: "window" }]); + }); + it("marks delayed requests as tentative and cuts association at the next operation", () => { + const record = fixture(); + record.requests.push( + { ...request, id: "late", started_at: 2500 }, + { ...request, id: "next", started_at: 3000 }, + ); + record.operations.push({ ...save, id: "d:a3", started_at: 3000 }); + expect(operationEvidence(record, save).links).toEqual([ + { request_id: "d:n1", relation: "window" }, + { request_id: "late", relation: "delayed" }, + ]); + }); + it("keeps replayed and unapplied controls out of the original operation's field chain", () => { + for (const control of [ + { replay_from: "earlier" }, + { intervention: { rule_id: "r1", type: "block", state: "applied" } }, + { intervention: { rule_id: "r1", type: "modify", state: "failed" } }, + { intervention: { rule_id: "r1", type: "mock", state: "pending" } }, + ] as Partial[]) { + const record = fixture(); + record.requests = [{ ...request, ...control }]; + const evidence = operationEvidence(record, save); + expect(evidence.fields[0].submitted).toEqual([]); + expect(evidence.fields[0].response).toEqual([]); + expect(evidence.links).toHaveLength(1); + expect(evidence.gaps).toContain( + control.replay_from ? "request_replayed" : `control_${control.intervention?.type}`, + ); + } + const record = fixture(); + record.requests = [ + { ...request, intervention: { rule_id: "r1", type: "mock", state: "applied" } }, + ]; + const evidence = operationEvidence(record, save); + expect(evidence.fields[0].submitted).toHaveLength(1); + expect(evidence.gaps).toContain("control_mock"); + }); + it("uses the observed settled reload value rather than an empty loading form", () => { + const record = fixture(); + record.pages = [{ ...page(10000, ""), navigation: "reload" }]; + record.operations.push({ + ...save, + id: "d:a3", + method: "tool.reload", + started_at: 9900, + after: page(12000, "Alice"), + }); + expect(operationEvidence(record, save).fields[0].later).toMatchObject({ + value: "Alice", + at: 12000, + source: "page:reload", + }); + }); + it("keeps ambiguous, missing and truncated values explicit instead of joining by value", () => { + const record = fixture(); + record.requests = [ + { + ...request, + request_body: { state: "available", text: '{"name":"Bob"}' }, + response_body: { state: "truncated", text: '{"displayName":"Bo' }, + }, + ]; + let evidence = operationEvidence(record, save); + expect(evidence.fields[0].submitted).toEqual([]); + expect(evidence.fields[0].response).toEqual([{ state: "body_truncated", source: "d:n1" }]); + expect(evidence.gaps).toContain("body_truncated"); + record.requests[0].response_body = { + state: "available", + text: '{"a":{"displayName":"Bob"},"b":{"displayName":"Alice"}}', + }; + evidence = operationEvidence(record, save); + expect(evidence.fields[0].response).toEqual([]); + expect(evidence.payloads.filter((item) => item.part === "response")).toHaveLength(2); + }); + it("does not reconstruct fields absent from older history and reports interrupted evidence", () => { + const record = fixture(); + record.operations = [{ ...save, state: "interrupted", before: undefined, after: undefined }]; + record.run.coverage = ["initial_load_not_recorded", "interrupted_checkpoint"]; + const evidence = operationEvidence(record, record.operations[0]); + expect(evidence.fields).toEqual([]); + expect(evidence.gaps).toEqual( + expect.arrayContaining([ + "operation_interrupted", + "initial_load_not_recorded", + "fields_partial", + "after_unavailable", + ]), + ); + }); + it("classifies evidence by source and preserves unknown sources", () => { + expect(requestKind({ ...request, url: "chrome-extension://abc/file.js" })).toBe("extension"); + expect(requestKind({ ...request, method: "GET", resource_type: "Image" })).toBe("resource"); + expect(consoleSource("chrome-extension://abc/a.js")).toBe("extension"); + expect(consoleSource("https://app.test/app.js")).toBe("website"); + expect(consoleSource()).toBe("unknown"); + }); + it("redacts sensitive fields again at the storage boundary and bounds field values", () => { + const result = sanitizeFields({ + fields: [ + { key: "password", label: "Password", value: "private", state: "available" }, + { key: "bio", label: "Bio", value: "x".repeat(1000), state: "available" }, + ], + }); + expect(result.fields[0]).toMatchObject({ state: "redacted" }); + expect(result.fields[0].value).toBeUndefined(); + expect(result.fields[1].value).toHaveLength(256); + }); +}); diff --git a/apps/extension/src/debug/__tests__/journal.test.ts b/apps/extension/src/debug/__tests__/journal.test.ts new file mode 100644 index 00000000..6832a8d6 --- /dev/null +++ b/apps/extension/src/debug/__tests__/journal.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DebugArchive } from "../archive"; +import { DebugJournal, mergeRequest } from "../journal"; +import type { DebugRequest, DebugRun } from "../types"; + +const run = { id: "d", session_id: "s", state: "capturing" } as DebugRun; +const entry = (i: number): DebugRequest => ({ + id: `d:n${i}`, + run_id: "d", + sequence: i, + started_at: i, + method: "GET", + url: "https://site.test", + state: "complete", + request_body: { state: "empty" }, + response_body: { state: "available", text: "saved" }, +}); +describe("bounded evidence journal", () => { + it("coalesces updates while preserving captured bodies across memory eviction", async () => { + const retain = vi.fn(async () => {}), + failed = vi.fn(); + const journal = new DebugJournal({ retain } as unknown as DebugArchive, () => run, failed); + const source = entry(1); + journal.retain(source); + source.response_body = { state: "evicted" }; + source.sequence++; + journal.retain(source); + await journal.flush(); + expect(retain).toHaveBeenCalledTimes(1); + expect((retain.mock.calls[0] as unknown[])[1]).toEqual([ + expect.objectContaining({ response_body: { state: "available", text: "saved" } }), + ]); + expect(failed).not.toHaveBeenCalled(); + await journal.flush(); + }); + it("reports storage failures and bounds the queue while a transaction is stalled", async () => { + let release: () => void = () => {}; + const retain = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const failed = vi.fn(); + const journal = new DebugJournal({ retain } as unknown as DebugArchive, () => run, failed); + for (let i = 0; i < 500; i++) journal.retain(entry(i)); + expect(retain).toHaveBeenCalledTimes(1); + expect(failed).toHaveBeenCalledWith("evidence_write_backlog"); + retain.mockImplementation(async () => { + throw new Error("disk full"); + }); + release(); + await journal.flush(); + expect(failed).toHaveBeenCalledWith("evidence_write_failed"); + }); + it("preserves pins and complete stored bodies when merging a metadata-only read", () => { + const saved = { ...entry(1), pinned: true }; + expect( + mergeRequest(saved, { ...entry(1), response_body: { state: "available", chars: 5 } }), + ).toMatchObject({ pinned: true, response_body: { text: "saved" } }); + }); +}); diff --git a/apps/extension/src/debug/__tests__/manager.test.ts b/apps/extension/src/debug/__tests__/manager.test.ts new file mode 100644 index 00000000..b1cc481c --- /dev/null +++ b/apps/extension/src/debug/__tests__/manager.test.ts @@ -0,0 +1,923 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CdpDebuggee } from "@/browser-driver/chromium-cdp"; +import { SessionManager } from "@/session-manager/manager"; +import { handleDebug, validateDebugParams } from "@/tools/debug"; +import type { DebugArchive } from "../archive"; +import { mergeRequest } from "../journal"; +import { DEBUG_START_TIMEOUT_MS, DebugManager } from "../manager"; +import type { DebugParams, DebugRecording, DebugRequest, DebugRun } from "../types"; + +async function fixture(archive?: DebugArchive) { + let window = 100; + const sessions = new SessionManager({ + agentWindow: { + create: async () => ({ windowId: window++, initialTabIds: [7] }), + remove: async () => {}, + ensureActiveTab: async () => 7, + }, + }); + const context = await sessions.start("s1"); + context.agentCreatedTabs.add(7); + let now = 10_000; + let listener: ((source: CdpDebuggee, method: string, params: unknown) => void) | undefined; + const dispose = vi.fn(() => { + listener = undefined; + }); + const sendAttached = vi.fn( + async (_target: CdpDebuggee, method: string): Promise => + method === "Accessibility.getFullAXTree" + ? { + nodes: [ + { role: { value: "StaticText" }, name: { value: "Save failed" } }, + { role: { value: "textbox" }, name: { value: "not retained" } }, + ], + } + : method === "Network.getResponseBody" + ? { body: '{"ok":false}' } + : {}, + ); + const cdp = { + send: vi.fn(), + sendAttached: sendAttached as never, + ensureNetworkCapture: vi.fn(async () => {}), + onEvent: vi.fn((fn: typeof listener) => { + listener = fn; + return { dispose }; + }), + }; + const tabs = { + get: vi.fn( + async (id: number) => + ({ + id, + windowId: 100, + active: true, + title: "App", + url: "https://site.test", + }) as chrome.tabs.Tab, + ), + query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }] as chrome.tabs.Tab[]), + }; + const manager = new DebugManager(sessions, cdp, tabs, () => now, archive); + const event = (method: string, data: object, tabId = 7) => listener?.({ tabId }, method, data); + const request = (id: string) => + event("Network.requestWillBeSent", { + requestId: id, + request: { url: `https://site.test/${id}`, method: "GET" }, + }); + return { + manager, + sessions, + context, + cdp, + sendAttached, + tabs, + dispose, + event, + request, + advance: (ms: number) => { + now += ms; + }, + }; +} +const active: DebugManager[] = []; +afterEach(() => { + for (const manager of active.splice(0)) manager.dispose(); + vi.useRealTimers(); +}); + +describe("task-scoped debug lifecycle", () => { + it("cancels a hung Network.enable promptly, stops events, and ignores late completion", async () => { + const f = await fixture(); + active.push(f.manager); + let resolve!: () => void; + f.cdp.ensureNetworkCapture.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const abort = new AbortController(); + const starting = handleDebug( + f.sessions, + { session_id: "s1", action: "start", tab_id: 7 }, + f.manager, + f.tabs, + abort.signal, + ); + await vi.waitFor(() => expect(f.cdp.ensureNetworkCapture).toHaveBeenCalled()); + f.request("before-cancel"); + abort.abort(); + f.request("after-cancel"); + expect(await starting).toMatchObject({ code: "cancelled" }); + expect((await f.manager.read({ session_id: "s1", action: "status" })).runs).toEqual([]); + expect(f.dispose).toHaveBeenCalledTimes(1); + resolve(); + await Promise.resolve(); + expect(f.sendAttached).not.toHaveBeenCalled(); + expect((await f.manager.start("s1", 7)).state).toBe("capturing"); + }); + + it("applies one startup deadline across successive CDP waits and releases the start lock", async () => { + vi.useFakeTimers(); + const f = await fixture(); + active.push(f.manager); + f.cdp.ensureNetworkCapture.mockImplementationOnce( + () => new Promise((done) => setTimeout(done, 8000)), + ); + const graph = vi.fn(() => new Promise(() => {})); + Object.assign(f.cdp, { getFrameGraph: graph }); + const starting = expect(f.manager.start("s1", 7)).rejects.toThrow("debug start timeout"); + await vi.advanceTimersByTimeAsync(DEBUG_START_TIMEOUT_MS); + await starting; + expect(graph).toHaveBeenCalledTimes(1); + expect((await f.manager.read({ session_id: "s1", action: "status" })).runs).toEqual([]); + Object.assign(f.cdp, { getFrameGraph: undefined }); + expect((await f.manager.start("s1", 7)).state).toBe("capturing"); + }); + + it("does not install an observer after startup was cancelled while reading the frame tree", async () => { + const f = await fixture(); + active.push(f.manager); + let resolve!: (value: object) => void; + f.sendAttached.mockImplementation(async (_target, method) => + method === "Page.getFrameTree" + ? new Promise((done) => { + resolve = done; + }) + : {}, + ); + const abort = new AbortController(); + const starting = expect(f.manager.start("s1", 7, "", abort.signal)).rejects.toThrow( + "cancelled", + ); + await vi.waitFor(() => expect(resolve).toBeDefined()); + abort.abort(); + await starting; + resolve({ frameTree: { frame: { id: "root" } } }); + await Promise.resolve(); + expect(f.sendAttached.mock.calls.map(([, method]) => method)).not.toContain( + "Runtime.addBinding", + ); + expect(f.sendAttached.mock.calls.map(([, method]) => method)).not.toContain( + "Page.addScriptToEvaluateOnNewDocument", + ); + }); + + it("removes a new-document script whose identifier arrives after startup cancellation", async () => { + const f = await fixture(); + active.push(f.manager); + let resolve!: (value: object) => void; + f.sendAttached.mockImplementation(async (_target, method) => { + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } }; + if (method === "Page.addScriptToEvaluateOnNewDocument") + return new Promise((done) => { + resolve = done; + }); + return {}; + }); + const abort = new AbortController(); + const starting = expect(f.manager.start("s1", 7, "", abort.signal)).rejects.toThrow( + "cancelled", + ); + await vi.waitFor(() => expect(resolve).toBeDefined()); + abort.abort(); + await starting; + resolve({ identifier: "late-script" }); + await vi.waitFor(() => + expect(f.sendAttached).toHaveBeenCalledWith( + { tabId: 7 }, + "Page.removeScriptToEvaluateOnNewDocument", + { identifier: "late-script" }, + ), + ); + expect(f.sendAttached.mock.calls.map(([, method]) => method)).not.toContain( + "Page.createIsolatedWorld", + ); + }); + it("refuses capture outside the Agent Window and clears evidence on a manual tab move", async () => { + const f = await fixture(); + active.push(f.manager); + f.tabs.get.mockResolvedValueOnce({ id: 7, windowId: 999 } as chrome.tabs.Tab); + expect( + await handleDebug( + f.sessions, + { action: "start", session_id: "s1", tab_id: 7 }, + f.manager, + f.tabs, + ), + ).toMatchObject({ code: "permission_denied" }); + expect(f.cdp.ensureNetworkCapture).not.toHaveBeenCalled(); + await f.manager.start("s1", 7); + f.request("one"); + f.manager.releaseTab(7); + expect((await f.manager.read({ action: "status", session_id: "s1" })).runs).toEqual([]); + expect(f.dispose).toHaveBeenCalledTimes(1); + }); + + it("bounds observer pre-reads and cancels without leaving agent suppression enabled", async () => { + vi.useFakeTimers(); + const f = await fixture(); + active.push(f.manager); + const normal = f.sendAttached.getMockImplementation()!; + const expressions: string[] = []; + f.sendAttached.mockImplementation((async ( + target: CdpDebuggee, + method: string, + params?: { expression?: string }, + ) => { + if (params?.expression) expressions.push(params.expression); + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 1 }; + if (method === "Runtime.evaluate" && String(params?.expression).includes(".agent(true,")) + return new Promise(() => {}); + return normal(target, method); + }) as never); + await f.manager.start("s1", 7); + const req = { + id: "navigate", + method: "tool.navigate", + params: { session_id: "s1", tab_id: 7 }, + }; + const before = f.manager.before(req); + await vi.advanceTimersByTimeAsync(601); + const ticket = await before; + expect(ticket?.operation.method).toBe("tool.navigate"); + f.manager.after(ticket); + expect( + (await f.manager.read({ session_id: "s1", action: "status" })).runs![0].coverage, + ).toContain("manual_capture_unavailable"); + const ac = new AbortController(); + const cancelled = f.manager.before(req, ac.signal); + ac.abort(); + expect(await cancelled).toBeUndefined(); + expect(expressions.some((expression) => expression.includes(".agent(false,"))).toBe(true); + }); + + it("updates the operation cursor for delayed evidence only within its observation window", async () => { + const f = await fixture(); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + const ticket = await f.manager.before({ + id: "click", + method: "tool.click", + params: { session_id: "s1", tab_id: 7 }, + }); + f.manager.after(ticket); + const before = await f.manager.read({ session_id: "s1", action: "operations" }); + f.advance(2000); + f.request("delayed"); + const updated = await f.manager.read({ + session_id: "s1", + action: "operations", + since: before.next_since, + }); + expect(updated.operations).toHaveLength(1); + expect(updated.operations![0].request_ids).toEqual([`${run.id}:n1`]); + const detail = await f.manager.read({ + session_id: "s1", + action: "operation", + id: ticket!.operation.id, + }); + expect(detail.evidence!.links).toEqual([{ request_id: `${run.id}:n1`, relation: "delayed" }]); + f.advance(14000); + f.request("outside"); + const unchanged = await f.manager.read({ + session_id: "s1", + action: "operations", + since: updated.next_since, + }); + expect(unchanged.operations).toEqual([]); + }); + + it("adds no CDP listeners, reads or page observations before explicit start", async () => { + const f = await fixture(); + active.push(f.manager); + expect( + await f.manager.before({ + id: "r1", + method: "tool.click", + params: { session_id: "s1", selector: "#save" }, + }), + ).toBeUndefined(); + expect(f.cdp.onEvent).not.toHaveBeenCalled(); + expect(f.cdp.sendAttached).not.toHaveBeenCalled(); + expect(f.tabs.query).not.toHaveBeenCalled(); + }); + it("requires task ownership even in local mode and isolates evidence between sessions", async () => { + const f = await fixture(); + active.push(f.manager); + expect( + await handleDebug( + f.sessions, + { action: "start", session_id: "s1", tab_id: 8 }, + f.manager, + f.tabs, + ), + ).toMatchObject({ code: "permission_denied" }); + await f.manager.start("s1", 7); + f.request("one"); + await f.sessions.start("s2"); + expect((await f.manager.read({ action: "status", session_id: "s2" })).runs).toEqual([]); + const id = (await f.manager.read({ action: "requests", session_id: "s1" })).requests![0].id; + await expect(f.manager.read({ action: "request", session_id: "s2", id })).rejects.toThrow( + "not found", + ); + }); + it("records before/after page state and correlates async evidence within a bounded action window", async () => { + vi.useFakeTimers(); + const f = await fixture(); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("background-before"); + f.advance(10); + const ticket = await f.manager.before({ + id: "r1", + method: "tool.click", + params: { session_id: "s1", selector: "#save" }, + }); + f.advance(10); + f.manager.after(ticket); + f.advance(500); + f.request("save"); + f.event("Runtime.consoleAPICalled", { + type: "error", + args: [{ type: "string", value: "Save failed" }], + timestamp: 10_520, + }); + await vi.advanceTimersByTimeAsync(1500); + f.advance(1600); + f.request("background-after"); + const list = await f.manager.read({ action: "operations", session_id: "s1", run_id: run.id }); + expect(list.operations).toHaveLength(1); + expect(list.operations![0].before).toBeUndefined(); + const result = await f.manager.read({ + action: "operation", + session_id: "s1", + id: list.operations![0].id, + }); + expect(result.requests?.map((request) => request.url)).toEqual([ + "https://site.test/save", + "https://site.test/background-after", + ]); + expect(result.evidence?.links.map((link) => link.relation)).toEqual(["window", "delayed"]); + expect(result.console).toHaveLength(1); + expect(result.operation?.before?.text).toBe("Save failed"); + expect(result.operation?.after?.state).toBe("available"); + const calls = f.sendAttached.mock.calls.length; + await f.manager.read({ action: "operation", session_id: "s1", id: list.operations![0].id }); + expect(f.cdp.sendAttached).toHaveBeenCalledTimes(calls); + }); + it("closes the previous observation window when the next action begins", async () => { + vi.useFakeTimers(); + const f = await fixture(); + active.push(f.manager); + await f.manager.start("s1", 7); + const first = await f.manager.before({ + id: "r1", + method: "tool.click", + params: { session_id: "s1", selector: "#save" }, + }); + f.manager.after(first); + f.advance(100); + const second = await f.manager.before({ + id: "r2", + method: "tool.click", + params: { session_id: "s1", selector: "#save" }, + }); + f.manager.after(second); + f.advance(1); + f.request("second"); + const before = await f.manager.read({ + action: "operation", + session_id: "s1", + id: first!.operation.id, + }); + const after = await f.manager.read({ + action: "operation", + session_id: "s1", + id: second!.operation.id, + }); + expect(before.requests).toHaveLength(0); + expect(after.requests).toHaveLength(1); + expect(before.operation?.after?.state).toBe("available"); + }); + it("retains stopped evidence, releases listeners, and removes data when tabs are returned", async () => { + const f = await fixture(); + active.push(f.manager); + await f.manager.start("s1", 7); + f.request("pending"); + await f.manager.read({ action: "stop", session_id: "s1" }); + expect(f.dispose).toHaveBeenCalledTimes(1); + expect( + (await f.manager.read({ action: "requests", session_id: "s1" })).requests![0].state, + ).toBe("interrupted"); + f.context.agentCreatedTabs.delete(7); + f.manager.sync(); + expect((await f.manager.read({ action: "status", session_id: "s1" })).runs).toEqual([]); + }); + it("limits retained runs, evicting stopped captures before admitting new ones", async () => { + const f = await fixture(); + active.push(f.manager); + for (let i = 0; i < 8; i++) { + await f.manager.start("s1", 7); + await f.manager.read({ action: "stop", session_id: "s1" }); + } + expect((await f.manager.read({ action: "status", session_id: "s1" })).runs).toHaveLength(4); + f.manager.releaseSession("s1"); + expect((await f.manager.read({ action: "status", session_id: "s1" })).runs).toEqual([]); + }); + it("does not resume capture if it is stopped while starting", async () => { + const f = await fixture(); + active.push(f.manager); + let resolve!: () => void; + f.cdp.ensureNetworkCapture.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const starting = f.manager.start("s1", 7); + await expect(f.manager.start("s1", 7)).rejects.toThrow("already starting"); + await Promise.resolve(); + f.manager.stopTab(7, "requested"); + resolve(); + expect((await starting).state).toBe("stopped"); + expect(f.cdp.sendAttached).not.toHaveBeenCalled(); + }); + it("cleans up failed starts and ignores evidence from unrelated tabs and replayed console messages", async () => { + const f = await fixture(); + active.push(f.manager); + f.cdp.ensureNetworkCapture.mockRejectedValueOnce(new Error("detached")); + await expect(f.manager.start("s1", 7)).rejects.toThrow("detached"); + expect(f.dispose).toHaveBeenCalledTimes(1); + await f.manager.start("s1", 7); + f.event( + "Network.requestWillBeSent", + { requestId: "foreign", request: { url: "https://other.test" } }, + 8, + ); + f.event("Runtime.consoleAPICalled", { + type: "error", + args: [{ type: "string", value: "old" }], + timestamp: 1, + }); + const result = await f.manager.read({ action: "status", session_id: "s1" }); + expect(result.runs![0]).toMatchObject({ requests: 0, errors: 0 }); + }); + it("rejects invalid limits and selectors before touching browser state", () => { + for (const params of [ + { action: "request" }, + { action: "compare" }, + { action: "requests", limit: 101 }, + { action: "requests", since: -1 }, + { action: "request", id: "n1", part: "headers", pointer: "/x" }, + ]) + expect(validateDebugParams({ session_id: "s1", ...params } as DebugParams)).toBeTruthy(); + }); +}); + +class MemoryArchive implements DebugArchive { + records = new Map(); + put = vi.fn(async (record: DebugRecording) => { + this.records.set(record.run.id, structuredClone(record)); + }); + async list() { + return [...this.records.values()].map((record) => structuredClone(record.run)); + } + async get(id: string) { + const record = this.records.get(id); + return record && structuredClone(record); + } + async delete(id: string) { + this.records.delete(id); + } +} + +describe("persistent debugging records", () => { + it("uses the archive's complete request reader after a metadata-only history read", async () => { + const archive = new MemoryArchive(); + const f = await fixture(archive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("legacy"); + f.event("Network.responseReceived", { + requestId: "legacy", + response: { + status: 200, + mimeType: "application/json", + headers: { "x-source": "legacy" }, + timing: { receiveHeadersEnd: 42 }, + }, + }); + f.event("Network.loadingFinished", { requestId: "legacy" }); + for (let i = 0; i < 6; i++) await Promise.resolve(); + await f.manager.read({ session_id: "s1", action: "stop" }); + const source = await archive.get(run.id); + expect(source?.requests[0].response_body.text).toContain('"ok":false'); + const reader = new DebugManager(f.sessions, f.cdp, f.tabs, Date.now, { + ...archive, + put: (record) => archive.put(record), + list: () => archive.list(), + delete: (id) => archive.delete(id), + get: async (id, bodies = true) => { + const record = await archive.get(id); + if (record && !bodies) + for (const request of record.requests) { + delete request.response_body.text; + delete request.request_headers; + delete request.response_headers; + delete request.timing; + } + return record; + }, + request: async (id, requestId) => + (await archive.get(id))?.requests.find((request) => request.id === requestId), + }); + active.push(reader); + for (const part of ["response", "headers", "timing"] as const) { + const result = await reader.readHistory({ + session_id: "", + run_id: run.id, + action: "request", + id: `${run.id}:n1`, + part, + }); + if (part === "response") expect(result.request?.response_body.text).toContain('"ok":false'); + if (part === "headers") expect(result.request?.response_headers?.["x-source"]).toBe("legacy"); + if (part === "timing") expect(result.request?.timing?.receiveHeadersEnd).toBe(42); + } + }); + + it("reads saved captures beyond the live cache only for their original task instance", async () => { + const archive = new MemoryArchive(); + const f = await fixture(archive); + active.push(f.manager); + let first = ""; + for (let i = 0; i < 6; i++) { + const run = await f.manager.start("s1", 7); + first ||= run.id; + f.request(`request-${i}`); + await f.manager.read({ session_id: "s1", action: "stop" }); + f.advance(1); + } + expect((await f.manager.read({ session_id: "s1", action: "status" })).runs).toHaveLength(6); + expect( + (await f.manager.read({ session_id: "s1", run_id: first, action: "export" })).recording?.run + .id, + ).toBe(first); + expect( + (await f.manager.read({ session_id: "s1", id: `${first}:n1`, action: "request" })).request + ?.id, + ).toBe(`${first}:n1`); + await expect( + f.manager.read({ session_id: "s1", run_id: first, tab_id: 8, action: "export" }), + ).rejects.toThrow("not found"); + await f.sessions.start("s2"); + await expect( + f.manager.read({ session_id: "s2", run_id: first, action: "export" }), + ).rejects.toThrow("not found"); + await f.sessions.stop("s1"); + const reused = await f.sessions.start("s1"); + reused.agentCreatedTabs.add(7); + expect((await f.manager.read({ session_id: "s1", action: "status" })).runs).toEqual([]); + await expect( + f.manager.read({ session_id: "s1", run_id: first, action: "export" }), + ).rejects.toThrow("not found"); + expect( + (await f.manager.readHistory({ session_id: "", run_id: first, action: "export" })).recording + ?.run.id, + ).toBe(first); + }); + + it("revokes saved capture access when its tab is released", async () => { + const f = await fixture(new MemoryArchive()); + active.push(f.manager); + let first = ""; + for (let i = 0; i < 5; i++) { + const run = await f.manager.start("s1", 7); + first ||= run.id; + await f.manager.read({ session_id: "s1", action: "stop" }); + } + f.manager.releaseTab(7); + expect((await f.manager.read({ session_id: "s1", action: "status" })).runs).toEqual([]); + await expect( + f.manager.read({ session_id: "s1", run_id: first, action: "export" }), + ).rejects.toThrow("not found"); + }); + + it("journals early bodies before hot-cache eviction, survives stop and preserves task isolation", async () => { + const archive = new MemoryArchive(); + const requests = new Map(); + const journalArchive: DebugArchive = { + list: () => archive.list(), + delete: (id) => archive.delete(id), + put: (record) => archive.put(record), + retain: async (_run: DebugRun, entries: DebugRequest[]) => { + for (const entry of entries) + requests.set(entry.id, structuredClone(mergeRequest(requests.get(entry.id), entry))); + }, + request: async (_run, id) => structuredClone(requests.get(id)), + get: async (id) => { + const record = await archive.get(id); + return ( + record && { + ...record, + run: { + ...record.run, + storage: { requests: requests.size, bytes: 1, pins: 0, dropped: 0 }, + }, + requests: structuredClone([...requests.values()]), + } + ); + }, + }; + const f = await fixture(journalArchive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + const ticket = await f.manager.before({ + id: "save", + method: "tool.click", + params: { session_id: "s1", tab_id: 7 }, + }); + expect(ticket).toBeDefined(); + for (let i = 0; i < 230; i++) { + f.event("Network.requestWillBeSent", { + requestId: `post-${i}`, + type: "Fetch", + request: { + url: "https://site.test/save", + method: "POST", + hasPostData: true, + headers: { "content-type": "text/plain" }, + postData: "x".repeat(8192), + }, + }); + f.event("Network.loadingFailed", { requestId: `post-${i}`, errorText: "test failure" }); + if (i % 20 === 0) await Promise.resolve(); + } + f.manager.after(ticket); + const first = `${run.id}:n1`; + const body = await f.manager.read({ + session_id: "s1", + action: "request", + id: first, + part: "request", + max_chars: 10000, + }); + expect(body.request!.request_body.text).toHaveLength(8192); + expect(body.run!.dropped_requests).toBe(0); + await f.manager.read({ session_id: "s1", action: "stop" }); + const saved = await f.manager.read({ session_id: "s1", action: "export" }); + expect(saved.recording!.requests).toHaveLength(230); + expect(saved.recording!.operations[0].request_ids).toContain(first); + expect(saved.recording!.operations[0].truncated).toBe(false); + for (const reader of ["read", "readHistory"] as const) { + const detail = await f.manager[reader]({ + session_id: "s1", + run_id: run.id, + action: "operation", + id: ticket!.operation.id, + }); + expect(detail.operation!.request_ids).toEqual( + detail.evidence!.links.map((link) => link.request_id), + ); + expect(detail.operation!.truncated).toBe(false); + const operations = await f.manager[reader]({ + session_id: "s1", + run_id: run.id, + action: "operations", + }); + expect(operations.operations![0].request_ids).toContain(first); + expect(operations.operations![0].truncated).toBe(false); + } + expect(saved.run!.coverage).not.toContain("evidence_write_backlog"); + await f.sessions.start("s2"); + await expect( + f.manager.read({ session_id: "s2", action: "request", id: first }), + ).rejects.toThrow("not found"); + f.manager.releaseSession("s1"); + const restored = await f.manager.readHistory({ + session_id: "", + run_id: run.id, + action: "request", + id: first, + part: "request", + }); + expect(restored.request!.request_body.text).toHaveLength(4096); + }); + + it.each([ + "read", + "readHistory", + ] as const)("%s reports a failed detail read in the current response", async (reader) => { + const archive = new MemoryArchive(); + const f = await fixture({ + list: () => archive.list(), + put: (record) => archive.put(record), + delete: (id) => archive.delete(id), + get: (id) => archive.get(id), + retain: async () => {}, + request: async () => { + throw new Error("read failed"); + }, + }); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("live"); + const result = await f.manager[reader]({ + action: "request", + session_id: "s1", + run_id: run.id, + id: `${run.id}:n1`, + }); + expect(result.request!.url).toBe("https://site.test/live"); + expect(result.run!.coverage).toContain("evidence_read_failed"); + }); + + it("rechecks task ownership when a retained detail read finishes", async () => { + const archive = new MemoryArchive(); + let finish: (entry: DebugRequest | undefined) => void = () => {}; + const requested = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const f = await fixture({ + list: () => archive.list(), + put: (record) => archive.put(record), + delete: (id) => archive.delete(id), + get: (id) => archive.get(id), + request: requested, + }); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.request("owned"); + const reading = f.manager.read({ + session_id: "s1", + run_id: run.id, + action: "request", + id: `${run.id}:n1`, + }); + const rejected = expect(reading).rejects.toThrow("session not found"); + await vi.waitFor(() => expect(requested).toHaveBeenCalled()); + f.context.agentCreatedTabs.delete(7); + finish(undefined); + await rejected; + }); + + it("reports failed persistent reads and still exposes live evidence", async () => { + const archive = new MemoryArchive(); + const failure = async (): Promise => { + throw new Error("storage unavailable"); + }; + const f = await fixture({ + list: () => archive.list(), + put: (record) => archive.put(record), + delete: (id) => archive.delete(id), + get: failure, + query: failure, + request: failure, + retain: failure, + }); + active.push(f.manager); + await f.manager.start("s1", 7); + f.request("live"); + const list = await f.manager.read({ session_id: "s1", action: "requests" }); + expect(list.requests).toHaveLength(1); + expect(list.run!.coverage).toEqual( + expect.arrayContaining(["evidence_write_failed", "evidence_read_failed"]), + ); + const read = await f.manager.read({ + session_id: "s1", + action: "request", + id: list.requests![0].id, + }); + expect(read.request!.url).toBe("https://site.test/live"); + }); + it("retains redacted evidence after task release and reads it without attaching to the tab", async () => { + const archive = new MemoryArchive(); + const f = await fixture(archive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.event("Network.requestWillBeSent", { + requestId: "post", + request: { + url: "https://site.test/save?token=private", + method: "POST", + hasPostData: true, + headers: { "Content-Type": "application/json", Authorization: "Bearer private" }, + postData: '{"name":"Alice","password":"private"}', + }, + }); + f.event("Runtime.consoleAPICalled", { + type: "error", + args: [{ type: "string", value: "Save failed" }], + timestamp: 10000, + }); + f.manager.releaseSession("s1"); + await vi.waitFor(() => expect(archive.records.get(run.id)?.run.state).toBe("stopped")); + expect((await f.manager.read({ action: "status", session_id: "s1" })).runs).toEqual([]); + const restored = new DebugManager(f.sessions, f.cdp, f.tabs, () => 11000, archive); + active.push(restored); + const calls = f.sendAttached.mock.calls.length; + const result = await restored.readHistory({ action: "export", run_id: run.id, session_id: "" }); + expect(result.recording?.run.stop_reason).toBe("session_ended"); + expect(result.recording?.requests[0].state).toBe("interrupted"); + expect(result.recording?.console[0].text).toBe("Save failed"); + expect(result.recording?.pages[0].title).toBe("App"); + expect(JSON.stringify(result)).not.toContain("private"); + expect(f.sendAttached).toHaveBeenCalledTimes(calls); + const request = await restored.readHistory({ + action: "request", + run_id: run.id, + session_id: "", + id: result.recording!.requests[0].id, + part: "request", + pointer: "/name", + }); + expect(request.request?.request_body.text).toBe('"Alice"'); + await restored.deleteHistory(run.id); + expect((await restored.history()).runs).toEqual([]); + }); + + it("coalesces active checkpoints and flushes the final record before stop returns", async () => { + vi.useFakeTimers(); + const archive = new MemoryArchive(); + const f = await fixture(archive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + expect(archive.put).toHaveBeenCalledTimes(1); + for (let i = 0; i < 100; i++) f.request(`request-${i}`); + expect(archive.put).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2000); + expect(archive.put).toHaveBeenCalledTimes(2); + await expect(f.manager.deleteHistory(run.id)).rejects.toThrow("stop capture"); + await f.manager.read({ action: "stop", session_id: "s1" }); + expect(archive.records.get(run.id)?.run.state).toBe("stopped"); + expect(archive.records.get(run.id)?.requests).toHaveLength(100); + await f.manager.deleteHistory(run.id); + await vi.advanceTimersByTimeAsync(3000); + expect(archive.records.has(run.id)).toBe(false); + }); + + it("shows storage failures without losing access to live export, and retries on stop", async () => { + const archive = new MemoryArchive(); + archive.put.mockRejectedValueOnce(new Error("quota exceeded")); + const f = await fixture(archive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + expect(run.storage_error).toBe("quota exceeded"); + f.request("save"); + expect( + (await f.manager.read({ action: "export", session_id: "s1" })).recording?.requests, + ).toHaveLength(1); + const result = await f.manager.read({ action: "stop", session_id: "s1" }); + expect(result.run?.storage_error).toBeUndefined(); + expect(archive.records.get(run.id)?.requests).toHaveLength(1); + }); + + it("flushes release after an in-flight checkpoint and never resurrects a deleted record", async () => { + vi.useFakeTimers(); + const archive = new MemoryArchive(); + const f = await fixture(archive); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + let finish!: () => void; + archive.put.mockImplementationOnce( + (record) => + new Promise((resolve) => { + finish = () => { + archive.records.set(record.run.id, structuredClone(record)); + resolve(); + }; + }), + ); + f.request("first"); + await vi.advanceTimersByTimeAsync(2000); + f.request("last"); + f.manager.releaseSession("s1"); + finish(); + await vi.waitFor(() => { + const saved = archive.records.get(run.id); + expect(saved?.run.state).toBe("stopped"); + expect(saved?.requests).toHaveLength(2); + }); + await f.manager.deleteHistory(run.id); + await vi.advanceTimersByTimeAsync(4000); + expect((await f.manager.history()).runs).toEqual([]); + expect(archive.records.has(run.id)).toBe(false); + }); + + it("does not expose a released capture when a short session ID is reused", async () => { + const f = await fixture(new MemoryArchive()); + active.push(f.manager); + const run = await f.manager.start("s1", 7); + f.manager.releaseSession("s1"); + await expect( + f.manager.read({ action: "export", session_id: "s1", run_id: run.id }), + ).rejects.toThrow("not found"); + expect((await f.manager.history()).runs.some((item) => item.id === run.id)).toBe(true); + }); +}); diff --git a/apps/extension/src/debug/__tests__/network-control.browser.test.ts b/apps/extension/src/debug/__tests__/network-control.browser.test.ts new file mode 100644 index 00000000..d5a61767 --- /dev/null +++ b/apps/extension/src/debug/__tests__/network-control.browser.test.ts @@ -0,0 +1,367 @@ +// @vitest-environment node +// Opt-in isolated Chrome and fixture server; never touches the user's browser. + +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { type CdpDebuggee, type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import { SessionManager } from "@/session-manager/manager"; +import { DebugManager } from "../manager"; +import type { DebugRuleSpec } from "../types"; + +type Send = >( + method: string, + params?: object, + sessionId?: string, +) => Promise; +type Event = { sessionId?: string; method: string; params?: Record }; +type Listener = (source: CdpDebuggee, method: string, params: unknown) => void; +describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser network controls", () => { + it("modifies actual traffic, mocks/blocks without server hits, links replay once and cleans up pending rules", async () => { + const hits: { url: string; body: string; header?: string }[] = []; + const server = createServer((req, res) => { + if (req.url === "/") { + res.setHeader("Content-Type", "text/html"); + res.end( + 'Network controls

Profile

Ready

', + ); + return; + } + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + hits.push({ url: req.url!, body, header: req.headers["x-test"] as string | undefined }); + res.setHeader("Content-Type", "application/json"); + // Echo JSON tokens verbatim so the fixture itself does not round 64-bit IDs. + res.end(`{"source":"server","body":${body || "null"}}`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + let onEvent: ((event: Event) => void) | undefined; + await withChrome( + { + executable: process.env.BSK_CLICK_CHROME, + deviceScale: 1, + zoom: 1, + startupTimeout: 30000, + onEvent: (event: Event) => onEvent?.(event), + }, + async (send: Send) => { + const { targetId } = await send<{ targetId: string }>("Target.createTarget", { + url: "about:blank", + }); + const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId, + flatten: true, + }); + const listeners = new Set(); + const children = new Set(); + onEvent = (event) => { + if ( + event.sessionId !== sessionId && + (!event.sessionId || !children.has(event.sessionId)) + ) + return; + if (event.method === "Target.attachedToTarget") + children.add(event.params?.sessionId as string); + for (const listener of listeners) + listener( + { + tabId: 7, + ...(event.sessionId !== sessionId ? { sessionId: event.sessionId } : {}), + }, + event.method, + event.params, + ); + }; + const api: CdpDebuggerApi = { + attach: async () => {}, + detach: async () => { + await send("Target.detachFromTarget", { sessionId }); + }, + sendCommand: (target, method, params) => + send(method, params, target.sessionId ?? sessionId), + onEvent: { + addListener: (fn: Listener) => listeners.add(fn), + removeListener: (fn: Listener) => listeners.delete(fn), + } as unknown as CdpDebuggerApi["onEvent"], + onDetach: { + addListener: () => {}, + removeListener: () => {}, + } as unknown as CdpDebuggerApi["onDetach"], + }; + const cdp = new ChromiumCdp(api); + const sessions = new SessionManager({ + agentWindow: { + create: async () => ({ windowId: 100, initialTabIds: [7] }), + remove: async () => {}, + ensureActiveTab: async () => 7, + }, + }); + await sessions.start("network-controls"); + const tab = { + id: 7, + windowId: 100, + active: true, + url, + title: "Network controls", + } as chrome.tabs.Tab; + const debug = new DebugManager(sessions, cdp, { + get: async () => tab, + query: async () => [tab], + }); + const evaluate = async (expression: string, targetSession = sessionId) => { + const reply = await send<{ result: { value?: unknown }; exceptionDetails?: unknown }>( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true }, + targetSession, + ); + expect(reply.exceptionDetails, JSON.stringify(reply)).toBeUndefined(); + return reply.result.value; + }; + const read = (action: "requests" | "rules" | "stop" | "export") => + debug.read({ session_id: "network-controls", action, limit: 100 }); + const add = (rule: DebugRuleSpec) => + debug.read({ session_id: "network-controls", action: "rule_add", rule }); + const fetch = (path: string, body?: object) => + evaluate( + `fetch(${JSON.stringify(url + path)},${JSON.stringify(body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {})}).then(async r=>({status:r.status,data:await r.json()})).catch(()=>({error:true}))`, + ); + try { + await debug.start("network-controls", 7, "Request control verification"); + await cdp.send(7, "Page.navigate", { url }); + await vi.waitFor(async () => + expect(await evaluate("document.title")).toBe("Network controls"), + ); + await add({ + name: "Correct nickname field", + match: { url: `${url}/save`, method: "POST" }, + effect: { + type: "modify", + headers: { "x-test": "changed" }, + json: { rename: { displayName: "name" } }, + }, + }); + expect(await fetch("/save", { displayName: "张三", keep: 1 })).toMatchObject({ + data: { body: { name: "张三", keep: 1 } }, + }); + expect(hits.find((hit) => hit.url === "/save")).toMatchObject({ + header: "changed", + body: '{"keep":1,"name":"张三"}', + }); + await vi.waitFor(async () => + expect( + (await read("requests")).requests?.find((item) => item.url.endsWith("/save")) + ?.response_body.state, + ).toBe("available"), + ); + const saved = (await read("export")).recording!.requests.find((item) => + item.url.endsWith("/save"), + )!; + expect(saved.intervention).toMatchObject({ type: "modify", state: "applied" }); + expect(JSON.parse(saved.request_body.text!)).toEqual({ keep: 1, name: "张三" }); + expect(saved.request_headers?.["x-test"]).toBe("changed"); + expect((await read("rules")).rules?.[0].state).toBe("exhausted"); + expect(await fetch("/save", { displayName: "unchanged" })).toMatchObject({ + data: { body: { displayName: "unchanged" } }, + }); + + await add({ + name: "Mock backend failure", + match: { url: `${url}/mock` }, + effect: { + type: "mock", + status: 503, + body: '{"source":"mock","message":"稍后重试"}', + delay_ms: 100, + }, + }); + expect(await fetch("/mock")).toMatchObject({ + status: 503, + data: { source: "mock", message: "稍后重试" }, + }); + expect(hits.some((hit) => hit.url === "/mock")).toBe(false); + await add({ match: { url: `${url}/blocked` }, effect: { type: "block" } }); + expect(await fetch("/blocked")).toEqual({ error: true }); + expect(hits.some((hit) => hit.url === "/blocked")).toBe(false); + + const beforeReplay = hits.filter((hit) => hit.url === "/save").length; + const replayParams = { + session_id: "network-controls", + action: "replay" as const, + id: saved.id, + replay: { key: "one-attempt", body: '{"name":"Replay"}' }, + }; + const [first, again] = await Promise.all([ + debug.read(replayParams), + debug.read(replayParams), + ]); + expect(first.replay).toMatchObject({ state: "complete", source_request_id: saved.id }); + expect(again.replay?.id).toBe(first.replay?.id); + expect(hits.filter((hit) => hit.url === "/save")).toHaveLength(beforeReplay + 1); + await vi.waitFor(async () => { + const replayed = (await read("requests")).requests?.find( + (item) => item.replay_from === saved.id, + ); + expect(replayed?.id).toBe(first.replay?.request_id); + expect(replayed?.response_body.state).toBe("available"); + }); + + const original = '{"orderId":9007199254740993,"action":"cancel"}'; + await evaluate( + `fetch('${url}/precision',{method:'POST',headers:{'content-type':'application/json'},body:${JSON.stringify(original)}}).then(r=>r.text())`, + ); + await vi.waitFor(async () => + expect( + (await read("requests")).requests?.find((item) => item.url.endsWith("/precision")) + ?.response_body.state, + ).toBe("available"), + ); + const precise = (await read("export")).recording!.requests.find((item) => + item.url.endsWith("/precision"), + )!; + expect(precise.request_body).toMatchObject({ text: original, replay_safe: true }); + expect(precise.response_body.text).toContain("9007199254740993"); + await debug.read({ + session_id: "network-controls", + action: "replay", + id: precise.id, + replay: { key: "precision" }, + }); + expect(hits.filter((hit) => hit.url === "/precision").map((hit) => hit.body)).toEqual([ + original, + original, + ]); + + await add({ + match: { url: `${url}/precision-edit` }, + effect: { type: "modify", json: { set: { action: "inspect" } } }, + }); + await evaluate( + `fetch('${url}/precision-edit',{method:'POST',headers:{'content-type':'application/json'},body:${JSON.stringify(original)}}).then(r=>r.text())`, + ); + expect(hits.find((hit) => hit.url === "/precision-edit")?.body).toBe( + '{"orderId":9007199254740993,"action":"inspect"}', + ); + + const longPath = `/long?q=${"x".repeat(2200)}&mode=dry-run`; + await fetch(longPath); + await vi.waitFor(async () => + expect( + (await read("requests")).requests?.find((item) => + item.url.startsWith(`${url}/long?`), + )?.response_body.state, + ).toBe("available"), + ); + const longRequest = (await read("requests")).requests!.find((item) => + item.url.startsWith(`${url}/long?`), + )!; + const longReplay = { + session_id: "network-controls", + action: "replay" as const, + id: longRequest.id, + replay: { key: "long-url" }, + }; + await expect(debug.read(longReplay)).rejects.toThrow("replacement URL"); + expect(hits.filter((hit) => hit.url.startsWith("/long?"))).toHaveLength(1); + await debug.read({ + ...longReplay, + replay: { ...longReplay.replay, url: url + longPath }, + }); + expect( + hits.filter((hit) => hit.url.startsWith("/long?")).map((hit) => hit.url), + ).toEqual([longPath, longPath]); + + // Existing child targets receive the same bounded rule configuration. + await evaluate( + `const frame=document.createElement('iframe');frame.src=${JSON.stringify(url.replace("127.0.0.1", "localhost") + "/frame")};document.body.append(frame);0`, + ); + await vi.waitFor(() => expect(children.size).toBeGreaterThan(0)); + const childSession = [...children][0]; + await vi.waitFor(async () => + expect(await evaluate("location.hostname", childSession)).toBe("localhost"), + ); + const frameUrl = url.replace("127.0.0.1", "localhost") + "/frame-api"; + await add({ + match: { url: frameUrl }, + effect: { type: "mock", status: 200, body: '{"source":"iframe-mock"}' }, + }); + expect( + await evaluate(`fetch(${JSON.stringify(frameUrl)}).then(r=>r.json())`, childSession), + ).toMatchObject({ source: "iframe-mock" }); + expect(hits.some((hit) => hit.url === "/frame-api")).toBe(false); + + // A second target has no controls even when its URL matches an active rule. + await add({ + match: { url: `${url}/isolated` }, + effect: { type: "mock", status: 200, body: '{"source":"mock"}' }, + times: 0, + }); + const second = await send<{ targetId: string }>("Target.createTarget", { url }); + const other = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId: second.targetId, + flatten: true, + }); + await vi.waitFor(async () => + expect(await evaluate("document.title", other.sessionId)).toBe("Network controls"), + ); + expect( + await evaluate(`fetch('${url}/isolated').then(r=>r.json())`, other.sessionId), + ).toMatchObject({ source: "server" }); + await send("Target.closeTarget", { targetId: second.targetId }); + expect(await fetch("/isolated")).toMatchObject({ data: { source: "mock" } }); + + await add({ + name: "Pending mock", + match: { url: `${url}/pending` }, + effect: { type: "mock", status: 200, body: '{"source":"mock"}', delay_ms: 10000 }, + }); + await evaluate( + `globalThis.pendingResult=null;fetch('${url}/pending').then(()=>pendingResult='fulfilled').catch(()=>pendingResult='aborted');0`, + ); + await vi.waitFor(async () => expect((await read("rules")).rules?.at(-1)?.hits).toBe(1)); + if (process.env.BSK_CONTROL_FIXTURE_OUT) + await writeFile( + process.env.BSK_CONTROL_FIXTURE_OUT, + JSON.stringify((await read("export")).recording), + ); + await read("stop"); + await vi.waitFor(async () => expect(await evaluate("pendingResult")).toBe("aborted")); + expect(hits.some((hit) => hit.url === "/pending")).toBe(false); + expect( + (await read("export")).recording!.requests.find((item) => + item.url.endsWith("/pending"), + )?.intervention?.state, + ).toBe("cancelled"); + expect(await fetch("/isolated")).toMatchObject({ data: { source: "server" } }); + const archive = (await read("export")).recording!; + expect(archive.rules?.some((rule) => rule.state === "enabled")).toBe(false); + expect(archive.requests.some((item) => item.intervention?.type === "mock")).toBe(true); + expect(archive.requests.some((item) => item.intervention?.type === "block")).toBe(true); + await expect(debug.read(replayParams)).rejects.toThrow("active capture"); + } finally { + debug.dispose(); + await cdp.detachAll(); + } + }, + ); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, 45000); +}); diff --git a/apps/extension/src/debug/__tests__/network-control.test.ts b/apps/extension/src/debug/__tests__/network-control.test.ts new file mode 100644 index 00000000..3f95f53d --- /dev/null +++ b/apps/extension/src/debug/__tests__/network-control.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CdpDebuggee } from "@/browser-driver/chromium-cdp"; +import type { DebugCdp } from "../manager"; +import { DebugNetworkControl } from "../network-control"; +import { DebugNetworkStore } from "../network-store"; +import type { DebugRuleSpec } from "../types"; + +function fixture() { + let owned = true, + sequence = 0; + const sendAttached = vi.fn(async (_target: CdpDebuggee, method: string) => { + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 1 }; + return {}; + }); + const cdp = { sendAttached, send: vi.fn(), ensureNetworkCapture: vi.fn() } as unknown as DebugCdp; + const network = new DebugNetworkStore("d1", cdp, () => ++sequence); + const controls = new DebugNetworkControl( + "d1", + 7, + cdp, + network, + () => { + sequence++; + }, + () => owned, + ); + const event = (id: string, body = '{"name":"Alice"}', target = { tabId: 7 }) => { + network.onEvent(target, "Network.requestWillBeSent", { + requestId: id, + type: "Fetch", + request: { + url: "https://site.test/save", + method: "POST", + headers: { "content-type": "application/json" }, + postData: body, + }, + }); + controls.onEvent(target, "Fetch.requestPaused", { + requestId: `fetch-${id}`, + networkId: id, + resourceType: "Fetch", + request: { + url: "https://site.test/save", + method: "POST", + headers: { "content-type": "application/json" }, + postData: body, + }, + }); + }; + return { + controls, + network, + sendAttached, + event, + release: () => { + owned = false; + }, + }; +} +const mock: DebugRuleSpec = { + match: { url: "https://site.test/save", method: "POST" }, + effect: { type: "mock", status: 200, body: '{"name":"Mock"}', delay_ms: 10000 }, +}; +afterEach(() => vi.useRealTimers()); +describe("local request control lifecycle", () => { + it("has no Fetch subscription until a rule exists and attaches rules to child targets", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + expect(f.sendAttached).not.toHaveBeenCalled(); + await f.controls.add({ ...mock, effect: { type: "block" }, times: 0 }); + await f.controls.target({ tabId: 7, sessionId: "child" }); + expect(f.sendAttached).toHaveBeenCalledWith( + { tabId: 7, sessionId: "child" }, + "Fetch.enable", + expect.anything(), + ); + await f.controls.stop(); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.disable"); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7, sessionId: "child" }, "Fetch.disable"); + }); + it("does not widen default Fetch/XHR rules when another rule intercepts Documents", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add({ ...mock, effect: { type: "mock", status: 200, body: "{}" }, times: 0 }); + await f.controls.add({ + match: { ...mock.match, resource_type: "Document" }, + effect: { type: "block" }, + }); + f.controls.onEvent({ tabId: 7 }, "Fetch.requestPaused", { + requestId: "doc", + networkId: "doc", + resourceType: "Document", + request: { url: mock.match.url, method: "POST", headers: {} }, + }); + await vi.waitFor(() => + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.failRequest", { + requestId: "doc", + errorReason: "BlockedByClient", + }), + ); + expect(f.controls.list().map((rule) => rule.hits)).toEqual([0, 1]); + await f.controls.stop(); + }); + it("reserves a one-shot rule synchronously so concurrent requests do not both consume it", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add({ ...mock, effect: { type: "block" } }); + f.event("one"); + f.event("two"); + await vi.waitFor(() => + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.failRequest", { + requestId: "fetch-one", + errorReason: "BlockedByClient", + }), + ); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.continueRequest", { + requestId: "fetch-two", + }); + expect(f.controls.list()[0]).toMatchObject({ state: "exhausted", hits: 1 }); + await f.controls.stop(); + }); + it("cancels a delayed mock on disable and does not fall through to the real server", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add(mock); + f.event("one"); + await Promise.resolve(); + await f.controls.update("d1:r1", "rule_disable"); + expect(f.sendAttached).not.toHaveBeenCalledWith( + expect.anything(), + "Fetch.fulfillRequest", + expect.anything(), + ); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.failRequest", { + requestId: "fetch-one", + errorReason: "Aborted", + }); + expect(f.network.list()[0].intervention).toMatchObject({ type: "mock", state: "cancelled" }); + await f.controls.stop(); + }); + it("aborts on invalid JSON transformations instead of sending a partially edited request", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add({ + match: mock.match, + effect: { type: "modify", json: { rename: { missing: "name" } } }, + }); + f.event("one"); + await vi.waitFor(() => expect(f.controls.list()[0].failures).toBe(1)); + expect(f.sendAttached).not.toHaveBeenCalledWith( + expect.anything(), + "Fetch.continueRequest", + expect.anything(), + ); + expect(f.network.list()[0].intervention?.state).toBe("failed"); + await f.controls.stop(); + }); + it("disables partially enabled interception after a failed configuration", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + f.sendAttached.mockRejectedValueOnce(new Error("failed")); + await expect(f.controls.add(mock)).rejects.toThrow("configuration failed"); + expect(f.controls.list()[0].state).toBe("disabled"); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.disable"); + await f.controls.stop(); + }); + it("rolls back a cancelled rule setup and rejects writes after task ownership is lost", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + const ac = new AbortController(); + let release: () => void = () => {}; + f.sendAttached.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({}); + }), + ); + const adding = f.controls.add(mock, ac.signal); + await vi.waitFor(() => expect(f.sendAttached).toHaveBeenCalled()); + ac.abort(); + release(); + await expect(adding).rejects.toThrow("cancelled"); + expect(f.controls.list()[0].state).toBe("disabled"); + f.release(); + await expect(f.controls.add(mock)).rejects.toThrow("task-owned"); + await f.controls.stop(); + }); + it("keeps executable secrets out of request annotations and saved rule definitions", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add({ + match: mock.match, + effect: { + type: "modify", + headers: { Authorization: "Bearer private-value" }, + json: { set: { password: "hidden-value", name: "Bob" } }, + }, + }); + f.event("one"); + await vi.waitFor(() => expect(f.network.list()[0].intervention?.state).toBe("applied")); + const saved = JSON.stringify({ rules: f.controls.list(), requests: f.network.list() }); + expect(saved).not.toContain("private-value"); + expect(saved).not.toContain("hidden-value"); + const command = f.sendAttached.mock.calls.find((call) => call[1] === "Fetch.continueRequest"); + expect(JSON.stringify(command)).toContain("private-value"); + await f.controls.stop(); + }); + it("rolls back re-enabling a rule if the agent action is cancelled during setup", async () => { + const f = fixture(); + await f.controls.target({ tabId: 7 }); + await f.controls.add(mock); + await f.controls.update("d1:r1", "rule_disable"); + f.sendAttached.mockClear(); + const ac = new AbortController(); + let release: () => void = () => {}; + f.sendAttached.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({}); + }), + ); + const enabling = f.controls.update("d1:r1", "rule_enable", ac.signal); + await vi.waitFor(() => expect(f.sendAttached).toHaveBeenCalled()); + ac.abort(); + release(); + await expect(enabling).rejects.toThrow("cancelled"); + expect(f.controls.list()[0].state).toBe("disabled"); + expect(f.sendAttached).toHaveBeenCalledWith({ tabId: 7 }, "Fetch.disable"); + await f.controls.stop(); + }); +}); diff --git a/apps/extension/src/debug/__tests__/network-retention.test.ts b/apps/extension/src/debug/__tests__/network-retention.test.ts new file mode 100644 index 00000000..205dc50b --- /dev/null +++ b/apps/extension/src/debug/__tests__/network-retention.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { mergeRequest } from "../journal"; +import { DebugNetworkStore, MAX_INFLIGHT, MAX_REQUESTS } from "../network-store"; +import type { DebugRequest } from "../types"; + +function fixture(send = vi.fn(async () => ({ body: '{"saved":false}' }))) { + const saved = new Map(); + let sequence = 0; + const store = new DebugNetworkStore( + "d1", + { send: send as never }, + () => ++sequence, + Date.now, + (entry) => saved.set(entry.id, structuredClone(mergeRequest(saved.get(entry.id), entry))), + ); + const event = (method: string, data: object) => + store.onEvent({ tabId: 7 }, `Network.${method}`, data); + const start = (id: string) => + event("requestWillBeSent", { + requestId: id, + timestamp: 1, + type: "Fetch", + request: { url: `https://site.test/${id}`, method: "GET" }, + }); + const finish = (id: string) => { + event("responseReceived", { + requestId: id, + hasExtraInfo: false, + response: { status: 503, mimeType: "application/json" }, + }); + event("loadingFinished", { requestId: id, timestamp: 20 }); + }; + return { store, saved, event, start, finish, send }; +} +const settle = async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); +}; + +describe("in-flight evidence retention", () => { + it("keeps a slow request's identity, status, timing and body beyond recent traffic and unmatched headers", async () => { + const f = fixture(); + f.start("slow"); + for (let i = 0; i < MAX_REQUESTS; i++) f.start(`asset-${i}`); + for (let i = 0; i < 800; i++) + f.event("requestWillBeSentExtraInfo", { requestId: `unmatched-${i}`, headers: {} }); + f.finish("slow"); + await settle(); + f.store.stop("requested"); + expect(f.saved.get("d1:n1")).toMatchObject({ + state: "complete", + status: 503, + duration_ms: 19000, + response_body: { state: "available", text: '{"saved":false}' }, + }); + expect(f.send).toHaveBeenCalledWith(7, "Network.getResponseBody", { requestId: "slow" }); + }); + + it("preserves an already running body job when its request leaves the recent cache", async () => { + let resolve!: (value: { body: string }) => void; + const f = fixture( + vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ), + ); + f.start("body"); + f.finish("body"); + for (let i = 0; i < MAX_REQUESTS; i++) f.start(`asset-${i}`); + resolve({ body: '{"saved":false}' }); + await settle(); + expect(f.saved.get("d1:n1")?.response_body.text).toContain('"saved":false'); + f.store.stop("requested"); + }); + + it("bounds tracking and persists explicit gaps instead of forever-pending rows", () => { + const f = fixture(); + for (let i = 0; i < MAX_REQUESTS + MAX_INFLIGHT + 5; i++) f.start(`pending-${i}`); + expect(f.store.size).toBe(MAX_REQUESTS + MAX_INFLIGHT); + expect(f.saved.get("d1:n1")).toMatchObject({ + state: "interrupted", + error: "tracking_limit", + truncated: true, + response_body: { state: "unavailable", reason: "tracking_limit" }, + }); + f.store.stop("requested"); + expect( + [...f.saved.values()].some( + (entry) => entry.state === "pending" || entry.response_body.state === "pending", + ), + ).toBe(false); + expect(f.store.size).toBeLessThanOrEqual(MAX_REQUESTS); + }); +}); diff --git a/apps/extension/src/debug/__tests__/network-store.test.ts b/apps/extension/src/debug/__tests__/network-store.test.ts new file mode 100644 index 00000000..b5e3dd31 --- /dev/null +++ b/apps/extension/src/debug/__tests__/network-store.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi } from "vitest"; +import { bodySlice, DebugNetworkStore, MAX_REQUESTS, requestProjection } from "../network-store"; +import { BODY_CHARS, redactBody, redactHeaders, redactText, redactUrl } from "../redact"; + +function fixture( + send = vi.fn(async () => ({ body: '{"ok":false,"token":"secret","data":{"name":"Alice"}}' })), +) { + let sequence = 0; + let now = 1000; + const store = new DebugNetworkStore( + "d1", + { send: send as never }, + () => ++sequence, + () => now++, + ); + const event = (method: string, data: object, sessionId?: string) => + store.onEvent({ tabId: 7, ...(sessionId ? { sessionId } : {}) }, `Network.${method}`, { + requestId: "raw", + timestamp: now / 1000, + ...data, + }); + const request = (data = {}, sessionId?: string) => + event( + "requestWillBeSent", + { + type: "Fetch", + request: { + url: "https://site.test/api?token=private&item=1", + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer secret" }, + postData: '{"password":"hidden","item":1}', + }, + ...data, + }, + sessionId, + ); + const response = (data = {}, sessionId?: string) => + event( + "responseReceived", + { + response: { + status: 200, + mimeType: "application/json", + headers: { "Set-Cookie": "secret" }, + }, + ...data, + }, + sessionId, + ); + const finish = (sessionId?: string) => + event("loadingFinished", { encodedDataLength: 40 }, sessionId); + return { store, event, request, response, finish, send }; +} + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("debug network evidence", () => { + it("retains URL integrity in request metadata, including annotations arriving before events", () => { + const f = fixture(); + const url = `https://site.test/save?q=${"x".repeat(2200)}&mode=dry-run`; + f.request({ request: { url, method: "GET" } }); + expect(requestProjection(f.store.list()[0])).toMatchObject({ + truncated: true, + integrity: { url: "truncated", metadata: "complete" }, + request_body: { state: "empty", replay_safe: true }, + }); + const second = fixture(); + second.store.annotate({ tabId: 7 }, "raw", { effective: { url, method: "GET", headers: {} } }); + second.request(); + expect(second.store.list()[0]).toMatchObject({ + truncated: true, + url: url.slice(0, 2048), + integrity: { url: "truncated" }, + }); + }); + + it("keeps nested form secrets out of retained evidence and preserves numeric IDs in detail slices", () => { + const f = fixture(); + f.request({ + request: { + url: "https://site.test", + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + postData: + "user[password]=private&credentials.password=private&password_confirmation=private&orderId=9007199254740993", + }, + }); + const entry = f.store.list()[0]; + expect(entry.request_body).toMatchObject({ + state: "available", + redacted: true, + replay_safe: false, + }); + expect(JSON.stringify(entry)).not.toContain("private"); + expect(entry.request_body.text).toContain("9007199254740993"); + expect( + bodySlice({ state: "available", text: '{"id":9007199254740993}' }, 0, 100, "/id").text, + ).toBe("9007199254740993"); + }); + it("preserves truncation and redaction when control annotations precede network events", () => { + const f = fixture(); + const base = JSON.stringify({ password: "pwd", values: "" }); + const body = JSON.stringify({ + password: "pwd", + values: "a".repeat(BODY_CHARS - base.length - 1), + }); + expect(body.length).toBeLessThan(BODY_CHARS); + f.store.annotate({ tabId: 7 }, "raw", { + intervention: { rule_id: "r1", type: "mock", state: "applied" }, + effective: { + url: "https://site.test/api", + method: "POST", + headers: { "content-type": "application/json", "x-large": "x".repeat(3000) }, + postData: body, + }, + mock: { status: 200, headers: { "content-type": "application/json" }, body }, + }); + f.request(); + const entry = f.store.list()[0]; + for (const retained of [entry.request_body, entry.response_body]) { + expect(retained.state).toBe("truncated"); + expect(retained.redacted).toBe(true); + expect(retained.text).not.toContain('"password":"pwd"'); + } + expect(entry.truncated).toBe(true); + }); + + it("bounds long redirect chains and does not misassign late extra headers after eviction", () => { + const f = fixture(); + f.request(); + for (let i = 0; i < 220; i++) + f.request({ redirectResponse: { status: 302 }, redirectHasExtraInfo: true }); + f.event("requestWillBeSentExtraInfo", { headers: { "x-old-hop": "must-not-migrate" } }); + expect(f.store.list()).toHaveLength(MAX_REQUESTS); + expect(f.store.list().some((entry) => entry.request_headers?.["x-old-hop"])).toBe(false); + expect(f.store.list().at(-1)?.truncated).toBe(true); + }); + + it("retains HTTP 200 business failures and exposes bounded body projections with JSON pointers", async () => { + const f = fixture(); + f.request(); + f.response(); + f.finish(); + await settle(); + const entry = f.store.list()[0]; + expect(entry.state).toBe("complete"); + expect(entry.status).toBe(200); + expect(entry.url).not.toContain("private"); + expect(entry.request_headers?.authorization).toBe("[redacted]"); + expect(entry.request_body.text).not.toContain("hidden"); + expect(entry.response_body.text).toContain('"ok":false'); + expect(entry.response_body.text).not.toContain("secret"); + const summary = requestProjection(entry); + expect(summary.response_body.text).toBeUndefined(); + expect(summary.request_headers).toBeUndefined(); + expect(requestProjection(entry, "response", 0, 100, "/data/name").response_body.text).toBe( + '"Alice"', + ); + expect(requestProjection(entry, "response", 0, 5).response_body.next_offset).toBe(5); + expect(f.send).toHaveBeenCalledWith(7, "Network.getResponseBody", { requestId: "raw" }); + }); + + it("correlates out-of-order ExtraInfo with redirect hops without mixing headers", () => { + const f = fixture(); + f.event("requestWillBeSentExtraInfo", { headers: { "x-hop": "first" } }); + f.request(); + f.event("responseReceivedExtraInfo", { + headers: { location: "/next", "Set-Cookie": "secret" }, + }); + f.request({ redirectResponse: { status: 302 }, redirectHasExtraInfo: true }); + f.event("requestWillBeSentExtraInfo", { headers: { "x-hop": "second" } }); + f.response({ hasExtraInfo: true }); + f.event("responseReceivedExtraInfo", { headers: { "x-result": "final" } }); + const [first, second] = f.store.list(); + expect(first.state).toBe("redirected"); + expect(first.request_headers?.["x-hop"]).toBe("first"); + expect(first.response_headers?.location).toBe("/next"); + expect(second.request_headers?.["x-hop"]).toBe("second"); + expect(second.response_headers?.["x-result"]).toBe("final"); + expect(second.redirect_from).toBe(first.id); + expect(first.response_body).toMatchObject({ state: "unavailable", reason: "redirect" }); + }); + + it("does not assign the next hop's extra headers to a redirect without ExtraInfo", () => { + const f = fixture(); + f.request(); + f.event("requestWillBeSentExtraInfo", { headers: { "x-hop": "second" } }); + f.request({ redirectResponse: { status: 301 }, redirectHasExtraInfo: false }); + f.response({ hasExtraInfo: true }); + const [first, second] = f.store.list(); + expect(first.request_headers?.["x-hop"]).toBeUndefined(); + expect(second.request_headers?.["x-hop"]).toBe("second"); + }); + + it("separates identical request IDs across root and child targets", () => { + const f = fixture(); + f.request(); + f.request({}, "child"); + f.event("loadingFailed", { errorText: "net::ERR_FAILED" }, "child"); + expect(f.store.list().map((entry) => entry.state)).toEqual(["pending", "failed"]); + expect(f.store.list()[1].response_body.reason).toBe("request_failed"); + }); + + it("records cached/service-worker responses, skips binary and empty bodies", async () => { + const f = fixture(); + f.request(); + f.response({ + response: { + status: 200, + mimeType: "image/png", + fromDiskCache: true, + fromServiceWorker: true, + }, + }); + f.finish(); + await settle(); + expect(f.store.list()[0]).toMatchObject({ + from_cache: true, + from_service_worker: true, + response_body: { state: "omitted", reason: "non_text" }, + }); + f.request({ requestId: "empty", request: { url: "https://site.test", method: "HEAD" } }); + f.event("loadingFinished", { requestId: "empty" }); + expect(f.store.list()[1].response_body.state).toBe("empty"); + expect(f.send).not.toHaveBeenCalled(); + }); + + it("marks unavailable browser buffers and does not pretend truncated structured bodies are complete", async () => { + const failing = fixture( + vi.fn(async () => { + throw new Error("No resource"); + }), + ); + failing.request(); + failing.response(); + failing.finish(); + await settle(); + expect(failing.store.list()[0].response_body).toMatchObject({ state: "unavailable" }); + const large = fixture( + vi.fn(async () => ({ body: JSON.stringify({ token: "x".repeat(BODY_CHARS) }) })), + ); + large.request(); + large.response(); + large.finish(); + await settle(); + expect(large.store.list()[0].response_body).toMatchObject({ state: "truncated", text: "" }); + }); + + it("bounds concurrent body reads and ignores in-flight completions after stop", async () => { + let resolve!: (value: { body: string }) => void; + const promise = new Promise<{ body: string }>((done) => { + resolve = done; + }); + const f = fixture(vi.fn(() => promise)); + for (let i = 0; i < 50; i++) { + f.request({ requestId: String(i) }); + f.response({ requestId: String(i) }); + f.event("loadingFinished", { requestId: String(i) }); + } + expect(f.send).toHaveBeenCalledTimes(4); + expect(f.store.list().some((entry) => entry.response_body.reason === "capture_busy")).toBe( + true, + ); + f.store.stop("requested"); + resolve({ body: "late secret" }); + await settle(); + expect(f.send).toHaveBeenCalledTimes(4); + expect(f.store.list().every((entry) => entry.response_body.text === undefined)).toBe(true); + f.request(); + expect(f.store.list()).toHaveLength(50); + }); + + it("evicts old records and bodies within a fixed memory budget", async () => { + const f = fixture(vi.fn(async () => ({ body: "x".repeat(60000) }))); + for (let i = 0; i < 15; i++) { + f.request({ requestId: String(i) }); + f.response({ requestId: String(i), response: { status: 200, mimeType: "text/plain" } }); + f.event("loadingFinished", { requestId: String(i) }); + await settle(); + } + expect(f.store.list().some((entry) => entry.response_body.state === "evicted")).toBe(true); + expect( + f.store + .list() + .reduce( + (sum, entry) => + sum + (entry.response_body.text?.length ?? 0) + (entry.request_body.text?.length ?? 0), + 0, + ), + ).toBeLessThanOrEqual(512 * 1024); + for (let i = 15; i < 230; i++) f.request({ requestId: String(i) }); + expect(f.store.entries.size).toBe(MAX_REQUESTS); + expect(f.store.list()).toHaveLength(215); // Pending requests keep their CDP identities. + expect(f.store.dropped).toBe(15); + expect(f.store.get("d1:n1")).toBeUndefined(); + }); + + it("decodes UTF-8 base64 bodies without corrupting text", async () => { + const f = fixture( + vi.fn(async () => ({ + body: btoa(String.fromCharCode(...new TextEncoder().encode('{"message":"保存失败"}'))), + base64Encoded: true, + })), + ); + f.request(); + f.response(); + f.finish(); + await settle(); + expect(f.store.list()[0].response_body.text).toContain("保存失败"); + }); +}); + +describe("redaction and projections", () => { + it("redacts quoted secrets containing spaces and reports depth truncation", () => { + expect(redactText('password="private words" token="more private words')).not.toMatch( + /private|words/, + ); + let value: unknown = { ok: true }; + for (let i = 0; i < 30; i++) value = { child: value }; + expect(redactBody(JSON.stringify(value), "application/json").truncated).toBe(true); + }); + it("marks in-flight frame requests as interrupted without affecting the root", () => { + const f = fixture(); + f.request(); + f.request({}, "child"); + f.store.detachTarget("child"); + expect(f.store.list().map((entry) => entry.state)).toEqual(["pending", "interrupted"]); + }); + + it("scrubs nested JSON, forms, URL credentials and malformed quoted assignments", () => { + expect(redactHeaders({ AUTHORIZATION: "Bearer x", Cookie: "sid=abc" })).toEqual({ + authorization: "[redacted]", + cookie: "[redacted]", + }); + expect(redactUrl("https://user:password@example.com/a?access_token=hidden#secret")).not.toMatch( + /user|password|hidden|secret/, + ); + expect( + redactBody('{"a":[{"password":"hidden"}],"ok":false}', "application/json").text, + ).not.toContain("hidden"); + expect( + redactBody("password=hidden&name=Alice", "application/x-www-form-urlencoded").text, + ).not.toContain("hidden"); + expect(redactText('{"password": "hidden", "token":"private"')).not.toMatch(/hidden|private/); + }); + it("refuses incomplete JSON pointers and inherited property access", () => { + expect(() => bodySlice({ state: "truncated", text: "{}" }, 0, 30, "/x")).toThrow("complete"); + expect(() => bodySlice({ state: "available", text: "{}" }, 0, 30, "/constructor")).toThrow( + "not found", + ); + expect( + bodySlice({ state: "available", text: '{"a/b":{"~":true}}' }, 0, 30, "/a~1b/~0").text, + ).toBe("true"); + }); +}); diff --git a/apps/extension/src/debug/__tests__/observer.browser.test.ts b/apps/extension/src/debug/__tests__/observer.browser.test.ts new file mode 100644 index 00000000..64706cb8 --- /dev/null +++ b/apps/extension/src/debug/__tests__/observer.browser.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +// Owns an isolated browser and fixture; never freezes a user's tab. +import { createServer } from "node:http"; +import { expect, it } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import { type DebugCdp, DebugManager } from "../manager"; + +it.skipIf(!process.env.BSK_CLICK_CHROME)( + "bounds pre-reads on a frozen renderer and restores manual capture after late hooks", + async () => { + const server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/html"); + response.end( + '', + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + let listener: Parameters>[0] | undefined; + await withChrome( + { + executable: process.env.BSK_CLICK_CHROME, + deviceScale: 1, + zoom: 1, + startupTimeout: 30000, + onEvent: (event: { method: string; params?: object }) => + listener?.({ tabId: 7 }, event.method, event.params), + }, + async (send: (method: string, params?: object, sessionId?: string) => Promise) => { + const { targetId } = await send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true }); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + await send("Page.enable", {}, sessionId); + await send("Runtime.enable", {}, sessionId); + await send("Page.navigate", { url }, sessionId); + await send("Runtime.evaluate", { expression: "document.readyState" }, sessionId); + const sessions = new SessionManager({ + agentWindow: { + create: async () => ({ windowId: 100, initialTabIds: [7] }), + remove: async () => {}, + ensureActiveTab: async () => 7, + }, + }); + (await sessions.start("s1")).agentCreatedTabs.add(7); + const hooks: object[] = []; + const cdp = { + sendAttached: (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === "Runtime.evaluate" && params?.expression?.includes(".agent(true,")) + hooks.push(params); + return send(method, params, sessionId); + }, + ensureNetworkCapture: async () => { + await send("Network.enable", {}, sessionId); + }, + onEvent: (fn: typeof listener) => { + listener = fn; + return { + dispose() { + listener = undefined; + }, + }; + }, + } as unknown as DebugCdp; + const tabs = { + get: async () => ({ id: 7, windowId: 100, url }) as chrome.tabs.Tab, + query: async () => [{ id: 7 } as chrome.tabs.Tab], + }; + const manager = new DebugManager(sessions, cdp, tabs); + try { + await manager.start("s1", 7); + await send( + "Runtime.evaluate", + { expression: "setTimeout(()=>{while(true){}},50);true" }, + sessionId, + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + const before = Date.now(); + const ticket = await manager.before({ + id: "nav", + method: "tool.navigate", + params: { session_id: "s1", tab_id: 7 }, + }); + expect(Date.now() - before).toBeLessThan(1800); + expect(ticket).toBeDefined(); + manager.after(ticket); + expect((await send("Browser.getVersion")).product).toContain("Chrome"); + } finally { + await send("Runtime.terminateExecution", {}, sessionId); + } + try { + // Re-deliver the obsolete enable after the disable and its admission deadline. + // It must not suppress subsequent genuine user input. + await send("Runtime.evaluate", { expression: "true" }, sessionId); + expect(hooks).toHaveLength(1); + await send("Runtime.evaluate", hooks[0], sessionId); + await send( + "Input.dispatchMouseEvent", + { type: "mousePressed", x: 40, y: 30, button: "left", clickCount: 1 }, + sessionId, + ); + await send( + "Input.dispatchMouseEvent", + { type: "mouseReleased", x: 40, y: 30, button: "left", clickCount: 1 }, + sessionId, + ); + await send("Runtime.evaluate", { expression: "true" }, sessionId); + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = await manager.read({ session_id: "s1", action: "operations" }); + expect( + result.operations?.some( + (operation) => + operation.source === "human" && operation.target === "Manual capture", + ), + ).toBe(true); + } finally { + manager.dispose(); + } + }, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 15000, +); diff --git a/apps/extension/src/debug/__tests__/performance.browser.test.ts b/apps/extension/src/debug/__tests__/performance.browser.test.ts new file mode 100644 index 00000000..46153151 --- /dev/null +++ b/apps/extension/src/debug/__tests__/performance.browser.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +// Owns its Chrome profile and local server; never operates user tabs. +import { createServer } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { installPerformance } from "../performance-observer"; +import type { DebugPerformance } from "../types"; + +type Send = >( + method: string, + params?: object, + session?: string, +) => Promise; +describe.skipIf(!process.env.BSK_CLICK_CHROME)("native performance capture", () => { + it("installs before navigation, captures browser metrics, survives reload and reports hidden/late capture", async () => { + const server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/html"); + response.end(`Performance fixture

Page ready

Visible content for paint timing

`); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + await withChrome( + { + executable: process.env.BSK_CLICK_CHROME, + deviceScale: 1, + zoom: 1, + startupTimeout: 30000, + }, + async (send: Send) => { + const create = async (background = false) => { + const { targetId } = await send<{ targetId: string }>("Target.createTarget", { + url: "about:blank", + background, + }); + const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId, + flatten: true, + }); + await send("Page.enable", {}, sessionId); + return { targetId, sessionId }; + }; + const world = "bsk-performance-test"; + const script = (early: boolean) => + `globalThis.__capture=(${installPerformance.toString()})(()=>{},${early});`; + const read = async (session: string, expression = "globalThis.__capture.snapshot()") => { + const { frameTree } = await send<{ frameTree: { frame: { id: string } } }>( + "Page.getFrameTree", + {}, + session, + ); + const { executionContextId } = await send<{ executionContextId: number }>( + "Page.createIsolatedWorld", + { frameId: frameTree.frame.id, worldName: world }, + session, + ); + const result = await send<{ + result: { value: DebugPerformance }; + exceptionDetails?: unknown; + }>( + "Runtime.evaluate", + { expression, contextId: executionContextId, returnByValue: true }, + session, + ); + expect(result.exceptionDetails).toBeUndefined(); + return result.result.value; + }; + const front = await create(); + await send("Target.activateTarget", { targetId: front.targetId }); + await send( + "Page.addScriptToEvaluateOnNewDocument", + { source: script(true), worldName: world }, + front.sessionId, + ); + await send("Page.navigate", { url }, front.sessionId); + let visible!: DebugPerformance; + await vi.waitFor( + async () => { + visible = await read(front.sessionId); + expect(visible.metrics.load_ms.value).toBeGreaterThan(0); + expect(visible.metrics.fcp_ms.value).toBeGreaterThan(0); + expect(visible.metrics.lcp_ms.value).toBeGreaterThan(0); + expect(visible.metrics.long_task_count.value).toBeGreaterThan(0); + expect(visible.metrics.cls.value).toBeGreaterThan(0); + }, + { timeout: 7000, interval: 100 }, + ); + expect(visible.early).toBe(true); + expect(visible.metrics.cls.state).toBe("provisional"); + const key = visible.document_key; + await send("Page.reload", {}, front.sessionId); + await vi.waitFor( + async () => { + const reloaded = await read(front.sessionId); + expect(reloaded.document_key).not.toBe(key); + expect(reloaded.navigation).toBe("reload"); + expect(reloaded.metrics.load_ms.value).toBeGreaterThan(0); + }, + { timeout: 5000 }, + ); + await read( + front.sessionId, + `globalThis.__capture.dispose();${script(false)};globalThis.__capture.snapshot()`, + ); + const late = await read(front.sessionId); + expect(late.early).toBe(false); + expect(late.coverage).toContain("visibility_before_capture_unknown"); + expect(late.metrics.cls.state).toBe("partial"); + const back = await create(true); + await send( + "Page.addScriptToEvaluateOnNewDocument", + { source: script(true), worldName: world }, + back.sessionId, + ); + await send("Page.navigate", { url }, back.sessionId); + await vi.waitFor( + async () => + expect((await read(back.sessionId)).metrics.load_ms.value).toBeGreaterThan(0), + { timeout: 5000 }, + ); + const hidden = await read(back.sessionId); + expect(hidden.visibility[0].state).toBe("hidden"); + expect(hidden.metrics.fcp_ms).toMatchObject({ + state: "unavailable", + reasons: ["initially_hidden"], + }); + await send("Target.activateTarget", { targetId: back.targetId }); + await vi.waitFor(async () => + expect((await read(back.sessionId)).visibility.at(-1)?.state).toBe("visible"), + ); + const finished = await read(back.sessionId, "globalThis.__capture.finish()"); + expect(finished.state).toBe("completed"); + expect(finished.metrics.cls.state).toBe("partial"); + await read(back.sessionId, "globalThis.__capture.dispose();({})"); + }, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30000); +}); diff --git a/apps/extension/src/debug/__tests__/performance.test.ts b/apps/extension/src/debug/__tests__/performance.test.ts new file mode 100644 index 00000000..7bf8c2a8 --- /dev/null +++ b/apps/extension/src/debug/__tests__/performance.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { interruptPerformance, performanceSnapshot } from "../performance"; +import { installPerformance } from "../performance-observer"; +import type { DebugPerformance } from "../types"; + +const callbacks = new Map void>(); +const disconnect = vi.fn(); +let current = 100; +let visibility: DocumentVisibilityState = "visible"; +let capture: ReturnType | undefined; +class Observer { + static supportedEntryTypes = [ + "navigation", + "paint", + "largest-contentful-paint", + "layout-shift", + "longtask", + ]; + constructor(private callback: (list: { getEntries(): object[] }) => void) {} + observe({ type }: { type: string }) { + callbacks.set(type, (entries) => this.callback({ getEntries: () => entries })); + } + takeRecords() { + return []; + } + disconnect = disconnect; +} +const entry = (type: string, values: object[]) => callbacks.get(type)?.(values); +beforeEach(() => { + vi.useFakeTimers(); + current = 100; + visibility = "visible"; + callbacks.clear(); + disconnect.mockClear(); + vi.stubGlobal("PerformanceObserver", Observer); + vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(1000); + vi.spyOn(performance, "now").mockImplementation(() => current); + vi.spyOn(performance, "getEntriesByType").mockReturnValue([ + { type: "navigate", responseStart: 20, domContentLoadedEventEnd: 30, loadEventEnd: 40 }, + ] as unknown as PerformanceEntry[]); + vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); +}); +afterEach(() => { + capture?.dispose(); + capture = undefined; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("native performance observer", () => { + it("computes CLS session windows, excludes recent input, and retains bounded worst long tasks", () => { + capture = installPerformance(() => {}, true); + entry("layout-shift", [ + { startTime: 10, value: 0.1 }, + { startTime: 500, value: 0.2 }, + { startTime: 550, value: 2, hadRecentInput: true }, + { startTime: 1600, value: 0.25 }, + ]); + entry( + "longtask", + Array.from({ length: 60 }, (_, i) => ({ startTime: i * 100, duration: 50 + i })), + ); + const value = capture.snapshot(); + expect(value.metrics.cls.value).toBeCloseTo(0.3); + expect(value.metrics.cls.state).toBe("provisional"); + expect(value.metrics.ttfb_ms).toMatchObject({ value: 20, state: "available" }); + expect(value.metrics.long_task_count.value).toBe(60); + expect(value.metrics.long_task_total_ms.value).toBe(4770); + expect(value.long_tasks).toHaveLength(50); + expect(value.long_tasks[0].duration_ms).toBe(109); + expect(value.long_tasks_truncated).toBe(true); + expect(capture.finish().metrics.cls).toMatchObject({ + state: "partial", + reasons: ["capture_stopped_before_final"], + }); + }); + it("exposes hidden-page validity and visibility history without inventing paint values", () => { + visibility = "hidden"; + capture = installPerformance(() => {}, true); + entry("paint", [{ name: "first-contentful-paint", startTime: 25 }]); + entry("largest-contentful-paint", [{ startTime: 60 }]); + expect(capture.snapshot().metrics.fcp_ms).toEqual({ + state: "unavailable", + reasons: ["initially_hidden"], + }); + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + expect(capture.snapshot().visibility.map((v) => v.state)).toEqual(["hidden", "visible"]); + expect(capture.snapshot().metrics.lcp_ms.value).toBeUndefined(); + }); + it("marks late buffered observations partial and unsupported APIs explicitly", () => { + vi.stubGlobal( + "PerformanceObserver", + class extends Observer { + static supportedEntryTypes = ["navigation", "paint"]; + }, + ); + capture = installPerformance(() => {}, false); + entry("paint", [{ name: "first-contentful-paint", startTime: 25 }]); + const value = capture.snapshot(); + expect(value.metrics.fcp_ms).toMatchObject({ + value: 25, + state: "partial", + reasons: ["started_late"], + }); + expect(value.metrics.cls).toEqual({ state: "unsupported", reasons: ["api_unsupported"] }); + expect(value.coverage).toContain("visibility_before_capture_unknown"); + }); + it("resets restored visits without reusing navigation or paint metrics", () => { + capture = installPerformance(() => {}, true); + entry("longtask", [{ startTime: 10, duration: 80 }]); + window.dispatchEvent(new Event("pagehide")); + const event = new Event("pageshow"); + Object.defineProperty(event, "persisted", { value: true }); + current = 2000; + window.dispatchEvent(event); + entry("longtask", [ + { startTime: 10, duration: 80 }, + { startTime: 2100, duration: 70 }, + ]); + const value = capture.snapshot(); + expect(value.document_key).toBe("1000:1"); + expect(value.metrics.ttfb_ms).toEqual({ + state: "unsupported", + reasons: ["back_forward_cache"], + }); + expect(value.metrics.long_task_count.value).toBe(1); + }); + it("coalesces writes and disconnects observers and listeners on cleanup", () => { + const emit = vi.fn(); + capture = installPerformance(emit, true); + entry("longtask", [{ startTime: 10, duration: 80 }]); + entry("longtask", [{ startTime: 100, duration: 80 }]); + expect(emit).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(500); + expect(emit).toHaveBeenCalledTimes(2); + capture.dispose(); + capture = undefined; + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(1000); + expect(emit).toHaveBeenCalledTimes(2); + expect(disconnect).toHaveBeenCalledTimes(4); + }); + it("validates and redacts persistent snapshots and marks interrupted metrics", () => { + capture = installPerformance(() => {}, true); + const raw = { ...capture.snapshot(), url: "https://site.test/?token=secret" }; + const value = performanceSnapshot(raw)!; + expect(value.url).not.toContain("secret"); + expect(performanceSnapshot({ ...raw, document_key: "untrusted" })).toBeUndefined(); + expect(performanceSnapshot({ ...raw, time_origin: NaN })).toBeUndefined(); + const saved = { ...value, id: "p1", sequence: 1 } as DebugPerformance; + interruptPerformance(saved, "worker_restarted"); + expect(saved.state).toBe("interrupted"); + expect(saved.metrics.cls).toMatchObject({ state: "partial", reasons: ["worker_restarted"] }); + }); +}); diff --git a/apps/extension/src/debug/__tests__/query.test.ts b/apps/extension/src/debug/__tests__/query.test.ts new file mode 100644 index 00000000..ae656678 --- /dev/null +++ b/apps/extension/src/debug/__tests__/query.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { debugCapabilities } from "../capabilities"; +import { bodySlice } from "../network-store"; +import { budgetResult, matchesRequest, projectFields } from "../query"; +import { readRecording } from "../recording"; +import type { DebugRecording, DebugRequest, DebugResult } from "../types"; + +const request = (sequence: number): DebugRequest => ({ + id: `d:n${sequence}`, + run_id: "d", + sequence, + started_at: sequence, + method: "POST", + url: `https://site.test/api/${sequence}`, + resource_type: "Fetch", + status: 200, + state: "complete", + duration_ms: 200, + request_body: { state: "empty" }, + response_body: { state: "available", chars: 3000 }, +}); +const size = (value: unknown) => new TextEncoder().encode(JSON.stringify(value, null, 2)).length; +describe("agent debug query budget", () => { + it("paginates whole requests without skipping evidence and keeps the source unchanged", () => { + const entries = Array.from({ length: 100 }, (_, i) => request(i + 1)); + const original: DebugResult = { session_id: "s", requests: entries, next_since: 200 }; + const found: string[] = []; + let since = 0; + while (since < 100) { + const result = budgetResult( + { ...original, requests: entries.filter((entry) => entry.sequence > since) }, + { session_id: "s", action: "requests", budget: 4096 }, + ); + expect(size(result)).toBeLessThanOrEqual(4096); + expect(result.requests?.length).toBeGreaterThan(0); + found.push(...result.requests!.map((entry) => entry.id)); + since = result.next_since!; + } + expect(found).toEqual(entries.map((entry) => entry.id)); + expect(original.requests).toHaveLength(100); + }); + it("compacts inline resources and filters by request attributes without changing identity", () => { + const entry = request(1); + expect( + matchesRequest(entry, { + session_id: "s", + action: "requests", + url: "/api", + method: "POST", + status: 200, + resource_type: "Fetch", + kind: "business", + }), + ).toBe(true); + expect(matchesRequest(entry, { session_id: "s", action: "requests", status: 503 })).toBe(false); + const selected = projectFields(entry, ["status"]); + expect(selected.id).toBe(entry.id); + expect(selected.status).toBe(200); + expect(selected.duration_ms).toBeUndefined(); + const result = budgetResult( + { + session_id: "s", + requests: [{ ...entry, url: `data:image/png;base64,${"x".repeat(2048)}` }], + }, + { session_id: "s", action: "requests" }, + ); + expect(result.requests![0].url.length).toBeLessThan(100); + expect(result.output?.omitted).toContain("inline_url_content"); + }); + it("returns exact UTF-16 body slices under a UTF-8 budget with a usable continuation", () => { + const text = '你好🙂\\"'.repeat(3000); + let offset = 0, + collected = ""; + while (offset < text.length) { + const result = budgetResult( + { + session_id: "s", + request: { + ...request(1), + response_body: { + state: "available", + text: text.slice(offset), + offset, + chars: text.length, + }, + }, + }, + { session_id: "s", action: "request", part: "response", budget: 4096 }, + ); + expect(size(result)).toBeLessThanOrEqual(4096); + const body = result.request!.response_body; + expect(body.text!.length).toBeGreaterThan(0); + collected += body.text; + offset = body.next_offset ?? text.length; + } + expect(collected).toBe(text); + }); + it("marks projected omissions without rewriting stored operation evidence or exports", () => { + const original: DebugResult = { + session_id: "s", + pages: Array.from({ length: 20 }, (_, i) => ({ + at: i, + state: "available", + text: "中".repeat(4000), + })), + }; + const result = budgetResult(original, { session_id: "s", action: "pages", budget: 4096 }); + expect(size(result)).toBeLessThanOrEqual(4096); + expect(result.output?.truncated).toBe(true); + expect(original.pages![0].text).toHaveLength(4000); + expect(budgetResult(original, { session_id: "s", action: "export" })).toBe(original); + }); + it("keeps every console row reachable after budget pagination and retains intervention provenance", () => { + const console = Array.from({ length: 50 }, (_, i) => ({ + id: `c${i}`, + at: i, + text: "x".repeat(400), + level: "error", + count: 1, + })); + const recording = { run: { session_id: "s" }, console } as DebugRecording; + let offset = 0; + const seen: string[] = []; + do { + const params = { session_id: "s", action: "console", budget: 4096, offset } as const; + const page = budgetResult(readRecording(recording, params), params); + expect(size(page)).toBeLessThanOrEqual(4096); + seen.push(...page.console!.map((entry) => entry.id)); + offset = page.next_offset ?? console.length; + } while (offset < console.length); + expect(seen).toEqual(console.map((entry) => entry.id)); + const entry = { ...request(1), replay_id: "r1", pinned: true }; + expect(projectFields(entry, [])).toMatchObject({ replay_id: "r1", pinned: true }); + }); + it("does not advance a body cursor with no text when metadata consumes the budget", () => { + const result = budgetResult( + { + session_id: "s", + request: { + ...request(1), + response_headers: { huge: "h".repeat(9000) }, + response_body: { state: "available", text: "v".repeat(10000), offset: 0 }, + }, + }, + { session_id: "s", action: "request", part: "response", budget: 4096 }, + ); + expect(result.request!.response_body.text!.length).toBeGreaterThan(0); + expect(result.request!.response_body.next_offset).toBeGreaterThan(0); + expect(size(result)).toBeLessThanOrEqual(4096); + expect(result.output!.omitted).toContain("request.response_headers"); + }); + it("keeps Unicode body continuations valid for the Rust JSON transport", () => { + const body = { state: "available", text: "a🙂b" } as const; + expect(bodySlice(body, 0, 2)).toMatchObject({ text: "a", next_offset: 1 }); + expect(bodySlice(body, 1, 2)).toMatchObject({ text: "🙂", next_offset: 3 }); + expect(() => bodySlice(body, 2, 2)).toThrow("Unicode"); + expect(() => bodySlice(body, 1, 1)).toThrow("Unicode"); + }); + it("uses 64 KiB by default while preserving an explicit smaller budget", () => { + const original: DebugResult = { + session_id: "s", + console: Array.from({ length: 60 }, (_, i) => ({ + id: `c${i}`, + at: i, + last_at: i, + text: "x".repeat(700), + level: "info", + count: 1, + })), + }; + const wide = budgetResult(original, { session_id: "s", action: "console" }); + expect(wide.console).toHaveLength(60); + expect(wide.output?.budget).toBe(65536); + expect(size(wide)).toBeGreaterThan(32768); + const narrow = budgetResult(original, { session_id: "s", action: "console", budget: 32768 }); + expect(narrow.console!.length).toBeLessThan(60); + expect(narrow.next_offset).toBe(narrow.console!.length); + }); + it("applies filters before pagination and advances past unmatched records", () => { + const recording = { + run: { session_id: "s", next_since: 10, dropped_requests: 0 }, + requests: [request(1), { ...request(2), status: 503 }, request(3)], + } as DebugRecording; + const result = readRecording(recording, { + session_id: "s", + action: "requests", + status: 503, + limit: 1, + }); + expect(result.requests?.map((entry) => entry.id)).toEqual(["d:n2"]); + expect( + readRecording(recording, { session_id: "s", action: "requests", status: 404 }).next_since, + ).toBe(10); + expect(debugCapabilities().parameters).toMatchObject({ + budget: { min: 4096, max: 262144, default: 65536 }, + limit: { max: 100 }, + }); + }); +}); diff --git a/apps/extension/src/debug/__tests__/redact.test.ts b/apps/extension/src/debug/__tests__/redact.test.ts new file mode 100644 index 00000000..2506d00d --- /dev/null +++ b/apps/extension/src/debug/__tests__/redact.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { jsonPointer, parseJsonSource } from "../json-source"; +import { BODY_CHARS, redactBody, redactRequestUrl } from "../redact"; + +describe("source-preserving evidence redaction", () => { + it.each([ + '{"orderId":9007199254740993,"action":"cancel"}', + ' { "id":18446744073709551615, "small":-9007199254740993, "n":1.234567890123456789 } ', + '[1e400,1e-400,-0,1.00,true,false,null,"a\\u002fb",{"x":[]} ]', + '{"id":1,"id":9007199254740993,"__proto__":{"value":1}}', + ])("preserves every untouched byte: %s", (text) => { + expect(redactBody(text, "application/json")).toEqual({ + text, + redacted: false, + truncated: false, + replay_safe: true, + }); + }); + + it("replaces all secret values without rounding neighbors or normalizing escaped strings", () => { + const text = + '{ "id":9007199254740993, "pass\\u0077ord":{"nested":42}, "password":"second", "label":"a\\u002fb", "data":[{"token":123}] }'; + const result = redactBody(text, "application/json"); + expect(result.text).toBe( + '{ "id":9007199254740993, "pass\\u0077ord":"[redacted]", "password":"[redacted]", "label":"a\\u002fb", "data":[{"token":"[redacted]"}] }', + ); + expect(result).toMatchObject({ redacted: true, truncated: false, replay_safe: false }); + expect(jsonPointer(result.text, "/id")).toBe("9007199254740993"); + }); + + it.each([ + "password", + "user[password]", + "credentials.password", + "password_confirmation", + "user[0][newPassword]", + "confirmPassword", + "old_password", + "user[access_token]", + ])("redacts common form, query and JSON field names: %s", (key) => { + const field = `${encodeURIComponent(key)}=example-secret`; + const body = redactBody( + `${field}&${field}&name=Alice%20Smith`, + "application/x-www-form-urlencoded", + ); + expect(body).toMatchObject({ redacted: true, truncated: false, replay_safe: false }); + expect(body.text).not.toContain("example-secret"); + expect(new URLSearchParams(body.text).getAll(key)).toEqual(["[redacted]", "[redacted]"]); + expect(body.text).toContain("name=Alice%20Smith"); + expect(redactRequestUrl(`https://site.test/?${field}`).text).not.toContain("example-secret"); + expect( + redactBody(JSON.stringify({ [key]: "example-secret" }), "application/json").text, + ).not.toContain("example-secret"); + }); + + it("preserves non-secret form encoding, ordering and duplicates for replay", () => { + const text = "name=Alice%20Smith&label=a%2fb&v=1&v=2&flag&empty="; + expect(redactBody(text, "application/x-www-form-urlencoded")).toMatchObject({ + text, + replay_safe: true, + redacted: false, + }); + }); + + it("omits malformed or over-deep JSON instead of retaining unparsed secrets", () => { + for (const text of [ + '{"user[password]":"private"', + '{"password":"private",}', + "[".repeat(100) + '"private"' + "]".repeat(100), + ]) { + expect(redactBody(text, "application/json")).toEqual({ + text: "", + truncated: true, + redacted: true, + replay_safe: false, + reason: "unparsed_json", + }); + } + expect( + redactBody('{"password":"' + "x".repeat(BODY_CHARS) + '"}', "application/json").text, + ).toBe(""); + expect(() => parseJsonSource('{"a":1,}')).toThrow(); + }); + + it("preserves numeric tokens in JSON pointer projections, including nested and duplicate keys", () => { + const text = '{"a/b":[{"~":9007199254740993}],"id":1,"id":1e400}'; + expect(jsonPointer(text, "/a~1b/0/~0")).toBe("9007199254740993"); + expect(jsonPointer(text, "/id")).toBe("1e400"); + expect(jsonPointer(text, "")).toBe(text); + expect(() => jsonPointer(text, "/constructor")).toThrow("not found"); + }); +}); diff --git a/apps/extension/src/debug/analysis.ts b/apps/extension/src/debug/analysis.ts new file mode 100644 index 00000000..94a95d33 --- /dev/null +++ b/apps/extension/src/debug/analysis.ts @@ -0,0 +1,244 @@ +import { matchesRequest } from "./query"; +import type { + DebugAnalysis, + DebugDuplicate, + DebugEndpoint, + DebugParams, + DebugRecording, + DebugRequest, + DebugResult, +} from "./types"; + +const controlled = (entry: DebugRequest) => + !!(entry.intervention || entry.replay_id || entry.replay_from); +const duration = (entry: DebugRequest) => + typeof entry.duration_ms === "number" && + Number.isFinite(entry.duration_ms) && + entry.duration_ms >= 0 + ? entry.duration_ms + : undefined; +function httpUrl(value: string): boolean { + try { + return ["http:", "https:"].includes(new URL(value).protocol); + } catch { + return false; + } +} +function endpoint(entry: DebugRequest): string { + const url = new URL(entry.url); + return `${url.origin}${url.pathname}`; +} +function aggregate(entries: DebugRequest[], slow: number): DebugEndpoint[] { + const groups = new Map(); + for (const entry of entries) { + const key = `${entry.method} ${endpoint(entry)}`; + const group = groups.get(key) ?? []; + group.push(entry); + groups.set(key, group); + } + return [...groups.values()] + .map((group) => { + const samples = group + .flatMap((e) => (duration(e) === undefined ? [] : [duration(e)!])) + .sort((a, b) => a - b); + const total = samples.reduce((sum, value) => sum + value, 0); + const statuses: Record = {}; + for (const e of group) + if (e.status !== undefined) statuses[e.status] = (statuses[e.status] ?? 0) + 1; + const refs = [...group].sort( + (a, b) => + Number(b.state === "failed" || (b.status ?? 0) >= 400) - + Number(a.state === "failed" || (a.status ?? 0) >= 400) || + (duration(b) ?? 0) - (duration(a) ?? 0) || + a.id.localeCompare(b.id), + ); + const bytes = group.filter( + (e) => + typeof e.transfer_bytes === "number" && + Number.isFinite(e.transfer_bytes) && + e.transfer_bytes >= 0, + ); + return { + id: `aggregate:${group[0].id}`, + method: group[0].method, + endpoint: endpoint(group[0]), + count: group.length, + failed: group.filter((e) => e.state === "failed").length, + http_errors: group.filter((e) => (e.status ?? 0) >= 400).length, + pending: group.filter((e) => e.state === "pending").length, + interrupted: group.filter((e) => e.state === "interrupted").length, + statuses, + slow: samples.filter((ms) => ms >= slow).length, + timing_samples: samples.length, + ...(samples.length + ? { + duration_ms: { + min: samples[0], + mean: total / samples.length, + p50: samples[Math.ceil(samples.length * 0.5) - 1], + p95: samples[Math.ceil(samples.length * 0.95) - 1], + max: samples.at(-1)!, + total, + }, + } + : {}), + transfer_bytes: bytes.reduce((sum, e) => sum + e.transfer_bytes!, 0), + transfer_samples: bytes.length, + cached: group.filter((e) => e.from_cache).length, + service_worker: group.filter((e) => e.from_service_worker).length, + controlled: group.filter((e) => e.intervention).length, + replayed: group.filter((e) => e.replay_id || e.replay_from).length, + request_ids: refs.slice(0, 50).map((e) => e.id), + refs_truncated: group.length > 50, + }; + }) + .sort( + (a, b) => + b.count - a.count || + a.method.localeCompare(b.method) || + a.endpoint.localeCompare(b.endpoint), + ); +} +function duplicates( + entries: DebugRequest[], + record: DebugRecording, + analysis: DebugAnalysis, +): DebugDuplicate[] { + const buckets = new Map(); + const result: DebugDuplicate[] = []; + for (const entry of entries) { + const body = entry.request_body; + if ( + !Number.isFinite(entry.started_at) || + entry.url.length >= 2048 || + /\[redacted\]|%5bredacted%5d/i.test(entry.url) || + !["empty", "available"].includes(body.state) || + (body.state === "available" && body.text === undefined) || + /\[redacted\]|%5bredacted%5d/i.test(body.text ?? "") + ) { + analysis.uncomparable++; + continue; + } + if (!entry.loader_id || !entry.frame_id) { + analysis.coverage.push("missing_document_identity"); + analysis.uncomparable++; + continue; + } + const key = JSON.stringify([ + entry.method, + entry.url, + entry.frame_id ?? "", + entry.loader_id ?? "", + body.text ?? "", + ]); + const bucket = buckets.get(key) ?? []; + bucket.push(entry); + buckets.set(key, bucket); + } + const emit = (group: DebugRequest[]) => { + if (group.length < 2) return; + let overlap = 0, + lastFinish = -Infinity; + for (const e of group) { + if (e.started_at < lastFinish) overlap++; + lastFinish = Math.max( + lastFinish, + e.finished_at ?? (e.state === "pending" ? Infinity : e.started_at), + ); + } + const operationIds = record.operations + .filter((op) => + group.some( + (e) => + e.started_at >= op.started_at && + e.started_at <= (op.window_end ?? op.finished_at ?? op.started_at), + ), + ) + .map((op) => op.id); + result.push({ + id: `duplicate:${group[0].id}`, + method: group[0].method, + url: group[0].url, + count: group.length, + extra_requests: group.length - 1, + started_at: group[0].started_at, + ended_at: group.at(-1)!.started_at, + overlap_count: overlap, + possible_retry: group + .slice(0, -1) + .some((e) => e.state === "failed" || (e.status ?? 0) >= 400), + request_ids: group.slice(0, 50).map((e) => e.id), + operation_ids: operationIds.slice(0, 10), + refs_truncated: group.length > 50 || operationIds.length > 10, + }); + }; + for (const bucket of buckets.values()) { + bucket.sort((a, b) => a.started_at - b.started_at || a.id.localeCompare(b.id)); + let group: DebugRequest[] = []; + for (const entry of bucket) { + if (group.length && entry.started_at - group[0].started_at > analysis.window_ms) { + emit(group); + group = []; + } + group.push(entry); + } + emit(group); + } + return result.sort( + (a, b) => + b.extra_requests - a.extra_requests || + a.started_at - b.started_at || + a.id.localeCompare(b.id), + ); +} + +/** Recompute only on explicit analysis reads. Originals and bodies never enter summaries. */ +export function analyzeRecording(record: DebugRecording, params: DebugParams): DebugResult { + const matched = record.requests.filter( + (entry) => + httpUrl(entry.url) && matchesRequest(entry, { ...params, kind: params.kind ?? "business" }), + ); + const entries = params.include_controlled ? matched : matched.filter((e) => !controlled(e)); + const analysis: DebugAnalysis = { + retained: record.requests.length, + matched: matched.length, + included: entries.length, + excluded_controlled: matched.length - entries.length, + uncomparable: 0, + groups: 0, + suspected_extra_requests: 0, + window_ms: params.window_ms ?? 1000, + slow_ms: params.slow_ms ?? 1000, + coverage: [ + "retained_requests_only", + ...record.run.coverage.filter( + (s) => s.startsWith("evidence_") || s === "initial_load_not_recorded", + ), + ...(record.run.dropped_requests ? ["request_retention_limit"] : []), + ], + semantics: + params.action === "aggregate" + ? "method + exact origin/path; query values grouped; duration uses captured samples, p95 nearest rank; HTTP status is not business success" + : "suspected only: same method, retained URL, body and frame/document within a fixed start-time window; headers not compared; retries or deliberate calls may be valid", + }; + const values = + params.action === "aggregate" + ? aggregate(entries, analysis.slow_ms) + : duplicates(entries, record, analysis); + analysis.groups = values.length; + if (params.action === "duplicates") + analysis.suspected_extra_requests = (values as DebugDuplicate[]).reduce( + (sum, g) => sum + g.extra_requests, + 0, + ); + analysis.coverage = [...new Set(analysis.coverage)]; + const offset = params.offset ?? 0, + end = Math.min(values.length, offset + (params.limit ?? 30)); + return { + session_id: record.run.session_id, + run: record.run, + analysis, + [params.action === "aggregate" ? "aggregates" : "duplicates"]: values.slice(offset, end), + ...(end < values.length ? { next_offset: end, truncated: true } : {}), + }; +} diff --git a/apps/extension/src/debug/archive.ts b/apps/extension/src/debug/archive.ts new file mode 100644 index 00000000..32c221bc --- /dev/null +++ b/apps/extension/src/debug/archive.ts @@ -0,0 +1,518 @@ +import { + JOURNAL_BYTES, + JOURNAL_PINS, + JOURNAL_REQUESTS, + jsonBytes, + mergeRequest, + requestMetadata, + retentionPriority, +} from "./journal"; +import { interruptPerformance } from "./performance"; +import { matchesRequest, projectFields } from "./query"; +import type { DebugParams, DebugRecording, DebugRequest, DebugResult, DebugRun } from "./types"; + +const STORES = ["runs", "recordings", "requests", "request_index"]; +interface StoredRequest { + entry: DebugRequest; + bytes: number; + priority: number; +} + +export const HISTORY_LIMIT = 50; +export const HISTORY_BYTES = 50 * 1024 * 1024; +export const HISTORY_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +export interface DebugArchive { + list(): Promise; + get(id: string, bodies?: boolean): Promise; + query?(runId: string, params: DebugParams): Promise; + retain?(run: DebugRun, entries: DebugRequest[]): Promise; + request?(runId: string, id: string): Promise; + pin?(runId: string, id: string, pinned: boolean, sequence?: number): Promise; + put(recording: DebugRecording): Promise; + delete(id: string): Promise; +} + +interface StoredRun { + run: DebugRun; + bytes: number; +} + +export function expiredHistory(values: StoredRun[], now: number): string[] { + const active = values.filter(({ run }) => run.state === "capturing"); + let count = active.length; + let bytes = active.reduce((sum, item) => sum + item.bytes, 0); + return values + .filter(({ run }) => run.state !== "capturing") + .sort((a, b) => b.run.started_at - a.run.started_at) + .filter((value) => { + const expired = (value.run.stopped_at ?? value.run.started_at) < now - HISTORY_AGE_MS; + if (expired || count >= HISTORY_LIMIT || bytes + value.bytes > HISTORY_BYTES) return true; + count += 1; + bytes += value.bytes; + return false; + }) + .map(({ run }) => run.id); +} + +function result(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("debug history read failed")); + }); +} +function complete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => + reject(transaction.error ?? new Error("debug history write failed")); + transaction.onerror = () => {}; // The abort handler owns the terminal result. + }); +} + +/** Recover a checkpoint without representing an interrupted request as completed. */ +export function interrupted(recording: DebugRecording): DebugRecording { + if (recording.run.state !== "capturing") return recording; + recording.run.state = "stopped"; + recording.run.active_rules = 0; + for (const rule of recording.rules ?? []) + if (["enabled", "disabled"].includes(rule.state)) rule.state = "stopped"; + for (const replay of recording.replays ?? []) + if (replay.state === "running") replay.state = "interrupted"; + recording.run.stopped_at = recording.saved_at; + recording.run.stop_reason = "browser_restarted"; + recording.run.coverage = [...new Set([...recording.run.coverage, "interrupted_checkpoint"])]; + for (const request of recording.requests) { + if (request.intervention?.state === "pending") { + request.intervention.state = "cancelled"; + request.intervention.error = "browser restarted before control completed"; + } + if (request.state === "pending") request.state = "interrupted"; + for (const body of [request.request_body, request.response_body]) { + if (body.state === "pending") { + body.state = "unavailable"; + body.reason = "browser_restarted"; + } + } + } + for (const entry of recording.performance ?? []) interruptPerformance(entry, "browser_restarted"); + for (const operation of recording.operations) { + if (operation.state === "running") operation.state = "interrupted"; + } + return recording; +} + +/** One bounded database per extension/browser profile. No remote daemon dependency. */ +export class LocalDebugArchive implements DebugArchive { + private database?: Promise; + constructor( + private readonly factory?: IDBFactory, + private readonly now = Date.now, + ) {} + + private open(): Promise { + if (this.database) return this.database; + this.database = new Promise((resolve, reject) => { + const factory = this.factory ?? globalThis.indexedDB; + if (!factory) { + reject(new Error("debug history storage unavailable")); + return; + } + const request = factory.open("bsk-debug-history", 2); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains("runs")) + db.createObjectStore("runs", { keyPath: "run.id" }); + if (!db.objectStoreNames.contains("recordings")) + db.createObjectStore("recordings", { keyPath: "run.id" }); + const requests = db.createObjectStore("requests", { + keyPath: ["entry.run_id", "entry.id"], + }); + requests.createIndex("run", "entry.run_id"); + requests.createIndex("retention", [ + "entry.run_id", + "priority", + "entry.started_at", + "entry.id", + ]); + const index = db.createObjectStore("request_index", { keyPath: ["run_id", "id"] }); + index.createIndex("run", "run_id"); + index.createIndex("sequence", ["run_id", "sequence"]); + }; + request.onerror = () => reject(request.error ?? new Error("debug history unavailable")); + request.onblocked = () => reject(new Error("debug history database is blocked")); + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + this.database = undefined; + }; + resolve(db); + }; + }) + .then(async (db) => { + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + // Recovery runs once for this service worker. Old capture IDs are never resumed. + const runs = tx.objectStore("runs"); + const records = tx.objectStore("recordings"); + const request = runs.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + const value = cursor.value as StoredRun; + if (value.run.state === "capturing") { + const read = records.get(value.run.id); + read.onsuccess = () => { + if (read.result) { + const recording = interrupted(read.result as DebugRecording); + records.put(recording); + recording.run.storage = value.run.storage; + cursor.update({ ...value, run: recording.run }); + const evidence = tx + .objectStore("requests") + .index("run") + .openCursor(IDBKeyRange.only(recording.run.id)); + evidence.onsuccess = () => { + const item = evidence.result; + if (!item) return; + const saved = item.value as StoredRequest; + const recovered = interrupted({ + ...recording, + run: { ...recording.run, state: "capturing" }, + requests: [saved.entry], + }).requests[0]; + item.update({ ...saved, entry: recovered }); + tx.objectStore("request_index").put(requestMetadata(recovered)); + item.continue(); + }; + } + cursor.continue(); + }; + } else cursor.continue(); + }; + await done; + return db; + }) + .catch((error) => { + this.database = undefined; + throw error; + }); + return this.database; + } + + private prune(tx: IDBTransaction, values: StoredRun[]): void { + for (const id of expiredHistory(values, this.now())) { + tx.objectStore("runs").delete(id); + tx.objectStore("recordings").delete(id); + this.deleteRequests(tx, id); + } + } + + private deleteRequests(tx: IDBTransaction, id: string): void { + for (const name of ["requests", "request_index"]) { + const read = tx.objectStore(name).index("run").openCursor(IDBKeyRange.only(id)); + read.onsuccess = () => { + const item = read.result; + if (item) { + item.delete(); + item.continue(); + } + }; + } + } + + async list(): Promise { + const db = await this.open(); + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + const all = tx.objectStore("runs").getAll(); + all.onsuccess = () => this.prune(tx, all.result as StoredRun[]); + await done; + const read = db.transaction("runs"); + return ((await result(read.objectStore("runs").getAll())) as StoredRun[]) + .map(({ run }) => run) + .sort((a, b) => b.started_at - a.started_at); + } + + async get(id: string, bodies = true): Promise { + const db = await this.open(); + const recording = await result( + db.transaction("recordings").objectStore("recordings").get(id), + ); + if ( + recording && + recording.run.state !== "capturing" && + (recording.run.stopped_at ?? recording.run.started_at) < this.now() - HISTORY_AGE_MS + ) { + await this.delete(id); + return undefined; + } + if (!recording) return undefined; + const tx = db.transaction(["runs", bodies ? "requests" : "request_index"]); + const [saved, requests] = await Promise.all([ + result(tx.objectStore("runs").get(id)), + result( + tx + .objectStore(bodies ? "requests" : "request_index") + .index("run") + .getAll(IDBKeyRange.only(id)), + ), + ]); + const merged = new Map( + recording.requests.map((entry) => [entry.id, bodies ? entry : requestMetadata(entry)]), + ); + for (const value of requests) { + const entry = bodies ? (value as StoredRequest).entry : (value as DebugRequest); + merged.set(entry.id, mergeRequest(merged.get(entry.id), entry)); + } + recording.requests = [...merged.values()].sort( + (a, b) => a.started_at - b.started_at || a.sequence - b.sequence, + ); + if (saved?.run.storage) { + recording.run.storage = saved.run.storage; + recording.run.dropped_requests = saved.run.storage.dropped; + } + recording.run.requests = recording.requests.length; + if (recording.run.storage?.dropped) + recording.run.coverage = [...new Set([...recording.run.coverage, "evidence_storage_limit"])]; + return recording; + } + + async query(runId: string, params: DebugParams): Promise { + const db = await this.open(); + const tx = db.transaction(["runs", "request_index"]); + const runRead = tx.objectStore("runs").get(runId); + const entries: DebugRequest[] = []; + const limit = params.limit ?? 30; + const since = params.since ?? 0; + let seen = since, + more = false; + const request = tx + .objectStore("request_index") + .index("sequence") + .openCursor(IDBKeyRange.bound([runId, since], [runId, Number.MAX_SAFE_INTEGER], true)); + const done = complete(tx); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + const entry = cursor.value as DebugRequest; + if (matchesRequest(entry, params)) { + if (entries.length === limit) { + more = true; + return; + } + entries.push(projectFields(entry, params.fields)); + } + seen = entry.sequence; + cursor.continue(); + }; + await done; + const saved = runRead.result as StoredRun | undefined; + if (!saved?.run.storage) return undefined; + if ( + saved.run.state !== "capturing" && + (saved.run.stopped_at ?? saved.run.started_at) < this.now() - HISTORY_AGE_MS + ) { + await this.delete(runId); + return undefined; + } + return { + session_id: saved.run.session_id, + run: { + ...saved.run, + requests: saved.run.storage.requests, + dropped_requests: saved.run.storage.dropped, + coverage: [ + ...new Set([ + ...saved.run.coverage, + ...(saved.run.storage.dropped ? ["evidence_storage_limit"] : []), + ]), + ], + }, + requests: entries, + next_since: more ? (entries.at(-1)?.sequence ?? since) : Math.max(seen, saved.run.next_since), + truncated: more || saved.run.storage.dropped > 0, + }; + } + + async request(runId: string, id: string): Promise { + const db = await this.open(); + const saved = await result( + db.transaction("requests").objectStore("requests").get([runId, id]), + ); + if (saved) return saved.entry; + // v1 stored requests inside the recording. Keep the indexed v2 fast path, + // but resolve old details here so every caller gets the same complete data. + const recording = await result( + db.transaction("recordings").objectStore("recordings").get(runId), + ); + return recording?.requests.find((entry) => entry.id === id); + } + + async put(recording: DebugRecording): Promise { + const db = await this.open(); + const bytes = new TextEncoder().encode(JSON.stringify(recording)).byteLength; + if (bytes > HISTORY_BYTES) throw new Error("debug recording exceeds storage limit"); + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + const runs = tx.objectStore("runs"); + const previous = runs.get(recording.run.id); + previous.onsuccess = () => { + const storage = (previous.result as StoredRun | undefined)?.run.storage; + const run = { + ...recording.run, + ...(storage + ? { storage, requests: storage.requests, dropped_requests: storage.dropped } + : {}), + }; + tx.objectStore("recordings").put({ ...recording, run }); + runs.put({ run, bytes: bytes + (storage?.bytes ?? 0) } satisfies StoredRun); + const all = runs.getAll(); + all.onsuccess = () => this.prune(tx, all.result as StoredRun[]); + }; + await done; + } + + async delete(id: string): Promise { + const db = await this.open(); + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + tx.objectStore("runs").delete(id); + tx.objectStore("recordings").delete(id); + this.deleteRequests(tx, id); + await done; + } + + async retain(run: DebugRun, entries: DebugRequest[]): Promise { + const db = await this.open(); + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + const runs = tx.objectStore("runs"), + requests = tx.objectStore("requests"), + index = tx.objectStore("request_index"); + const read = runs.get(run.id); + read.onsuccess = () => { + const old = read.result as StoredRun | undefined; + const stats = { requests: 0, bytes: 0, dropped: 0, pins: 0, ...old?.run.storage }; + const baseBytes = old ? old.bytes - (old.run.storage?.bytes ?? 0) : 0; + let remaining = entries.length; + const finish = () => { + const save = () => { + runs.put({ + run: { + ...(old?.run ?? run), + storage: stats, + requests: stats.requests, + dropped_requests: stats.dropped, + }, + bytes: baseBytes + stats.bytes, + }); + if (!old) + tx.objectStore("recordings").put({ + version: 1, + saved_at: this.now(), + run, + requests: [], + operations: [], + console: [], + pages: [], + } satisfies DebugRecording); + const all = runs.getAll(); + all.onsuccess = () => this.prune(tx, all.result as StoredRun[]); + }; + if (stats.bytes <= JOURNAL_BYTES && stats.requests <= JOURNAL_REQUESTS) { + save(); + return; + } + const cursor = requests + .index("retention") + .openCursor(IDBKeyRange.bound([run.id, 0], [run.id, 9])); + cursor.onsuccess = () => { + const item = cursor.result; + if (!item || (stats.bytes <= JOURNAL_BYTES && stats.requests <= JOURNAL_REQUESTS)) { + save(); + return; + } + const value = item.value as StoredRequest; + stats.bytes -= value.bytes; + stats.requests--; + stats.dropped++; + index.delete([run.id, value.entry.id]); + item.delete(); + item.continue(); + }; + }; + if (!remaining) { + finish(); + return; + } + for (const current of entries) { + const previous = requests.get([run.id, current.id]); + previous.onsuccess = () => { + const saved = previous.result as StoredRequest | undefined; + const entry = mergeRequest(saved?.entry, current); + const bytes = jsonBytes(entry); + stats.bytes += bytes - (saved?.bytes ?? 0); + if (!saved) stats.requests++; + requests.put({ + entry, + bytes, + priority: retentionPriority(entry), + } satisfies StoredRequest); + index.put(requestMetadata(entry)); + if (--remaining === 0) finish(); + }; + } + }; + await done; + } + + async pin(runId: string, id: string, pinned: boolean, sequence?: number): Promise { + const db = await this.open(); + const tx = db.transaction(STORES, "readwrite"); + const done = complete(tx); + const request = tx.objectStore("requests").get([runId, id]); + const read = tx.objectStore("runs").get(runId); + let failure = ""; + read.onsuccess = () => { + const saved = request.result as StoredRequest | undefined; + const run = read.result as StoredRun | undefined; + if (!saved || !run?.run.storage) { + failure = "request is not saved"; + return; + } + if ( + pinned && + (saved.entry.state === "pending" || saved.entry.response_body.state === "pending") + ) { + failure = "wait for request capture to complete before pinning"; + return; + } + if (pinned && !saved.entry.pinned && run.run.storage.pins >= JOURNAL_PINS) { + failure = "pin limit reached (20)"; + return; + } + if (pinned && !saved.entry.pinned && saved.bytes > JOURNAL_BYTES / JOURNAL_PINS) { + failure = "request too large to pin"; + return; + } + run.run.storage.pins += Number(pinned) - Number(!!saved.entry.pinned); + saved.entry.pinned = pinned; + if (sequence !== undefined) { + saved.entry.sequence = sequence; + run.run.next_since = Math.max(run.run.next_since, sequence); + } + const bytes = jsonBytes(saved.entry); + run.bytes += bytes - saved.bytes; + run.run.storage.bytes += bytes - saved.bytes; + saved.bytes = bytes; + saved.priority = retentionPriority(saved.entry); + tx.objectStore("requests").put(saved); + tx.objectStore("request_index").put(requestMetadata(saved.entry)); + tx.objectStore("runs").put(run); + }; + await done; + if (failure) throw new Error(failure); + } +} diff --git a/apps/extension/src/debug/bridge.ts b/apps/extension/src/debug/bridge.ts new file mode 100644 index 00000000..28ebf7a8 --- /dev/null +++ b/apps/extension/src/debug/bridge.ts @@ -0,0 +1,71 @@ +import type { SessionManager } from "@/session-manager/manager"; +import { handleDebug, validateDebugParams } from "@/tools/debug"; +import { isRpcError } from "@/tools/shared"; +import type { DebugManager } from "./manager"; +import type { DebugParams } from "./types"; + +export const DEBUG_MESSAGE = "bsk_debug"; + +/** Only extension-owned UI may read evidence; content scripts are excluded. */ +export function isDebugPage(sender: chrome.runtime.MessageSender): boolean { + if (sender.id !== chrome.runtime.id || !sender.url) return false; + try { + const origin = new URL(chrome.runtime.getURL("/")); + const url = new URL(sender.url); + return ( + url.protocol === origin.protocol && + url.host === origin.host && + ["/popup.html", "/debug.html"].includes(url.pathname) + ); + } catch { + return false; + } +} + +export function attachDebugBridge(sessions: SessionManager, debug: DebugManager): void { + let writes = Promise.resolve(); + chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.kind !== DEBUG_MESSAGE) return false; + if (!isDebugPage(sender)) { + respond({ ok: false, error: "forbidden" }); + return false; + } + const action = message.action; + const execute = async () => { + if (action === "tasks") return { tasks: await debug.tasks() }; + if (action === "history") return debug.history(); + if (action === "delete") { + if (typeof message.run_id !== "string" || !/^d[a-zA-Z0-9]+$/.test(message.run_id)) + throw new Error("invalid recording ID"); + await debug.deleteHistory(message.run_id); + return {}; + } + const params = message.params as DebugParams; + if (action === "record") { + const invalid = validateDebugParams(params); + if (invalid) throw new Error(invalid); + return debug.readHistory(params); + } + if (action !== "debug") throw new Error("unsupported debug message"); + const result = await handleDebug(sessions, params, debug); + if (isRpcError(result)) throw new Error(result.message); + return result; + }; + const request = writes.then(execute); + if ( + action === "delete" || + message.params?.action === "start" || + message.params?.action === "stop" + ) + writes = request.then( + () => {}, + () => {}, + ); + void request.then( + (data) => respond({ ok: true, data }), + (error: unknown) => + respond({ ok: false, error: error instanceof Error ? error.message : "unavailable" }), + ); + return true; + }); +} diff --git a/apps/extension/src/debug/capabilities.ts b/apps/extension/src/debug/capabilities.ts new file mode 100644 index 00000000..aa0f904f --- /dev/null +++ b/apps/extension/src/debug/capabilities.ts @@ -0,0 +1,161 @@ +import { HISTORY_AGE_MS, HISTORY_BYTES, HISTORY_LIMIT } from "./archive"; +import { JOURNAL_BYTES, JOURNAL_PINS, JOURNAL_REQUESTS } from "./journal"; + +declare const __BSK_EXT_BUILD__: string; +declare const __BSK_EXT_VERSION__: string; +export const DEBUG_ACTIONS = [ + "performance", + "aggregate", + "duplicates", + "start", + "stop", + "status", + "requests", + "request", + "operations", + "operation", + "console", + "pages", + "export", + "rules", + "rule_add", + "rule_enable", + "rule_disable", + "rule_remove", + "replay", + "capabilities", + "pin", + "unpin", +] as const; +export const DEBUG_FIELDS = [ + "resource_type", + "frame_id", + "loader_id", + "status", + "error", + "mime_type", + "duration_ms", + "transfer_bytes", + "decoded_bytes", + "from_cache", + "from_service_worker", + "redirect_from", + "initiator", + "finished_at", +] as const; +export const QUERY_LIMITS = { + limit: { min: 1, max: 100, default: 30 }, + max_chars: { min: 1, max: 16384, default: 4096 }, + offset: { min: 0, max: 65536 }, + budget: { min: 4096, max: 262144, default: 65536 }, +}; +export function debugCapabilities(persistent = true): Record { + return { + schema_version: 1, + extension: { + version: typeof __BSK_EXT_VERSION__ === "string" ? __BSK_EXT_VERSION__ : "unknown", + build: typeof __BSK_EXT_BUILD__ === "string" ? __BSK_EXT_BUILD__ : "unknown", + }, + actions: DEBUG_ACTIONS.filter((action) => persistent || !["pin", "unpin"].includes(action)), + parameters: { + ...QUERY_LIMITS, + since: { min: 0, semantics: "incremental sequence; merge updates by id" }, + filters: { + actions: ["requests", "aggregate", "duplicates"], + url: "case-sensitive substring of retained URL", + method: "exact HTTP method", + resource_type: "exact CDP resource type", + status: "exact HTTP status (100..599)", + state: ["pending", "complete", "failed", "redirected", "interrupted"], + kind: ["all", "business", "resource", "extension"], + }, + fields: { + actions: ["requests", "request"], + optional: DEBUG_FIELDS, + always: [ + "id", + "run_id", + "sequence", + "started_at", + "method", + "url", + "integrity", + "state", + "request_body", + "response_body", + "truncated", + "intervention", + "replay_from", + "replay_id", + "pinned", + ], + detail: "Use part=request/response/headers/timing for large fields", + }, + }, + output: { + budget_unit: "UTF-8 JSON bytes", + export_exempt: true, + omissions: "output.omitted lists omitted sections; stored evidence is unchanged", + inline_urls: "data URLs use a compact descriptor", + }, + storage: { + persistent, + request_limit: JOURNAL_REQUESTS, + request_bytes: JOURNAL_BYTES, + pin_limit: JOURNAL_PINS, + history_count: HISTORY_LIMIT, + history_bytes: HISTORY_BYTES, + history_age_ms: HISTORY_AGE_MS, + scope: "owning task only; browser history is available through extension UI", + overflow: "old low-priority unpinned requests evicted first; reported by run.storage.dropped", + }, + analysis: { + default_kind: "business", + default_include_controlled: false, + slow_ms: { default: 1000, min: 0, max: 60000 }, + window_ms: { default: 1000, min: 100, max: 10000 }, + reference_limit: 50, + scope: "retained requests only; suspected duplicates are not defects", + pagination: "offset/next_offset; restart pagination when recording changes", + }, + performance: { + metrics: [ + "ttfb_ms", + "dom_content_loaded_ms", + "load_ms", + "fcp_ms", + "lcp_ms", + "cls", + "long_task_count", + "long_task_total_ms", + "long_task_max_ms", + ], + scope: "main frame; per document/visit", + record_limit: 20, + long_task_limit: 50, + visibility_limit: 64, + validity: + "inspect metric.state and reasons; provisional/partial values are not final Core Web Vitals", + }, + network_controls: { + capture_required: true, + rule_limit: 32, + replay_limit: 20, + replay_same_origin: true, + replay_requires_key: true, + url_max_chars: 16384, + retained_url_max_chars: 2048, + replay_fidelity: + "Reuse requires integrity.url=complete, complete metadata and request_body.replay_safe=true. Replace changed, missing or legacy URL/body explicitly; redacted placeholders are rejected.", + }, + unsupported: [ + "cpu_profiling", + "inp", + "iframe_vitals", + "soft_navigation_vitals", + "cross_origin_replay", + "binary_request_editing", + "response_rewriting", + ], + }; +} diff --git a/apps/extension/src/debug/client.ts b/apps/extension/src/debug/client.ts new file mode 100644 index 00000000..631edcb0 --- /dev/null +++ b/apps/extension/src/debug/client.ts @@ -0,0 +1,22 @@ +import type { DebugParams, DebugResult, DebugRun, DebugTask } from "./types"; + +async function request(message: object): Promise { + const response = await chrome.runtime.sendMessage({ kind: "bsk_debug", ...message }); + if (!response?.ok) throw new Error(response?.error ?? "unavailable"); + return response.data as T; +} +export const debugRequest = (params: DebugParams): Promise => + request({ action: "debug", params }); +export const debugTasks = (): Promise<{ tasks: DebugTask[] }> => request({ action: "tasks" }); +export const debugHistory = (): Promise<{ runs: DebugRun[]; error?: string }> => + request({ action: "history" }); +export const recordingRequest = (params: DebugParams): Promise => + request({ action: "record", params }); +export const deleteRecording = (id: string): Promise => + request({ action: "delete", run_id: id }); +export function openDebugPage(sessionId?: string, runId?: string): void { + const url = new URL(chrome.runtime.getURL("debug.html")); + if (sessionId) url.searchParams.set("session", sessionId); + if (runId) url.searchParams.set("run", runId); + void chrome.tabs.create({ url: url.href }); +} diff --git a/apps/extension/src/debug/control-model.ts b/apps/extension/src/debug/control-model.ts new file mode 100644 index 00000000..ca7af8c9 --- /dev/null +++ b/apps/extension/src/debug/control-model.ts @@ -0,0 +1,359 @@ +import { parseJsonSource } from "./json-source"; +import { BODY_CHARS, redactBody, redactHeaders, redactText, redactUrl } from "./redact"; +import type { DebugReplaySpec, DebugRequest, DebugRequestEdit, DebugRuleSpec } from "./types"; + +export const MAX_RULES = 32; +export const MAX_REPLAYS = 20; +const METHODS = /^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/; +const CONTROL_URL_CHARS = 16 * 1024; +const PLACEHOLDER = /\[redacted\]|%5bredacted%5d|\[depth limit\]/i; +const FORBIDDEN_HEADER = + /^(?:host|content-length|cookie|cookie2|origin|referer|user-agent|accept-encoding|connection|transfer-encoding|upgrade|proxy-.*|sec-.*|access-control-request-.*)$/i; +const own = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +function requireValue(ok: unknown, message: string): asserts ok { + if (!ok) throw new Error(message); +} +function keys(value: Record, allowed: string[]): void { + requireValue( + Object.keys(value).every((key) => allowed.includes(key)), + "unknown control option", + ); +} +export function httpUrl(value: string): URL { + requireValue( + typeof value === "string" && value.length <= CONTROL_URL_CHARS && !PLACEHOLDER.test(value), + "URL is missing, redacted or too long", + ); + const url = new URL(value); + requireValue( + ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash, + "an absolute HTTP(S) URL without credentials or fragment is required", + ); + return url; +} +function body(value: unknown): asserts value is string { + requireValue( + typeof value === "string" && value.length <= BODY_CHARS && !PLACEHOLDER.test(value), + "body must be complete text up to 65536 characters, without redacted placeholders", + ); +} +function jsonEditValue(value: unknown): string { + const text = JSON.stringify(value, (_key, item) => { + requireValue( + typeof item !== "number" || + (Number.isFinite(item) && (!Number.isInteger(item) || Number.isSafeInteger(item))), + "JSON edits cannot carry unsafe numeric values; use an exact text body replacement", + ); + return item; + }); + requireValue(text !== undefined, "JSON edit value is not serializable"); + return text; +} +export function checkedHeaders(value: unknown, response = false): Record { + requireValue(own(value), "headers must be an object"); + requireValue(Object.keys(value).length <= 40, "too many headers"); + const result: Record = {}; + for (const [key, valueText] of Object.entries(value)) { + const name = key.toLowerCase(); + requireValue( + /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) && name.length <= 128, + "invalid header name", + ); + requireValue( + !FORBIDDEN_HEADER.test(name) && + name !== "set-cookie" && + (!response || name !== "content-encoding"), + `unsupported header: ${name}`, + ); + requireValue( + valueText === null || + (typeof valueText === "string" && + valueText.length <= 4096 && + !/[\r\n\0]/.test(valueText) && + !PLACEHOLDER.test(valueText)), + `invalid or redacted header: ${name}`, + ); + requireValue(!Object.hasOwn(result, name), "duplicate header name"); + Object.defineProperty(result, name, { value: valueText, enumerable: true }); + } + requireValue(JSON.stringify(result).length <= 8192, "headers too large"); + return result; +} +export function validateEdit(edit: DebugRequestEdit): void { + if (edit.url !== undefined) httpUrl(edit.url); + if (edit.method !== undefined) requireValue(METHODS.test(edit.method), "unsupported HTTP method"); + if (edit.headers !== undefined) checkedHeaders(edit.headers); + if (edit.body !== undefined) body(edit.body); + requireValue( + !(edit.body !== undefined && edit.json !== undefined), + "choose body replacement or JSON edits", + ); + if (edit.json !== undefined) { + requireValue(own(edit.json), "JSON edits must be an object"); + keys(edit.json, ["set", "remove", "rename"]); + if (edit.json.set !== undefined) { + requireValue(own(edit.json.set), "JSON set must be an object"); + jsonEditValue(edit.json.set); + } + if (edit.json.rename !== undefined) { + requireValue(own(edit.json.rename), "JSON rename must be an object"); + requireValue( + Object.values(edit.json.rename).every( + (value) => typeof value === "string" && value.length > 0 && value.length <= 128, + ), + "invalid JSON rename", + ); + } + if (edit.json.remove !== undefined) + requireValue( + Array.isArray(edit.json.remove) && + edit.json.remove.every((key) => typeof key === "string" && key.length <= 128), + "invalid JSON remove", + ); + const count = + Object.keys(edit.json.set ?? {}).length + + Object.keys(edit.json.rename ?? {}).length + + (edit.json.remove?.length ?? 0); + requireValue(count > 0 && count <= 32, "JSON edits require 1..32 top-level fields"); + body(JSON.stringify(edit.json)); + } +} +export function validateRule(input: unknown): DebugRuleSpec { + requireValue(own(input), "rule must be an object"); + keys(input, ["name", "match", "effect", "times"]); + requireValue(own(input.match) && own(input.effect), "rule requires match and effect"); + keys(input.match, ["url", "method", "resource_type"]); + requireValue(typeof input.match.url === "string", "match.url is required"); + const url = httpUrl(input.match.url); + requireValue(!url.origin.includes("*"), "wildcards are only supported in the URL path/query"); + requireValue( + input.match.method === undefined || + (typeof input.match.method === "string" && METHODS.test(input.match.method)), + "unsupported match method", + ); + requireValue( + input.match.resource_type === undefined || + ["Fetch", "XHR", "Document"].includes(input.match.resource_type as string), + "unsupported resource type", + ); + requireValue( + input.name === undefined || (typeof input.name === "string" && input.name.length <= 120), + "rule name too long", + ); + requireValue( + input.times === undefined || + (Number.isInteger(input.times) && + (input.times as number) >= 0 && + (input.times as number) <= 100), + "times must be 0..100 (default 1)", + ); + const effect = input.effect; + if (effect.type === "block") keys(effect, ["type"]); + else if (effect.type === "modify") { + keys(effect, ["type", "url", "method", "headers", "body", "json"]); + validateEdit(effect); + requireValue(Object.keys(effect).length > 1, "modify requires at least one change"); + if (effect.url !== undefined) + requireValue( + httpUrl(effect.url as string).origin === url.origin, + "request URL changes must keep the same origin", + ); + } else if (effect.type === "mock") { + keys(effect, ["type", "status", "headers", "body", "delay_ms"]); + requireValue( + Number.isInteger(effect.status) && + (effect.status as number) >= 200 && + (effect.status as number) <= 599 && + ![301, 302, 303, 304, 305, 306, 307, 308].includes(effect.status as number), + "mock status must be 200..599, excluding redirects/304", + ); + body(effect.body); + requireValue( + ![204, 205].includes(effect.status as number) || effect.body === "", + "204/205 responses must have an empty body", + ); + if (effect.headers !== undefined) { + const headers = checkedHeaders(effect.headers, true); + requireValue( + Object.values(headers).every((value) => value !== null), + "mock headers cannot be null", + ); + } + requireValue( + effect.delay_ms === undefined || + (Number.isInteger(effect.delay_ms) && + (effect.delay_ms as number) >= 0 && + (effect.delay_ms as number) <= 10000), + "delay_ms must be 0..10000", + ); + } else throw new Error("effect.type must be block, modify or mock"); + requireValue(JSON.stringify(input).length <= 80 * 1024, "rule too large"); + const normalized = structuredClone(input) as unknown as DebugRuleSpec; + normalized.match.url = url.href; + return normalized; +} +export function validateReplay(input: unknown): DebugReplaySpec { + requireValue(own(input), "replay options with a unique key are required"); + keys(input, ["key", "url", "method", "headers", "body"]); + requireValue( + typeof input.key === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(input.key), + "replay key must be 1..80 letters, digits, _ or -", + ); + validateEdit(input); + return structuredClone(input) as unknown as DebugReplaySpec; +} +export function urlMatcher(pattern: string): RegExp { + return new RegExp( + `^${pattern + .split("*") + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*")}$`, + ); +} +export interface LiveRequest { + url: string; + method: string; + headers: Record; + postData?: string; + hasPostData?: boolean; +} +export function editRequest(request: LiveRequest, edit: DebugRequestEdit): LiveRequest { + const headers = Object.fromEntries( + Object.entries(request.headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + for (const [key, value] of Object.entries(checkedHeaders(edit.headers ?? {}))) { + if (value === null) delete headers[key]; + else + Object.defineProperty(headers, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + let postData = edit.body ?? request.postData; + if (edit.json) { + requireValue( + postData !== undefined && + postData.length <= BODY_CHARS && + /json/i.test(headers["content-type"] ?? ""), + "JSON edits require a complete JSON request body", + ); + const source = parseJsonSource(postData); + requireValue(source.kind === "object", "JSON edits require a top-level object"); + const value = new Map( + source.children!.map(({ key, value }) => [key, postData!.slice(value.start, value.end)]), + ); + requireValue( + value.size === source.children!.length, + "JSON edits require unique top-level keys", + ); + for (const [from, to] of Object.entries(edit.json.rename ?? {})) { + requireValue( + value.has(from) && !value.has(to), + "JSON rename source missing or destination already exists", + ); + value.set(to, value.get(from)!); + value.delete(from); + } + for (const key of edit.json.remove ?? []) value.delete(key); + for (const [key, item] of Object.entries(edit.json.set ?? {})) + value.set(key, jsonEditValue(item)); + postData = `{${[...value].map(([key, raw]) => `${JSON.stringify(key)}:${raw}`).join(",")}}`; + } + requireValue(postData === undefined || postData.length <= BODY_CHARS, "request body too large"); + requireValue( + !/multipart\/|octet-stream/i.test(headers["content-type"] ?? "") || + (edit.body === undefined && edit.json === undefined), + "binary/multipart body editing is unsupported", + ); + const method = edit.method ?? request.method; + requireValue(!["GET", "HEAD"].includes(method) || !postData, "GET/HEAD cannot have a body"); + const url = edit.url ?? request.url; + requireValue( + httpUrl(url).origin === httpUrl(request.url).origin, + "request URL changes must keep the same origin", + ); + return { url, method, headers, ...(postData === undefined ? {} : { postData }) }; +} +export function replayRequest( + source: DebugRequest, + spec: DebugReplaySpec, + pageUrl: string, +): LiveRequest { + requireValue( + spec.url !== undefined || source.integrity?.url === "complete", + "source URL is incomplete, redacted or unverified; provide a complete replacement URL", + ); + const url = httpUrl(spec.url ?? source.url); + requireValue( + url.origin === httpUrl(new URL(pageUrl).origin).origin && + new URL(source.url).origin === url.origin, + "replay currently requires the source request and active page to share an origin", + ); + requireValue( + source.integrity ? source.integrity.metadata === "complete" : !source.truncated, + "source request is incomplete; reproduce it to capture a complete request", + ); + requireValue( + !source.intervention || source.intervention.state === "applied", + "source intervention did not complete", + ); + const headers: Record = {}; + for (const [key, value] of Object.entries(source.request_headers ?? {})) { + if (!FORBIDDEN_HEADER.test(key) && key.toLowerCase() !== "set-cookie") + Object.defineProperty(headers, key.toLowerCase(), { value, enumerable: true }); + } + if (spec.body === undefined) + requireValue( + ["available", "empty"].includes(source.request_body.state) && + source.request_body.replay_safe === true && + !source.request_body.redacted && + (source.request_body.state === "empty" || source.request_body.text !== undefined), + "source body is missing, changed or unverified; provide a complete replacement body", + ); + const result = editRequest( + { url: url.href, method: source.method, headers, postData: source.request_body.text }, + spec, + ); + httpUrl(result.url); + for (const [key, value] of Object.entries(result.headers)) + requireValue(!PLACEHOLDER.test(value), `provide or remove redacted header: ${key}`); + if (result.postData !== undefined) body(result.postData); + requireValue( + !/multipart\/|octet-stream/i.test(result.headers["content-type"] ?? ""), + "binary/multipart replay is unsupported", + ); + return result; +} +/** Persist/display a scrubbed definition, never the executable rule object. */ +export function publicRule(spec: DebugRuleSpec): DebugRuleSpec { + const copy = structuredClone(spec); + copy.name = spec.name === undefined ? undefined : redactText(spec.name, 120); + copy.match.url = redactUrl(spec.match.url); + const effect = copy.effect; + if (effect.type === "modify") { + if (effect.url) effect.url = redactUrl(effect.url); + if (effect.headers) + effect.headers = Object.fromEntries( + Object.entries(effect.headers).map(([key, value]) => [ + key, + value === null ? null : redactHeaders({ [key]: value })[key.toLowerCase()], + ]), + ); + if (effect.json?.set) + effect.json.set = JSON.parse( + redactBody(JSON.stringify(effect.json.set), "application/json").text || "{}", + ); + } + if (effect.type === "mock" && effect.headers) effect.headers = redactHeaders(effect.headers); + if (effect.type !== "block" && effect.body !== undefined) { + const mime = + Object.entries(effect.headers ?? {}).find( + ([key]) => key.toLowerCase() === "content-type", + )?.[1] ?? "text/plain"; + effect.body = redactBody(effect.body, mime).text; + } + return copy; +} diff --git a/apps/extension/src/debug/evidence-model.ts b/apps/extension/src/debug/evidence-model.ts new file mode 100644 index 00000000..e19dda5f --- /dev/null +++ b/apps/extension/src/debug/evidence-model.ts @@ -0,0 +1,335 @@ +import { type JsonSource, parseJsonSource } from "./json-source"; +import type { + DebugConsole, + DebugEvidence, + DebugField, + DebugOperation, + DebugPage, + DebugRecording, + DebugRequest, + DebugValue, +} from "./types"; + +export function requestKind(request: DebugRequest): "business" | "resource" | "extension" { + if ( + /^(?:chrome|moz)-extension:/.test(request.url) || + /(?:chrome|moz)-extension:\/\//.test(request.initiator ?? "") + ) + return "extension"; + if ( + ["Fetch", "XHR", "Document"].includes(request.resource_type ?? "") || + !["GET", "HEAD"].includes(request.method) || + /json/.test(request.mime_type ?? "") + ) + return "business"; + return "resource"; +} + +export function consoleSource( + url?: string, + browserSource?: string, +): NonNullable { + if (/^(?:chrome|moz)-extension:/.test(url ?? "")) return "extension"; + if ( + browserSource && + ["deprecation", "intervention", "violation", "security"].includes(browserSource) + ) + return "browser"; + if (/^https?:/.test(url ?? "")) return "website"; + return "unknown"; +} + +/** Derive all associations from the same retained evidence, never the hot cache. */ +export function operationContext(recording: DebugRecording, operation: DebugOperation) { + const { immediate, end, next_start } = operationWindow(recording, operation); + const contains = (at: number) => at >= operation.started_at && at <= end && at < next_start; + const relation = (at: number): "window" | "delayed" => (at <= immediate ? "window" : "delayed"); + const requests = recording.requests.filter((request) => contains(request.started_at)); + const messages = recording.console + .filter((entry) => contains(entry.at)) + .map((entry) => ({ ...entry, relation: relation(entry.at) })); + return { + requests, + console: messages, + links: requests.map((request) => ({ + request_id: request.id, + relation: relation(request.started_at), + })), + operation: { + ...operation, + request_ids: requests.map((request) => request.id), + console_ids: messages.map((entry) => entry.id), + truncated: recording.run.dropped_requests > 0 || recording.run.dropped_console > 0, + }, + }; +} +export function operationWindow( + recording: DebugRecording, + operation: DebugOperation, +): { immediate: number; end: number; next_start: number } { + const index = recording.operations.findIndex((item) => item.id === operation.id); + const next = index >= 0 ? recording.operations[index + 1] : undefined; + const immediate = + operation.window_end ?? + operation.finished_at ?? + (operation.state === "running" ? recording.saved_at : operation.started_at); + const end = Math.min( + operation.observation_end ?? immediate, + next?.started_at ?? Infinity, + recording.run.stopped_at ?? Infinity, + ); + return { immediate, end, next_start: next?.started_at ?? Infinity }; +} + +function fields(page?: DebugPage): DebugField[] { + return page?.fields ?? []; +} +function uniqueField(page: DebugPage | undefined, key: string): DebugField | undefined { + const matches = fields(page).filter((field) => field.key === key); + return matches.length === 1 ? matches[0] : undefined; +} +function value( + field: DebugField | undefined, + page: DebugPage | undefined, + source: string, +): DebugValue { + return field + ? { state: field.state, value: field.value, source, at: page?.at } + : { state: "not_recorded" }; +} + +interface Leaf { + path: string; + name: string; + value: string; + truncated: boolean; +} +function payload(request: DebugRequest, part: "request" | "response"): Leaf[] { + const body = part === "request" ? request.request_body : request.response_body; + if (body.state !== "available" || !body.text || body.text.length > 65536) return []; + const result: Leaf[] = []; + const text = body.text; + const visit = (data: JsonSource, path: string, name: string, depth: number) => { + if (depth > 5 || result.length >= 32) return; + if (data.children) { + if (data.kind === "array") return; // Array indices and repeated names are not field identities. + for (const { key, value } of data.children.slice(0, 32)) + visit(value, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, key, depth + 1); + } else { + const raw = text.slice(data.start, data.end); + const value: string = data.kind === "string" ? JSON.parse(raw) : raw; + result.push({ + path, + name, + value: value.slice(0, 256), + truncated: value.length > 256, + }); + } + }; + try { + visit(parseJsonSource(text), "", "", 0); + } catch { + if ( + part === "request" && + /x-www-form-urlencoded/.test(request.request_headers?.["content-type"] ?? "") + ) { + for (const [name, raw] of new URLSearchParams(body.text)) { + if (result.length === 32) break; + result.push({ name, path: name, value: raw.slice(0, 256), truncated: raw.length > 256 }); + } + } + } + return result; +} + +/** Conservative, deterministic projections of retained facts; no semantic/causal inference. */ +export function operationEvidence( + record: DebugRecording, + operation: DebugOperation, + context = operationContext(record, operation), +): DebugEvidence { + const { links, requests } = context; + const gaps = new Set( + record.run.coverage.filter((value) => value.startsWith("evidence_")), + ); + if (operation.state === "running") gaps.add("operation_running"); + if (operation.state === "interrupted") gaps.add("operation_interrupted"); + if (record.run.dropped_requests || record.run.dropped_operations || record.run.dropped_console) + gaps.add("capacity_limit"); + if (record.run.coverage.includes("initial_load_not_recorded")) + gaps.add("initial_load_not_recorded"); + if (record.run.coverage.includes("manual_capture_unavailable")) + gaps.add("manual_capture_unavailable"); + if (record.run.coverage.includes("interrupted_checkpoint")) gaps.add("interrupted_checkpoint"); + if (operation.observation_limited) gaps.add("observation_limit"); + if (operation.before?.state !== "available") gaps.add("before_unavailable"); + if (!operation.before?.fields || operation.before.fields_partial) gaps.add("fields_partial"); + if (!operation.after || operation.after.state !== "available") gaps.add("after_unavailable"); + if (operation.before?.truncated || operation.after?.truncated) gaps.add("page_truncated"); + for (const request of requests.filter((item) => requestKind(item) === "business")) { + if (request.intervention) gaps.add(`control_${request.intervention.type}`); + if (request.intervention?.state === "pending") gaps.add("control_pending"); + if (request.intervention && ["failed", "cancelled"].includes(request.intervention.state)) + gaps.add("control_failed"); + if (request.replay_from) gaps.add("request_replayed"); + if (request.state === "pending") gaps.add("request_pending"); + if (request.state === "interrupted") gaps.add("request_interrupted"); + for (const body of [request.request_body, request.response_body]) { + if (!["empty", "available"].includes(body.state)) gaps.add(`body_${body.state}`); + } + } + const prior = record.operations.slice( + 0, + Math.max( + 0, + record.operations.findIndex((item) => item.id === operation.id), + ), + ); + const boundary = prior.findLastIndex( + (item) => !["tool.fill", "tool.select"].includes(item.method), + ); + const inputs = prior.slice(boundary + 1); + if (["tool.fill", "tool.select"].includes(operation.method)) inputs.push(operation); + const inputPage = + [operation.before, ...inputs.flatMap((item) => [item.before, item.after])] + .filter((page): page is DebugPage => !!page) + .sort((a, b) => a.at - b.at) + .at(-1) ?? operation.before; + const nextLoad = record.pages.find( + (page) => + page.at > operation.started_at && + !!page.navigation && + fields(page).some((field) => fields(inputPage).some((input) => input.key === field.key)), + ); + if ( + nextLoad && + record.operations.some( + (item) => + item.started_at > operation.started_at && + item.started_at < nextLoad.at && + !["tool.navigate", "tool.reload"].includes(item.method), + ) + ) + gaps.add("intervening_operations"); + if ( + fields(inputPage).some( + (field) => + field.state === "truncated" || + fields(inputPage).filter((item) => item.key === field.key).length > 1, + ) + ) + gaps.add("fields_partial"); + const navigation = nextLoad + ? record.operations.findLast( + (item) => + item.started_at > operation.started_at && + item.started_at <= nextLoad.at && + ["tool.navigate", "tool.reload"].includes(item.method), + ) + : undefined; + const loadedPage = + nextLoad && navigation?.after?.state === "available" && navigation.after.at >= nextLoad.at + ? { ...navigation.after, navigation: nextLoad.navigation } + : nextLoad; + const observations = [...(operation.observations ?? []), ...(loadedPage ? [loadedPage] : [])] + .sort((a, b) => a.at - b.at) + .slice(-9); + const laterPage = loadedPage ?? observations.at(-1) ?? operation.after; + const leaves = requests + .filter( + (item) => + requestKind(item) === "business" && + !item.replay_from && + (!item.intervention || + (item.intervention.state === "applied" && item.intervention.type !== "block")), + ) + .slice(0, 12) + .flatMap((request) => + (["request", "response"] as const).map((part) => ({ + request, + part, + leaves: payload(request, part), + })), + ); + const payloads: DebugEvidence["payloads"] = leaves + .flatMap(({ request, part, leaves }) => + leaves.map((leaf) => ({ + request_id: request.id, + part, + path: leaf.path, + value: leaf.value, + ...(leaf.truncated ? { truncated: true } : {}), + })), + ) + .slice(0, 96); + const traces = fields(inputPage) + .filter((field, _index, all) => all.filter((item) => item.key === field.key).length === 1) + .slice(0, 16) + .map((field) => { + const originalPage = + inputs.find((item) => uniqueField(item.before, field.key))?.before ?? operation.before; + const exact = (part: "request" | "response"): DebugValue[] => + leaves + .filter((item) => item.part === part) + .flatMap((item) => { + const matches = item.leaves.filter((leaf) => leaf.name === field.name); + const body = + part === "request" ? item.request.request_body : item.request.response_body; + if (!["available", "empty"].includes(body.state)) + return [{ state: `body_${body.state}`, source: item.request.id }]; + return matches.length === 1 + ? [ + { + state: + matches[0].value === "[redacted]" + ? "redacted" + : matches[0].truncated + ? "truncated" + : "available", + value: matches[0].value, + source: `${item.request.id} ${matches[0].path}`, + at: part === "request" ? item.request.started_at : item.request.finished_at, + }, + ] + : []; + }); + return { + key: field.key, + label: field.label, + before: value(uniqueField(originalPage, field.key), originalPage, "before"), + input: value(field, inputPage, "input"), + submitted: exact("request"), + response: exact("response"), + later: value( + uniqueField(laterPage, field.key), + laterPage, + nextLoad ? `page:${nextLoad.navigation}` : "after", + ), + }; + }); + const comparable = + operation.before?.state === "available" && + operation.after?.state === "available" && + typeof operation.before.text === "string" && + typeof operation.after.text === "string"; + const before = (comparable ? (operation.before?.text ?? "") : "").split("\n").filter(Boolean); + const after = (comparable ? (operation.after?.text ?? "") : "").split("\n").filter(Boolean); + const added = after.filter((line) => !before.includes(line)); + const removed = before.filter((line) => !after.includes(line)); + return { + fields: traces, + payloads, + links, + gaps: [...gaps], + changes: { + added: added.slice(0, 16), + removed: removed.slice(0, 16), + truncated: + added.length > 16 || + removed.length > 16 || + !!operation.before?.truncated || + !!operation.after?.truncated, + }, + observations, + }; +} diff --git a/apps/extension/src/debug/journal.ts b/apps/extension/src/debug/journal.ts new file mode 100644 index 00000000..803d7dfe --- /dev/null +++ b/apps/extension/src/debug/journal.ts @@ -0,0 +1,109 @@ +import type { DebugArchive } from "./archive"; +import type { DebugRequest, DebugRun } from "./types"; + +export const JOURNAL_REQUESTS = 2000; +export const JOURNAL_BYTES = 8 * 1024 * 1024; +export const JOURNAL_PINS = 20; +const PENDING_BYTES = 4 * 1024 * 1024; +const PENDING_COUNT = 256; +export const jsonBytes = (value: unknown): number => + new TextEncoder().encode(JSON.stringify(value)).byteLength; + +/** Shared by persisted indexes and detail projections; no browser dependencies. */ +export function requestMetadata(entry: DebugRequest): DebugRequest { + const { text: _request, ...request_body } = entry.request_body; + const { text: _response, ...response_body } = entry.response_body; + const { + request_headers: _headers, + response_headers: _responseHeaders, + timing: _timing, + ...rest + } = entry; + return { ...rest, request_body, response_body }; +} + +/** Memory eviction must not erase evidence already captured or saved. */ +export function mergeRequest( + previous: DebugRequest | undefined, + current: DebugRequest, +): DebugRequest { + if (!previous) return current; + const merged = { ...previous, ...current, pinned: previous.pinned ?? current.pinned }; + for (const key of ["request_body", "response_body"] as const) + if ( + (current[key].state === "evicted" && previous[key].state !== "pending") || + (current[key].state === previous[key].state && + current[key].text === undefined && + previous[key].text !== undefined) + ) + merged[key] = previous[key]; + merged.request_headers = current.request_headers ?? previous.request_headers; + merged.response_headers = current.response_headers ?? previous.response_headers; + return merged; +} +export function retentionPriority(entry: DebugRequest): number { + if (entry.pinned) return 9; + if ( + entry.state === "failed" || + (entry.status ?? 0) >= 400 || + entry.intervention || + entry.replay_id + ) + return 4; + return ["Fetch", "XHR"].includes(entry.resource_type ?? "") || /json/i.test(entry.mime_type ?? "") + ? 2 + : 0; +} + +/** One bounded pending batch and one transaction in flight; never blocks CDP. */ +export class DebugJournal { + private pending = new Map(); + private bytes = 0; + private timer?: ReturnType; + private writing?: Promise; + constructor( + private readonly archive: DebugArchive, + private readonly run: () => DebugRun, + private readonly failed: (reason: string) => void, + ) {} + retain = (value: DebugRequest): void => { + const entry = structuredClone(mergeRequest(this.pending.get(value.id)?.entry, value)); + const bytes = jsonBytes(entry); + const previous = this.pending.get(entry.id); + if ( + (!previous && this.pending.size >= PENDING_COUNT) || + this.bytes - (previous?.bytes ?? 0) + bytes > PENDING_BYTES + ) { + this.failed("evidence_write_backlog"); + if (!this.writing) void this.flush(); + return; + } + this.bytes += bytes - (previous?.bytes ?? 0); + this.pending.set(entry.id, { entry, bytes }); + if (!this.timer) + this.timer = setTimeout(() => { + this.timer = undefined; + void this.flush(); + }, 100); + if (this.pending.size >= 32 && !this.writing) void this.flush(); + }; + async flush(): Promise { + clearTimeout(this.timer); + this.timer = undefined; + if (this.writing) { + await this.writing; + return this.flush(); + } + if (!this.pending.size) return; + const entries = [...this.pending.values()].map(({ entry }) => entry); + this.pending.clear(); + this.bytes = 0; + const writing = this.archive.retain!(this.run(), entries).catch(() => + this.failed("evidence_write_failed"), + ); + this.writing = writing; + await writing; + if (this.writing === writing) this.writing = undefined; + if (this.pending.size) await this.flush(); + } +} diff --git a/apps/extension/src/debug/json-source.ts b/apps/extension/src/debug/json-source.ts new file mode 100644 index 00000000..6d5e67af --- /dev/null +++ b/apps/extension/src/debug/json-source.ts @@ -0,0 +1,60 @@ +/** JSON structure backed by source ranges. Numbers never pass through Number. */ +export interface JsonSource { + start: number; + end: number; + kind: "object" | "array" | "string" | "primitive"; + children?: { key: string; value: JsonSource }[]; +} + +export function parseJsonSource(text: string): JsonSource { + // Let the platform validate grammar; discard its potentially rounded values. + JSON.parse(text); + const tokens = text.matchAll(/"(?:[^"\\]|\\.)*"|[{}\[\]:,]|[^\s{}\[\]:,]+/g); + let token = tokens.next().value!; + const next = () => { + token = tokens.next().value!; + }; + const read = (depth: number): JsonSource => { + if (depth > 64) throw new Error("JSON structure exceeds depth limit"); + const raw = token[0]; + const node: JsonSource = { + start: token.index!, + end: token.index! + raw.length, + kind: + raw === "{" ? "object" : raw === "[" ? "array" : raw[0] === '"' ? "string" : "primitive", + }; + next(); + if (node.kind === "object" || node.kind === "array") { + node.children = []; + const end = node.kind === "object" ? "}" : "]"; + while (token[0] !== end) { + let key = String(node.children.length); + if (node.kind === "object") { + key = JSON.parse(token[0]); + next(); // colon + next(); // value + } + node.children.push({ key, value: read(depth + 1) }); + if (token[0] === ",") next(); + } + node.end = token.index! + 1; + next(); + } + return node; + }; + return read(0); +} + +export function jsonPointer(text: string, pointer: string): string { + if (pointer !== "" && !pointer.startsWith("/")) + throw new Error("pointer must be an RFC 6901 JSON pointer"); + let node = parseJsonSource(text); + for (const token of pointer === "" ? [] : pointer.slice(1).split("/")) { + if (/~(?:[^01]|$)/.test(token)) throw new Error("invalid JSON pointer escape"); + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + const child = node.children?.findLast((child) => child.key === key); + if (!child) throw new Error("JSON pointer not found"); + node = child.value; + } + return text.slice(node.start, node.end); +} diff --git a/apps/extension/src/debug/manager.ts b/apps/extension/src/debug/manager.ts new file mode 100644 index 00000000..7216a262 --- /dev/null +++ b/apps/extension/src/debug/manager.ts @@ -0,0 +1,1385 @@ +import { + type CdpDebuggee, + parseConsoleApiCalled, + parseExceptionThrown, + parseLogEntry, +} from "@/browser-driver/chromium-cdp"; +import { + isAgentControlledTab, + type SessionContext, + type SessionManager, +} from "@/session-manager/manager"; +import type { CdpRunner, ChromeTabsApi } from "@/tools/shared"; +import type { RequestFrame } from "@/transport/types"; +import { type DebugArchive, HISTORY_LIMIT } from "./archive"; +import { debugCapabilities } from "./capabilities"; +import { consoleSource } from "./evidence-model"; +import { DebugJournal, mergeRequest } from "./journal"; +import { DebugNetworkControl } from "./network-control"; +import { DebugNetworkStore, requestProjection } from "./network-store"; +import { DebugObserver, OBSERVATION_TIMEOUT_MS, sanitizeFields } from "./observer"; +import { interruptPerformance, PERFORMANCE_LIMIT, performanceSnapshot } from "./performance"; +import { readRecording } from "./recording"; +import { redactText, redactUrl } from "./redact"; +import type { + DebugConsole, + DebugOperation, + DebugPage, + DebugParams, + DebugPerformance, + DebugRecording, + DebugResult, + DebugRun, + DebugTask, +} from "./types"; + +export const DEBUG_START_TIMEOUT_MS = 10000; +const MAX_RUNS = 4; +const MAX_OPERATIONS = 64; +const MAX_CONSOLE = 100; +const WINDOW_MS = 1500; +const OBSERVE_MS = 15000; +const ACTIONS = new Set([ + "tool.navigate", + "tool.navigate_back", + "tool.navigate_forward", + "tool.reload", + "tool.click", + "tool.fill", + "tool.press", + "tool.select", + "tool.hover", + "tool.evaluate", + "tool.wheel", + "tool.scroll_to", + "tool.focus", + "tool.blur", +]); + +export interface DebugCdp extends CdpRunner { + ensureNetworkCapture(tabId: number): Promise; + sendAttached( + target: CdpDebuggee & { tabId: number }, + method: string, + params?: object, + ): Promise; + getFrameGraph?: CdpRunner["getFrameGraph"]; +} +interface RunState { + run: DebugRun; + owner: SessionContext; + network: DebugNetworkStore; + controls?: DebugNetworkControl; + journal?: DebugJournal; + controlCleanup?: Promise; + operations: DebugOperation[]; + console: DebugConsole[]; + timer?: ReturnType; + current?: DebugOperation; + nextOperation: number; + nextConsole: number; + targets: Set; + pages: DebugPage[]; + performance: DebugPerformance[]; + nextPerformance: number; + pagePending?: boolean; + released?: boolean; + archiveTimer?: ReturnType; + dirty?: boolean; + saving?: Promise; + observer?: DebugObserver; + observing?: boolean; + agentBusy?: boolean; + checkpoints?: ReturnType[]; +} +export interface DebugTicket { + run: RunState; + operation: DebugOperation; +} + +async function deadline(promise: Promise, ms: number, signal?: AbortSignal): Promise { + let timer: ReturnType | undefined; + let abort: (() => void) | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("debug observation timeout")), ms); + abort = () => + reject( + signal?.reason instanceof Error + ? signal.reason + : new Error("debug observation cancelled"), + ); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + }), + ]); + } finally { + clearTimeout(timer); + if (abort) signal?.removeEventListener("abort", abort); + } +} + +/** Bounded live capture with browser-local checkpoints, independent of audit. */ +export class DebugManager { + private readonly runs = new Map(); + // Only lightweight ownership outlives the live cache. Object identity prevents + // a reused short session ID from inheriting another task's saved evidence. + private readonly ownedRuns = new WeakMap>(); + private subscription?: { dispose(): void }; + private readonly starting = new Set(); + + constructor( + private readonly sessions: SessionManager, + private readonly cdp: DebugCdp, + private readonly tabs: ChromeTabsApi, + private readonly now: () => number = Date.now, + private readonly archive?: DebugArchive, + ) {} + + private owned(sessionId: string, tabId: number): boolean { + const context = this.sessions.get(sessionId); + return context !== null && isAgentControlledTab(context, tabId); + } + private active(sessionId: string, tabId?: number): RunState | undefined { + return [...this.runs.values()].find( + ({ run, owner }) => + run.session_id === sessionId && + owner === this.sessions.get(sessionId) && + run.state === "capturing" && + (tabId === undefined || run.tab_id === tabId), + ); + } + private change(state: RunState): number { + const sequence = ++state.run.next_since; + if ( + state.current && + this.now() <= (state.current.observation_end ?? state.current.window_end ?? this.now()) + ) + state.current.sequence = sequence; + this.scheduleSave(state); + return sequence; + } + + async start( + sessionId: string, + tabId: number, + name = "", + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) throw new Error("debug start cancelled"); + this.sync(); + if (!this.owned(sessionId, tabId)) throw new Error("tab is not owned by this task"); + const owner = this.sessions.get(sessionId)!; + const key = `${sessionId}:${tabId}`; + if (this.starting.has(key)) throw new Error("debug capture is already starting"); + const existing = this.active(sessionId, tabId); + if (existing) return this.summary(existing); + if (!this.cdp.onEvent) throw new Error("debug event capture is unavailable"); + if (this.active(sessionId)) + throw new Error("stop the task's current capture before selecting another tab"); + this.starting.add(key); + const startup = new AbortController(); + let rollback = (_reason: string) => {}; + const cancel = (reason: string) => { + // Stop retaining events synchronously, even if Chrome never resolves its command. + rollback(reason); + startup.abort( + new Error(reason === "cancelled" ? "debug start cancelled" : "debug start timeout"), + ); + }; + const abort = () => cancel("cancelled"); + signal?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(() => cancel("start_timeout"), DEBUG_START_TIMEOUT_MS); + const wait = (promise: Promise) => + deadline(promise, DEBUG_START_TIMEOUT_MS, startup.signal); + try { + while (this.runs.size >= MAX_RUNS) { + const stopped = [...this.runs.values()].find(({ run }) => run.state === "stopped"); + if (!stopped) throw new Error("debug capture limit reached; stop another capture first"); + await wait(Promise.resolve(stopped.controlCleanup)); + await wait(this.persist(stopped)); + if (stopped.run.storage_error) + throw new Error( + "debug history save failed; export the stopped capture before starting another", + ); + this.runs.delete(stopped.run.id); + } + await wait( + Promise.all( + [...this.runs.values()] + .filter((item) => item.run.tab_id === tabId) + .map((item) => item.controlCleanup), + ), + ); + const id = `d${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`; + const run: DebugRun = { + id, + session_id: sessionId, + tab_id: tabId, + name: redactText(name, 120), + url: "", + started_at: this.now(), + state: "capturing", + requests: 0, + operations: 0, + errors: 0, + dropped_requests: 0, + dropped_operations: 0, + dropped_console: 0, + next_since: 0, + coverage: [ + "from_start", + "task_tab", + "text_bodies_bounded", + "time_window_not_causality", + "page_main_frame", + "worker_targets_not_captured", + "manual_main_frame_only", + ], + environment: { + ...(typeof __BSK_EXT_VERSION__ === "string" + ? { extension_version: __BSK_EXT_VERSION__ } + : {}), + ...(typeof navigator !== "undefined" + ? { user_agent: redactText(navigator.userAgent, 300) } + : {}), + }, + }; + const network = new DebugNetworkStore( + id, + { + send: async (targetTab: number, method: string, params?: object) => { + if (!this.owned(sessionId, targetTab) || state.run.state !== "capturing") + throw new Error("capture stopped"); + return deadline(this.cdp.sendAttached({ tabId: targetTab }, method, params), 2500); + }, + sendToTarget: async ( + target: CdpDebuggee & { tabId: number }, + method: string, + params?: object, + ) => { + if (!this.owned(sessionId, target.tabId) || state.run.state !== "capturing") + throw new Error("capture stopped"); + return deadline(this.cdp.sendAttached(target, method, params), 2500); + }, + }, + () => this.change(state), + this.now, + (entry) => state.journal?.retain(entry), + ); + const state: RunState = { + run, + owner, + network, + operations: [], + console: [], + nextOperation: 0, + nextConsole: 0, + targets: new Set(), + pages: [], + performance: [], + nextPerformance: 0, + }; + if (this.archive?.retain) + state.journal = new DebugJournal( + this.archive, + () => this.summary(state), + (reason) => this.coverage(state, reason), + ); + state.controls = new DebugNetworkControl( + id, + tabId, + this.cdp, + network, + () => { + this.change(state); + }, + () => state.run.state === "capturing" && this.owned(sessionId, tabId), + this.now, + ); + rollback = (reason) => this.stopState(state, reason); + this.runs.set(id, state); + let ownedRuns = this.ownedRuns.get(state.owner); + if (!ownedRuns) this.ownedRuns.set(state.owner, (ownedRuns = new Map())); + ownedRuns.set(id, tabId); + while (ownedRuns.size > HISTORY_LIMIT + MAX_RUNS) + ownedRuns.delete(ownedRuns.keys().next().value!); + this.subscription ??= this.cdp.onEvent?.((source, method, params) => + this.onEvent(source, method, params), + ); + try { + if (this.sessions.get(sessionId) !== owner) + throw new Error("task ended during debug start"); + const tab = await wait(this.tabs.get(tabId)); + if (tab.windowId !== this.sessions.get(sessionId)?.agentWindowId) + throw new Error("debug tab must remain in its Agent Window"); + if (!this.runs.has(id) || state.run.state !== "capturing") + throw new Error("capture stopped during debug start"); + this.cdp.trackSessionTab?.(sessionId, tabId); + await wait(this.cdp.ensureNetworkCapture(tabId)); + if (!this.owned(sessionId, tabId) || !this.runs.has(id)) + throw new Error("task ended during debug start"); + if (state.run.state !== "capturing") return this.summary(state); + await wait(this.enableTarget(state, { tabId }, startup.signal)); + if (state.run.state !== "capturing") return this.summary(state); + const graph = await wait( + Promise.resolve( + this.cdp.getFrameGraph?.(tabId).catch(() => { + this.coverage(state, "child_capture_partial"); + return undefined; + }), + ), + ); + if (graph) { + // Frame graph discovery already belongs to the driver. Debug adds only + // bounded Network/Runtime listeners on its existing child attachments. + const targets = new Map(); + for (const frame of graph.frames) + if (frame.target.sessionId) targets.set(frame.target.sessionId, frame.target); + await wait( + Promise.all( + [...targets.values()] + .slice(0, 16) + .map((target) => + this.enableTarget(state, target, startup.signal).catch(() => + this.coverage(state, "child_capture_partial"), + ), + ), + ), + ); + } + run.url = redactUrl((await wait(this.tabs.get(tabId))).url ?? ""); + if (!this.owned(sessionId, tabId) || !this.runs.has(id)) + throw new Error("task ended during debug start"); + if (/^https?:/.test(run.url)) this.coverage(state, "initial_load_not_recorded"); + state.observer = new DebugObserver(this.cdp, tabId, id); + await wait( + deadline(state.observer.start(), 2500, startup.signal).catch((error) => { + if (startup.signal.aborted) throw error; + this.coverage(state, "manual_capture_unavailable"); + this.coverage(state, "performance_capture_unavailable"); + void state.observer?.dispose(); + }), + ); + await wait(this.capturePage(state)); + await wait(this.persist(state)); + return this.summary(state); + } catch (error) { + this.stopState(state, "start_failed"); + clearTimeout(state.archiveTimer); + this.runs.delete(id); + ownedRuns.delete(id); + this.pruneListener(); + throw error; + } + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + this.starting.delete(key); + } + } + + private async enableTarget( + state: RunState, + target: CdpDebuggee & { tabId: number }, + signal?: AbortSignal, + ): Promise { + const key = target.sessionId ?? "root"; + if ( + state.targets.has(key) || + state.run.state !== "capturing" || + !this.owned(state.run.session_id, target.tabId) + ) + return; + if (state.targets.size >= 17) { + this.coverage(state, "child_capture_limit"); + return; + } + state.targets.add(key); + try { + await deadline( + this.cdp.sendAttached(target, "Network.enable", { + maxTotalBufferSize: 2 * 1024 * 1024, + maxResourceBufferSize: 256 * 1024, + maxPostDataSize: 64 * 1024, + }), + 2500, + signal, + ); + if ( + target.sessionId && + state.run.state === "capturing" && + this.owned(state.run.session_id, target.tabId) + ) + await deadline(this.cdp.sendAttached(target, "Runtime.enable"), 2500, signal); + if (state.run.state === "capturing" && !signal?.aborted) + await deadline(Promise.resolve(state.controls?.target(target)), 2500, signal); + } catch (error) { + state.targets.delete(key); + throw error; + } + } + + private coverage(state: RunState, reason: string): void { + if (!state.run.coverage.includes(reason)) { + state.run.coverage.push(reason); + this.change(state); + } + } + + private onEvent(source: CdpDebuggee, method: string, params: unknown): void { + if (typeof source.tabId !== "number") return; + for (const state of this.runs.values()) { + if (state.run.tab_id !== source.tabId) continue; + if (state.run.state !== "capturing") { + if (state.controlCleanup) + state.controls?.onEvent({ ...source, tabId: source.tabId }, method, params); + continue; + } + if (!this.owned(state.run.session_id, source.tabId)) { + this.stopState(state, "tab_released"); + continue; + } + if (!source.sessionId) { + state.observer?.contextEvent(method, params); + if ( + method === "Page.frameStartedLoading" && + state.observer?.isRoot((params as { frameId?: string }).frameId) && + !state.agentBusy + ) + this.manualEvent(state, JSON.stringify({ kind: "navigate", at: this.now() })); + if (method === "Runtime.bindingCalled") { + const event = params as { name?: string; executionContextId?: number; payload?: string }; + if ( + state.observer?.accepts(event) && + typeof event.payload === "string" && + event.payload.length < 65536 + ) + this.manualEvent(state, event.payload); + continue; + } + } + if (method === "Target.attachedToTarget") { + const child = params as { sessionId?: string; targetInfo?: { type?: string } }; + if (child.sessionId && child.targetInfo?.type === "iframe") + void this.enableTarget(state, { tabId: source.tabId, sessionId: child.sessionId }).catch( + () => this.coverage(state, "child_capture_partial"), + ); + } + if (method === "Target.detachedFromTarget") { + const child = params as { sessionId?: string }; + if (child.sessionId) { + state.targets.delete(child.sessionId); + state.network.detachTarget(child.sessionId); + state.controls?.detach(child.sessionId); + } + } + if (!source.sessionId && method === "Page.loadEventFired") void this.capturePage(state, true); + state.network.onEvent({ ...source, tabId: source.tabId }, method, params); + state.controls?.onEvent({ ...source, tabId: source.tabId }, method, params); + if (method === "Network.loadingFinished") this.scheduleObservation(state); + const parsed = + method === "Runtime.consoleAPICalled" + ? parseConsoleApiCalled(params) + : method === "Runtime.exceptionThrown" + ? parseExceptionThrown(params) + : method === "Log.entryAdded" + ? parseLogEntry(params) + : null; + if (!parsed || (parsed.timestamp !== undefined && parsed.timestamp < state.run.started_at)) + continue; + const at = this.now(); + const text = redactText(parsed.text, 2048); + const sourceUrl = parsed.url || parsed.stack_trace?.find((frame) => frame.url)?.url; + const origin = consoleSource( + sourceUrl, + (params as { entry?: { source?: string } })?.entry?.source, + ); + const stack = parsed.stack_trace + ?.map( + (frame) => + `${frame.function_name ?? ""} ${redactUrl(frame.url ?? "")}:${frame.line ?? ""}:${frame.column ?? ""}`, + ) + .join("\n") + .slice(0, 4096); + // Coalesce only adjacent repeats within one action window. + const last = state.console.at(-1); + if ( + last && + last.text === text && + last.stack === stack && + last.level === parsed.level && + last.source === origin && + last.source_url === sourceUrl && + at - last.last_at < 1000 && + (!state.current || last.at >= state.current.started_at) + ) { + last.count += 1; + last.last_at = at; + } else { + state.console.push({ + id: `${state.run.id}:c${++state.nextConsole}`, + at, + last_at: at, + level: parsed.level, + text, + count: 1, + source: origin, + ...(sourceUrl ? { source_url: redactUrl(sourceUrl) } : {}), + ...(stack ? { stack } : {}), + }); + if (state.console.length > MAX_CONSOLE) { + state.console.shift(); + state.run.dropped_console += 1; + } + } + this.change(state); + } + } + + async before(req: RequestFrame, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + if (!ACTIONS.has(req.method)) return; + const params = req.params as { + session_id?: string; + tab_id?: number; + ref?: string; + selector?: string; + }; + if (!params?.session_id || !this.active(params.session_id)) return; + const context = this.sessions.get(params.session_id); + if (!context) return; + const tabId = + params.tab_id ?? + ( + await deadline( + this.tabs.query({ windowId: context.agentWindowId, active: true }), + OBSERVATION_TIMEOUT_MS, + signal, + ) + )[0]?.id; + if (tabId === undefined || !this.owned(params.session_id, tabId)) return; + const state = this.active(params.session_id, tabId); + if (!state) return; + await deadline( + state.observer?.call("agent", true) ?? Promise.resolve(), + OBSERVATION_TIMEOUT_MS, + signal, + ).catch(() => { + this.coverage(state, "manual_capture_unavailable"); + void state.observer?.call("agent", false).catch(() => {}); + }); + if ( + signal?.aborted || + state.run.state !== "capturing" || + this.sessions.get(params.session_id) !== context + ) { + void state.observer?.call("agent", false).catch(() => {}); + return; + } + state.agentBusy = true; + clearTimeout(state.timer); + state.timer = undefined; + state.checkpoints?.forEach(clearTimeout); + const now = this.now(); + const previous = state.current; + if (previous) { + previous.window_end = Math.min(previous.window_end ?? now, now); + previous.observation_end = Math.min(previous.observation_end ?? now, now); + } + const ref = params.ref ? context.refStore.resolveEntry(params.ref) : undefined; + const target = ref?.kind === "dom" ? ref.name : params.selector; + const operation: DebugOperation = { + id: `${state.run.id}:a${++state.nextOperation}`, + run_id: state.run.id, + sequence: this.change(state), + method: req.method, + source: "agent", + ...(target ? { target: redactText(target, 160) } : {}), + started_at: now, + state: "running", + request_ids: [], + console_ids: [], + truncated: false, + }; + state.operations.push(operation); + state.current = operation; + if (state.operations.length > MAX_OPERATIONS) { + state.operations.shift(); + state.run.dropped_operations += 1; + } + try { + operation.before = await deadline(this.page(state), OBSERVATION_TIMEOUT_MS, signal); + } catch { + this.coverage(state, "page_snapshot_unavailable"); + } + if (signal?.aborted) { + this.after({ run: state, operation }, "debug observation cancelled"); + return; + } + if ( + previous && + !previous.after && + previous.finished_at !== undefined && + now - previous.finished_at <= WINDOW_MS + ) { + previous.after = operation.before; + previous.sequence = this.change(state); + } + // Clock starts immediately before page input, after the passive pre-read. + operation.started_at = this.now(); + return { run: state, operation }; + } + + after(ticket: DebugTicket | undefined, error?: string): void { + if (!ticket) return; + const { run: state, operation } = ticket; + if (state.run.state !== "capturing") return; + state.agentBusy = false; + void state.observer?.call("agent", false).catch(() => {}); + operation.finished_at = this.now(); + operation.window_end = operation.finished_at + WINDOW_MS; + operation.observation_end = operation.finished_at + OBSERVE_MS; + operation.state = error ? "error" : "completed"; + if (error) operation.error = redactText(error, 1024); + operation.sequence = this.change(state); + this.startObservation(state); + } + + private manualEvent(state: RunState, payload: string): void { + let event: { + kind?: string; + at?: number; + target?: string; + before?: unknown; + after?: unknown; + data?: unknown; + }; + try { + event = JSON.parse(payload); + if (event?.kind === "performance") { + this.capturePerformance(state, event.data); + return; + } + } catch { + return; + } + if (!event || typeof event !== "object") return; + if (event.kind === "performance_error") { + this.coverage(state, "performance_capture_unavailable"); + return; + } + if (event.kind === "changed") { + this.scheduleObservation(state); + return; + } + if ( + state.agentBusy || + !["input_start", "input", "click", "submit", "navigate"].includes(event.kind ?? "") + ) + return; + const now = this.now(); + const at = + typeof event.at === "number" && Number.isFinite(event.at) + ? Math.min(now, Math.max(state.run.started_at, event.at)) + : now; + const previous = state.current; + if ( + event.kind === "input" && + previous?.source === "human" && + previous.method === "tool.fill" && + previous.started_at === at && + previous.state === "running" + ) { + previous.after = { ...previous.before!, at: now, ...sanitizeFields(event.after) }; + previous.state = "completed"; + previous.finished_at = now; + previous.window_end = now + WINDOW_MS; + previous.observation_end = now + OBSERVE_MS; + previous.sequence = this.change(state); + this.startObservation(state); + return; + } + if ( + event.kind === "navigate" && + previous?.source === "human" && + at - previous.started_at < 1000 + ) { + if (previous.method === "tool.navigate") { + if (event.before) { + previous.before = { + at, + state: "available", + url: state.run.url, + ...sanitizeFields(event.before), + }; + previous.sequence = this.change(state); + } + return; + } + if (previous.method === "tool.click" && at - previous.started_at < 250) return; + } + // A submit caused by the same click is one user operation, not two steps. + if ( + event.kind === "submit" && + previous?.source === "human" && + previous.method === "tool.click" && + at - previous.started_at < 250 + ) + return; + if (previous) { + previous.window_end = Math.min(previous.window_end ?? at, at); + previous.observation_end = Math.min(previous.observation_end ?? at, at); + } + const baseline = previous?.after ?? state.pages.at(-1); + const before: DebugPage = { + at, + state: event.before ? "available" : "unavailable", + url: baseline?.url ?? state.run.url, + title: baseline?.title, + text: baseline?.text, + ...sanitizeFields(event.before), + }; + const operation: DebugOperation = { + id: `${state.run.id}:a${++state.nextOperation}`, + run_id: state.run.id, + sequence: this.change(state), + method: `tool.${event.kind?.startsWith("input") ? "fill" : event.kind === "submit" ? "press" : event.kind}`, + source: "human", + target: redactText(typeof event.target === "string" ? event.target : "", 120), + started_at: at, + finished_at: now, + window_end: now + WINDOW_MS, + observation_end: now + OBSERVE_MS, + state: event.kind === "input_start" ? "running" : "completed", + before, + ...(event.after ? { after: { ...before, at: now, ...sanitizeFields(event.after) } } : {}), + request_ids: [], + console_ids: [], + truncated: false, + }; + state.operations.push(operation); + state.current = operation; + if (state.operations.length > MAX_OPERATIONS) { + state.operations.shift(); + state.run.dropped_operations += 1; + } + clearTimeout(state.timer); + state.timer = undefined; + this.startObservation(state); + } + + private startObservation(state: RunState): void { + state.checkpoints?.forEach(clearTimeout); + this.scheduleObservation(state); + state.checkpoints = [2500, 7500, 14000].map((delay) => + setTimeout(() => this.scheduleObservation(state), delay), + ); + } + + private scheduleObservation(state: RunState): void { + const operation = state.current; + if ( + !operation || + state.timer || + state.observing || + state.run.state !== "capturing" || + operation.state === "running" || + this.now() > (operation.observation_end ?? 0) + ) + return; + state.timer = setTimeout(() => { + state.timer = undefined; + if (state.current !== operation || state.run.state !== "capturing") return; + state.observing = true; + void this.page(state) + .then((page) => { + if (state.current !== operation || state.run.state !== "capturing") return; + const previous = operation.observations?.at(-1); + if ( + previous?.text === page.text && + JSON.stringify(previous?.fields) === JSON.stringify(page.fields) + ) + return; + operation.after = page; + operation.observations ??= []; + if (operation.observations.length === 4) { + operation.observations.shift(); + operation.observation_limited = true; + } + operation.observations.push(page); + operation.sequence = this.change(state); + }) + .finally(() => { + state.observing = false; + }); + }, 700); + } + + private async page(state: RunState): Promise { + const at = this.now(); + if (!this.owned(state.run.session_id, state.run.tab_id) || state.run.state !== "capturing") + return { at, state: "unavailable" }; + try { + const [tree, tab, fieldValues] = await deadline( + Promise.all([ + this.cdp.sendAttached<{ + nodes: { ignored?: boolean; role?: { value?: string }; name?: { value?: string } }[]; + }>({ tabId: state.run.tab_id }, "Accessibility.getFullAXTree"), + this.tabs.get(state.run.tab_id), + state.observer?.call("snapshot").catch(() => undefined), + ]), + 600, + ); + if (!this.owned(state.run.session_id, state.run.tab_id) || state.run.state !== "capturing") + return { at, state: "unavailable" }; + if (tab.windowId !== this.sessions.get(state.run.session_id)?.agentWindowId) + return { at, state: "unavailable" }; + const lines: string[] = []; + let chars = 0; + let truncated = false; + for (const node of tree.nodes ?? []) { + if ( + node.ignored || + !["StaticText", "heading", "alert", "status"].includes(node.role?.value ?? "") || + !node.name?.value + ) + continue; + const line = redactText(node.name.value, 500); + if (chars + line.length > 6000 || lines.length >= 100) { + truncated = true; + break; + } + lines.push(line); + chars += line.length; + } + return { + at, + state: "available", + url: redactUrl(tab.url ?? ""), + title: redactText(tab.title ?? "", 200), + text: lines.join("\n"), + truncated, + ...sanitizeFields(fieldValues), + }; + } catch { + return { at, state: "unavailable" }; + } + } + + private summary(state: RunState): DebugRun { + return { + ...state.run, + active_rules: state.controls?.activeCount ?? 0, + requests: Math.max(state.network.size, state.run.storage?.requests ?? 0), + operations: state.operations.length, + errors: state.console + .filter((entry) => entry.level === "error") + .reduce((sum, entry) => sum + entry.count, 0), + dropped_requests: state.run.storage?.dropped ?? (state.journal ? 0 : state.network.dropped), + coverage: [ + ...new Set([ + ...state.run.coverage, + ...(state.run.storage?.dropped ? ["evidence_storage_limit"] : []), + ]), + ], + }; + } + + private stopState(state: RunState, reason: string): void { + if (state.run.state === "stopped") return; + state.run.state = "stopped"; + state.run.stopped_at = this.now(); + state.run.stop_reason = reason; + clearTimeout(state.timer); + void state.observer?.dispose(); + for (const entry of state.performance) interruptPerformance(entry, "capture_interrupted"); + state.checkpoints?.forEach(clearTimeout); + if (state.current) { + state.current.window_end = Math.min(state.current.window_end ?? this.now(), this.now()); + if (state.current.state === "running") state.current.state = "interrupted"; + } + if (state.current) state.current.sequence = this.change(state); + state.controlCleanup = state.controls?.hasWork + ? state.controls + .stop() + .catch(() => { + this.coverage(state, "control_cleanup_failed"); + }) + .finally(() => { + state.controlCleanup = undefined; + this.pruneListener(); + state.network.checkpoint(); + void this.persist(state); + }) + : undefined; + state.network.stop(reason); + this.change(state); + void this.persist(state); + this.pruneListener(); + } + private pruneListener(): void { + if ( + ![...this.runs.values()].some( + ({ run, controlCleanup }) => run.state === "capturing" || controlCleanup, + ) + ) { + this.subscription?.dispose(); + this.subscription = undefined; + } + } + stopTab(tabId: number, reason = "tab_released"): void { + for (const state of this.runs.values()) + if (state.run.tab_id === tabId) this.stopState(state, reason); + } + private release(state: RunState, reason: string): void { + this.stopState(state, reason); + state.released = true; + this.ownedRuns.get(state.owner)?.delete(state.run.id); + void this.persist(state).then(() => { + if ( + this.archive && + !state.controlCleanup && + !state.run.storage_error && + !state.dirty && + !state.saving + ) + this.runs.delete(state.run.id); + }); + } + releaseTab(tabId: number): void { + for (const context of this.sessions.list()) + for (const [id, tab] of this.ownedRuns.get(context) ?? []) + if (tab === tabId) this.ownedRuns.get(context)!.delete(id); + for (const state of this.runs.values()) + if (state.run.tab_id === tabId) this.release(state, "tab_released"); + } + releaseSession(sessionId: string): void { + const context = this.sessions.get(sessionId); + if (context) this.ownedRuns.delete(context); + for (const state of this.runs.values()) + if (state.run.session_id === sessionId) this.release(state, "session_ended"); + } + sync(): void { + for (const state of this.runs.values()) { + if (state.released) continue; + if (this.sessions.get(state.run.session_id) !== state.owner) { + this.release(state, "session_ended"); + } else if (!this.owned(state.run.session_id, state.run.tab_id)) { + this.release(state, "tab_released"); + } + } + } + dispose(): void { + for (const context of this.sessions.list()) this.ownedRuns.delete(context); + for (const state of this.runs.values()) this.release(state, "disconnected"); + this.pruneListener(); + } + + async tasks(): Promise { + this.sync(); + return Promise.all( + this.sessions.list().map(async (context) => { + const tab = (await this.tabs.query({ windowId: context.agentWindowId, active: true }))[0]; + const latest = [...this.runs.values()] + .filter(({ run, released }) => !released && run.session_id === context.sessionId) + .at(-1); + return { + session_id: context.sessionId, + created_at: context.createdAtMs, + ...(tab?.id !== undefined && isAgentControlledTab(context, tab.id) + ? { + tab_id: tab.id, + title: redactText(tab.title ?? "", 160), + url: redactUrl(tab.url ?? ""), + } + : {}), + ...(latest ? { run: this.summary(latest) } : {}), + }; + }), + ); + } + + private async capturePage(state: RunState, loaded = false): Promise { + if (state.pagePending || state.run.state !== "capturing") return; + state.pagePending = true; + try { + const page = await this.page(state); + if (state.run.state !== "capturing" || state.released) return; + if (!loaded) delete page.navigation; + state.pages.push(page); + if (loaded && state.current?.source === "human" && state.current.method === "tool.navigate") { + if (page.navigation === "reload") state.current.method = "tool.reload"; + state.current.after = page; + state.current.sequence = this.change(state); + } + if (page.url) state.run.url = page.url; + if (state.pages.length > 20) { + state.pages.shift(); + this.coverage(state, "page_context_limit"); + } + this.change(state); + } finally { + state.pagePending = false; + } + } + + private capturePerformance(state: RunState, value: unknown): void { + if (state.run.state !== "capturing") return; + const data = performanceSnapshot(value); + if (!data) return; + const index = state.performance.findIndex((item) => item.document_key === data.document_key); + if (index >= 0 && state.performance[index].observed_at > data.observed_at) return; + const entry = { + ...data, + id: index >= 0 ? state.performance[index].id : `${state.run.id}:p${++state.nextPerformance}`, + sequence: this.change(state), + }; + if (index >= 0) state.performance[index] = entry; + else { + for (const previous of state.performance) + interruptPerformance(previous, "navigation_checkpoint_missing"); + state.performance.push(entry); + if (state.performance.length > PERFORMANCE_LIMIT) { + state.performance.shift(); + this.coverage(state, "performance_record_limit"); + } + } + } + private async refreshPerformance(state: RunState, finish = false): Promise { + if (state.run.state !== "capturing" || !state.observer) return; + try { + const data = await deadline( + state.observer.call(finish ? "finishPerformance" : "performance"), + 600, + ); + if (data) this.capturePerformance(state, data); + } catch { + this.coverage(state, "performance_read_failed"); + } + } + + private recording(state: RunState): DebugRecording { + return { + version: 1, + saved_at: this.now(), + run: this.summary(state), + requests: state.network.list(), + operations: state.operations, + console: state.console, + pages: state.pages, + performance: state.performance, + rules: state.controls?.list(), + replays: state.controls?.replays(), + }; + } + + private scheduleSave(state: RunState): void { + if (!this.archive) return; + state.dirty = true; + if (state.archiveTimer || state.saving) return; + state.archiveTimer = setTimeout(() => { + state.archiveTimer = undefined; + void this.persist(state); + }, 2000); + } + + private async persist(state: RunState): Promise { + if (!this.archive) return; + await state.journal?.flush(); + clearTimeout(state.archiveTimer); + state.archiveTimer = undefined; + while (state.saving) await state.saving; + if (!state.dirty && state.run.saved_at !== undefined && !state.run.storage_error) return; + const recording = structuredClone(this.recording(state)); + recording.run.saved_at = recording.saved_at; + delete recording.run.storage_error; + state.dirty = false; + const save = this.archive.put(state.journal ? { ...recording, requests: [] } : recording).then( + () => { + state.run.saved_at = recording.saved_at; + delete state.run.storage_error; + }, + (error: unknown) => { + state.run.storage_error = + error instanceof Error ? error.message : "debug history unavailable"; + }, + ); + state.saving = save; + await save; + if (state.saving === save) state.saving = undefined; + // Changes that arrived during an IndexedDB transaction need another checkpoint. + if (state.dirty && !state.archiveTimer) this.scheduleSave(state); + } + + async history(): Promise<{ runs: DebugRun[]; error?: string }> { + this.sync(); + let history: DebugRun[] = []; + let error: string | undefined; + try { + history = (await this.archive?.list()) ?? []; + } catch (reason) { + error = reason instanceof Error ? reason.message : "debug history unavailable"; + } + const combined = new Map(history.map((run) => [run.id, run])); + for (const state of this.runs.values()) combined.set(state.run.id, this.summary(state)); + return { + runs: [...combined.values()].sort((a, b) => b.started_at - a.started_at), + ...(error ? { error } : {}), + }; + } + + private async evidenceRecording(state: RunState, bodies = true): Promise { + await state.journal?.flush(); + const live = this.recording(state); + if (!state.journal) return live; + try { + const saved = await this.archive?.get(state.run.id, bodies); + if (!saved) return live; + const entries = new Map(saved.requests.map((entry) => [entry.id, entry])); + for (const entry of live.requests) + entries.set(entry.id, mergeRequest(entries.get(entry.id), entry)); + live.requests = [...entries.values()].sort( + (a, b) => a.started_at - b.started_at || a.sequence - b.sequence, + ); + live.run.requests = live.requests.length; + state.run.storage = saved.run.storage; + live.run.storage = saved.run.storage; + live.run.dropped_requests = saved.run.storage?.dropped ?? live.run.dropped_requests; + if (saved.run.storage?.dropped) + live.run.coverage = [...new Set([...live.run.coverage, "evidence_storage_limit"])]; + return live; + } catch { + this.coverage(state, "evidence_read_failed"); + live.run.coverage = [...state.run.coverage]; + return live; + } + } + + /** Browser-wide reader. Task RPC fallbacks must authorize the run before calling. */ + async readHistory(params: DebugParams): Promise { + this.sync(); + if (!params.run_id) throw new Error("recording ID is required"); + return this.readEvidence(params, this.runs.get(params.run_id)); + } + + /** Internal reader; callers retain their own task or extension-page authorization. */ + private async readEvidence(params: DebugParams, live?: RunState): Promise { + const runId = params.run_id!; + if (params.action === "requests" && (!live || live.journal)) { + await live?.journal?.flush(); + const indexed = await this.archive?.query?.(runId, params).catch((error) => { + if (!live) throw error; + this.coverage(live, "evidence_read_failed"); + return undefined; + }); + if (indexed) { + if (live) live.run.storage = indexed.run?.storage; + return live + ? { + ...indexed, + run: { + ...this.summary(live), + storage: indexed.run?.storage, + requests: indexed.run!.requests, + dropped_requests: indexed.run!.dropped_requests, + }, + } + : indexed; + } + } + if (live && params.action === "performance") { + await this.refreshPerformance(live); + return readRecording(this.recording(live), params); + } + const bodies = ["operation", "export", "duplicates"].includes(params.action); + const recording = live + ? await this.evidenceRecording(live, bodies) + : await this.archive?.get(runId, bodies); + if (recording && params.action === "request" && params.id) { + const stored = await this.archive?.request?.(runId, params.id).catch((error) => { + if (!live) throw error; + this.coverage(live, "evidence_read_failed"); + recording.run.coverage = [...new Set([...recording.run.coverage, ...live.run.coverage])]; + return undefined; + }); + const current = + live?.network.get(params.id) ?? recording.requests.find((entry) => entry.id === params.id); + if (stored || current) + recording.requests = [current ? mergeRequest(stored, current) : stored!]; + } + if (!recording) throw new Error("debug recording not found or expired"); + return readRecording(recording, params); + } + + async deleteHistory(id: string): Promise { + const state = this.runs.get(id); + if (state?.run.state === "capturing") + throw new Error("stop capture before deleting its record"); + if (state) { + await state.controlCleanup; + await this.persist(state); + clearTimeout(state.archiveTimer); + } + await this.archive?.delete(id); + this.runs.delete(id); + } + + async read(params: DebugParams, signal?: AbortSignal): Promise { + this.sync(); + const context = this.sessions.get(params.session_id); + if (!context) throw new Error("session not found"); + const result: DebugResult = { session_id: params.session_id }; + if (params.action === "capabilities") + return { ...result, capabilities: debugCapabilities(!!this.archive?.retain) }; + const states = [...this.runs.values()].filter( + ({ run, owner, released }) => + !released && + owner === context && + (params.tab_id === undefined || run.tab_id === params.tab_id), + ); + const owned = this.ownedRuns.get(context); + const savedRuns = async () => { + const runs = (await this.archive?.list()) ?? []; + if (this.sessions.get(params.session_id) !== context) throw new Error("session not found"); + return runs + .filter( + (run) => + this.ownedRuns.get(context)?.has(run.id) && + isAgentControlledTab(context, run.tab_id) && + (params.tab_id === undefined || run.tab_id === params.tab_id), + ) + .sort((a, b) => b.started_at - a.started_at); + }; + if (params.action === "status") { + const runs = new Map(); + if (owned && [...owned.keys()].some((id) => !this.runs.has(id))) + for (const run of await savedRuns()) runs.set(run.id, run); + for (const state of states) runs.set(state.run.id, this.summary(state)); + return { ...result, runs: [...runs.values()].sort((a, b) => a.started_at - b.started_at) }; + } + const state = params.run_id + ? states.find(({ run }) => run.id === params.run_id) + : params.id + ? states.find( + (entry) => + params.id!.startsWith(`${entry.run.id}:n`) || + entry.network.get(params.id!) || + entry.controls?.has(params.id!) || + entry.operations.some((operation) => operation.id === params.id), + ) + : states.at(-1); + if (!state) { + const id = params.run_id ?? (params.id ? params.id.split(":")[0] : undefined); + const runId = + id && + owned?.has(id) && + isAgentControlledTab(context, owned.get(id)!) && + (params.tab_id === undefined || owned.get(id) === params.tab_id) + ? id + : !id + ? (await savedRuns()).at(0)?.id + : undefined; + if (!runId) + throw new Error("debug capture not found; start capture before reproducing the issue"); + if (params.action.startsWith("rule_") || params.action === "replay") + throw new Error("network controls require an active capture"); + if (["pin", "unpin"].includes(params.action)) { + if (!this.archive?.pin) throw new Error("persistent evidence unavailable"); + await this.archive.pin(runId, params.id!, params.action === "pin"); + } + const saved = await this.readEvidence( + { + ...params, + run_id: runId, + action: + params.action === "stop" + ? "rules" + : ["pin", "unpin"].includes(params.action) + ? "request" + : params.action, + }, + this.runs.get(runId), + ); + if ( + this.sessions.get(params.session_id) !== context || + !this.ownedRuns.get(context)?.has(runId) || + !isAgentControlledTab(context, owned?.get(runId) ?? -1) + ) + throw new Error("session not found"); + return params.action === "stop" ? { ...result, run: saved.run } : saved; + } + if (params.action === "stop") { + await deadline(state.observer?.call("flush") ?? Promise.resolve(), 600).catch(() => {}); + await this.refreshPerformance(state, true); + this.stopState(state, "requested"); + await state.controlCleanup; + await this.persist(state); + return { ...result, run: this.summary(state) }; + } + if (["pin", "unpin"].includes(params.action)) { + if (!state.journal || !this.archive?.pin) throw new Error("persistent evidence unavailable"); + await state.journal.flush(); + await this.archive.pin(state.run.id, params.id!, params.action === "pin", this.change(state)); + const request = await this.archive.request?.(state.run.id, params.id!); + return { + ...result, + run: this.summary(state), + request: request && requestProjection(request), + }; + } + if (params.action.startsWith("rule_") || params.action === "replay") { + if (this.starting.has(`${state.run.session_id}:${state.run.tab_id}`)) + throw new Error("capture is still starting; wait until it is ready"); + if (state.run.state !== "capturing" || !state.controls) + throw new Error("network controls require an active capture"); + const tab = await this.tabs.get(state.run.tab_id); + if ( + !this.owned(params.session_id, state.run.tab_id) || + tab.windowId !== this.sessions.get(params.session_id)?.agentWindowId + ) + throw new Error("debug tab must remain in its Agent Window"); + if (signal?.aborted) throw new Error("debug action cancelled"); + if (params.action === "rule_add") await state.controls.add(params.rule!, signal); + else if (params.action === "replay") { + await state.journal?.flush(); + const saved = await this.archive?.request?.(state.run.id, params.id!); + const current = state.network.get(params.id!); + const source = current ? mergeRequest(saved, current) : saved; + if (!source) throw new Error("request not found or evicted"); + const replay = await state.controls.replay(source, params.replay!, tab.url ?? "", signal); + return { ...result, run: this.summary(state), replay }; + } else + await state.controls.update( + params.id!, + params.action as "rule_enable" | "rule_disable" | "rule_remove", + signal, + ); + return { ...result, run: this.summary(state), rules: state.controls.list() }; + } + if (params.action === "operation") { + const operation = state.operations.find((entry) => entry.id === params.id); + if (!operation) throw new Error("operation not found or evicted"); + // During the observation window a caller may request an early post-state. + if ( + state.current === operation && + state.run.state === "capturing" && + operation.state !== "running" && + this.now() <= (operation.window_end ?? 0) + ) { + operation.after = await this.page(state); + operation.sequence = this.change(state); + } + } + const evidence = await this.readEvidence({ ...params, run_id: state.run.id }, state); + if ( + this.sessions.get(params.session_id) !== context || + state.released || + !isAgentControlledTab(context, state.run.tab_id) + ) + throw new Error("session not found"); + return evidence; + } +} diff --git a/apps/extension/src/debug/network-control.ts b/apps/extension/src/debug/network-control.ts new file mode 100644 index 00000000..ceaaf0a4 --- /dev/null +++ b/apps/extension/src/debug/network-control.ts @@ -0,0 +1,620 @@ +import type { CdpDebuggee } from "@/browser-driver/chromium-cdp"; +import { + editRequest, + type LiveRequest, + MAX_REPLAYS, + MAX_RULES, + publicRule, + replayRequest, + urlMatcher, + validateReplay, + validateRule, +} from "./control-model"; +import type { DebugCdp } from "./manager"; +import type { DebugNetworkStore } from "./network-store"; +import { redactText } from "./redact"; +import type { + DebugIntervention, + DebugReplay, + DebugReplaySpec, + DebugRequest, + DebugRule, + DebugRuleSpec, +} from "./types"; + +type Target = CdpDebuggee & { tabId: number }; +interface Rule { + spec?: DebugRuleSpec; + public: DebugRule; + matcher: RegExp; +} +interface Paused { + requestId: string; + networkId?: string; + resourceType: string; + request: LiveRequest; +} +interface Pending { + target: Target; + id: string; + rule: Rule; + abort: AbortController; + done: Promise; +} +function base64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 8192) + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + return btoa(binary); +} +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const cancel = () => { + clearTimeout(timer); + reject(new Error("cancelled")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", cancel); + resolve(); + }, ms); + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + }); +} +async function bounded(promise: Promise, ms = 2500): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("browser command timed out")), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** Serialized into a dedicated isolated world; keep this function closure-free. */ +async function replayInPage(input: { + origin: string; + id: string; + url: string; + options: RequestInit; +}): Promise { + if (location.origin !== input.origin) throw new Error("page changed"); + const controller = new AbortController(); + const scope = globalThis as unknown as Record; + scope[input.id] = controller; + const timer = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(input.url, { ...input.options, signal: controller.signal }); + const reader = response.body?.getReader(); + let size = 0; + if (reader) { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.length; + if (size > 262144) { + await reader.cancel(); + throw new Error("response size limit"); + } + } + } + return response.status; + } finally { + clearTimeout(timer); + delete scope[input.id]; + } +} + +/** Executes predeclared rules locally. Paused requests never wait on the agent/tool queue. */ +export class DebugNetworkControl { + private alive = true; + private targets = new Map(); + private configured = new Set(); + private rules = new Map(); + private pending = new Map(); + private configuration: Promise = Promise.resolve(); + private closePromise?: Promise; + private replayRuns = new Map< + string, + { result: DebugReplay; promise: Promise; context?: number } + >(); + constructor( + private readonly runId: string, + private readonly tabId: number, + private readonly cdp: DebugCdp, + private readonly network: DebugNetworkStore, + private readonly changed: () => void, + private readonly allowed: () => boolean, + private readonly now: () => number = Date.now, + ) {} + private assertActive(): void { + if (!this.alive || !this.allowed()) + throw new Error("network controls require an active, task-owned capture"); + } + get activeCount(): number { + return [...this.rules.values()].filter((rule) => rule.public.state === "enabled").length; + } + get hasWork(): boolean { + return this.rules.size > 0 || this.replayRuns.size > 0; + } + list(): DebugRule[] { + return [...this.rules.values()].map((rule) => ({ ...rule.public })); + } + replays(): DebugReplay[] { + return [...this.replayRuns.values()].map(({ result }) => ({ ...result })); + } + has(id: string): boolean { + return this.rules.has(id); + } + async target(target: Target): Promise { + if (!this.alive) return; + this.targets.set(target.sessionId ?? "root", target); + if (this.rules.size) await this.refresh(); + } + detach(sessionId: string): void { + this.targets.delete(sessionId); + this.configured.delete(sessionId); + for (const item of this.pending.values()) + if (item.target.sessionId === sessionId) item.abort.abort(); + } + async add(input: DebugRuleSpec, signal?: AbortSignal): Promise { + this.assertActive(); + if (signal?.aborted) throw new Error("debug action cancelled"); + const spec = validateRule(input); + if (this.rules.size >= MAX_RULES) + throw new Error("capture rule limit reached (32); start a new capture"); + const id = `${this.runId}:r${this.rules.size + 1}`; + const rule: Rule = { + spec, + matcher: urlMatcher(spec.match.url), + public: { + ...publicRule(spec), + id, + times: spec.times ?? 1, + state: "enabled", + hits: 0, + failures: 0, + created_at: this.now(), + }, + }; + this.rules.set(id, rule); + this.changed(); + const cancel = () => { + rule.public.state = "disabled"; + for (const item of this.pending.values()) if (item.rule === rule) item.abort.abort(); + this.changed(); + void this.refresh().catch(() => {}); + }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + await this.refresh(); + this.assertActive(); + if (signal?.aborted) throw new Error("debug action cancelled"); + } catch (error) { + rule.public.state = this.alive ? "disabled" : "stopped"; + rule.public.last_error = "could not enable request interception"; + this.changed(); + await this.refresh().catch(() => {}); + throw error; + } finally { + signal?.removeEventListener("abort", cancel); + } + } + async update( + id: string, + action: "rule_enable" | "rule_disable" | "rule_remove", + signal?: AbortSignal, + ): Promise { + this.assertActive(); + if (signal?.aborted) throw new Error("debug action cancelled"); + const rule = this.rules.get(id); + if (!rule?.spec || rule.public.state === "removed") + throw new Error("rule not found or removed"); + if (action === "rule_enable") { + if (rule.public.times && rule.public.hits >= rule.public.times) + throw new Error("rule is exhausted; create a new rule to apply it again"); + rule.public.state = "enabled"; + } else { + rule.public.state = action === "rule_remove" ? "removed" : "disabled"; + if (action === "rule_remove") rule.spec = undefined; + for (const pending of this.pending.values()) if (pending.rule === rule) pending.abort.abort(); + await Promise.allSettled( + [...this.pending.values()].filter((item) => item.rule === rule).map((item) => item.done), + ); + } + this.changed(); + const cancel = () => { + if (action !== "rule_enable") return; + if (rule.public.state === "enabled") rule.public.state = "disabled"; + for (const item of this.pending.values()) if (item.rule === rule) item.abort.abort(); + this.changed(); + void this.refresh().catch(() => {}); + }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + await this.refresh(); + if (action === "rule_enable") { + this.assertActive(); + if (signal?.aborted) throw new Error("debug action cancelled"); + } + } catch (error) { + if (rule.public.state === "enabled") rule.public.state = "disabled"; + this.changed(); + await this.refresh().catch(() => {}); + throw error; + } finally { + signal?.removeEventListener("abort", cancel); + } + } + private refresh(): Promise { + const work = this.configuration + .catch(() => {}) + .then(async () => { + const active = this.alive + ? [...this.rules.values()].filter((rule) => rule.public.state === "enabled" && rule.spec) + : []; + const patterns = active.flatMap(({ spec }) => + (spec!.match.resource_type ? [spec!.match.resource_type] : ["Fetch", "XHR"]).map( + (resourceType) => ({ + urlPattern: spec!.match.url.replace(/\?/g, "\\?"), + resourceType, + requestStage: "Request", + }), + ), + ); + const errors: unknown[] = []; + await Promise.all( + [...this.targets].map(async ([key, target]) => { + try { + if (patterns.length && this.alive && this.allowed()) { + this.configured.add(key); + await bounded( + this.cdp.sendAttached(target, "Fetch.enable", { + patterns, + handleAuthRequests: false, + }), + ); + } else if (this.configured.has(key)) { + // Drain already-paused requests before disabling the domain. + await Promise.allSettled( + [...this.pending.values()] + .filter((item) => (item.target.sessionId ?? "root") === key) + .map((item) => item.done), + ); + await bounded(this.cdp.sendAttached(target, "Fetch.disable")); + this.configured.delete(key); + } + } catch (error) { + errors.push(error); + } + }), + ); + if (errors.length) + throw new Error("request control configuration failed; inspect the rule state"); + }); + this.configuration = work; + return work; + } + onEvent(source: Target, method: string, raw: unknown): void { + if (source.tabId !== this.tabId || !this.targets.has(source.sessionId ?? "root")) return; + if (method === "Network.requestWillBeSent") { + const event = raw as { + requestId?: string; + initiator?: { + stack?: { callFrames?: { url?: string }[]; parent?: { callFrames?: { url?: string }[] } }; + }; + }; + const frames = [ + ...(event.initiator?.stack?.callFrames ?? []), + ...(event.initiator?.stack?.parent?.callFrames ?? []), + ]; + for (const { result } of this.replayRuns.values()) { + if (event.requestId && frames.some((frame) => frame.url === `bsk-replay://${result.id}`)) { + this.network.annotate(source, event.requestId, { + replay_from: result.source_request_id, + replay_id: result.id, + }); + result.request_id = this.network.list().find((item) => item.replay_id === result.id)?.id; + this.changed(); + } + } + } + if (method !== "Fetch.requestPaused") return; + const event = raw as Paused; + if (!event?.requestId || !event.request) return; + const key = `${source.sessionId ?? "root"}:${event.requestId}`; + if (this.pending.has(key)) return; + if (!this.alive || !this.allowed()) { + void bounded( + this.cdp.sendAttached(source, "Fetch.failRequest", { + requestId: event.requestId, + errorReason: "Aborted", + }), + ).catch(() => {}); + return; + } + const rule = [...this.rules.values()].find( + ({ spec, public: meta, matcher }) => + spec && + meta.state === "enabled" && + matcher.test(event.request.url) && + (!spec.match.method || spec.match.method === event.request.method) && + (spec.match.resource_type + ? spec.match.resource_type === event.resourceType + : ["Fetch", "XHR"].includes(event.resourceType)), + ); + if (!rule?.spec) { + void bounded( + this.cdp.sendAttached(source, "Fetch.continueRequest", { requestId: event.requestId }), + ).catch(() => + bounded( + this.cdp.sendAttached(source, "Fetch.failRequest", { + requestId: event.requestId, + errorReason: "Aborted", + }), + ).catch(() => {}), + ); + return; + } + rule.public.hits += 1; + if (rule.public.times && rule.public.hits >= rule.public.times) rule.public.state = "exhausted"; + this.changed(); + const abort = new AbortController(); + const done = this.apply(source, event, rule, rule.spec, abort.signal).finally(() => { + this.pending.delete(key); + // The last one-shot rule stops interception after its request settles. + if ( + !this.pending.size && + ![...this.rules.values()].some((item) => item.public.state === "enabled") + ) + void this.refresh().catch(() => {}); + }); + this.pending.set(key, { target: source, id: event.requestId, rule, abort, done }); + if (this.pending.size > 32) abort.abort(); + } + private async apply( + target: Target, + event: Paused, + rule: Rule, + spec: DebugRuleSpec, + signal: AbortSignal, + ): Promise { + const mark: DebugIntervention = { + rule_id: rule.public.id, + type: spec.effect.type, + state: "pending", + }; + const annotate = (extra: Parameters[2] = {}) => { + if (event.networkId) + this.network.annotate(target, event.networkId, { intervention: mark, ...extra }); + }; + annotate(); + // Yield so the pending record exists before cancellation/configuration can drain it. + await Promise.resolve(); + try { + if (signal.aborted) throw new Error("cancelled"); + const effect = spec.effect; + if (effect.type === "block") { + annotate(); + await bounded( + this.cdp.sendAttached(target, "Fetch.failRequest", { + requestId: event.requestId, + errorReason: "BlockedByClient", + }), + ); + } else if (effect.type === "modify") { + const effective = editRequest(event.request, effect); + mark.changes = [ + effect.url !== undefined ? "url" : "", + effect.method !== undefined ? "method" : "", + ...Object.keys(effect.headers ?? {}).map((key) => `header:${key}`), + effect.body !== undefined ? "body" : "", + ...Object.keys(effect.json?.set ?? {}).map((key) => `json:${key}`), + ...(effect.json?.remove ?? []).map((key) => `remove:${key}`), + ...Object.entries(effect.json?.rename ?? {}).map(([from, to]) => `${from} → ${to}`), + ] + .filter(Boolean) + .map((value) => redactText(value, 160)); + annotate({ effective }); + await bounded( + this.cdp.sendAttached(target, "Fetch.continueRequest", { + requestId: event.requestId, + ...(effect.url === undefined ? {} : { url: effective.url }), + ...(effect.method === undefined ? {} : { method: effective.method }), + ...(effect.headers === undefined + ? {} + : { + headers: Object.entries(effective.headers) + .filter(([key]) => !/^(content-length|host)$/i.test(key)) + .map(([name, value]) => ({ name, value })), + }), + ...(effect.body === undefined && effect.json === undefined + ? {} + : { postData: base64(effective.postData ?? "") }), + }), + ); + } else { + if (effect.delay_ms) await wait(effect.delay_ms, signal); + if (signal.aborted || !this.alive || !this.allowed()) throw new Error("cancelled"); + const headers = Object.fromEntries( + Object.entries(effect.headers ?? { "content-type": "application/json" }).map( + ([key, value]) => [key.toLowerCase(), value], + ), + ); + const responseBody = event.request.method === "HEAD" ? "" : effect.body; + annotate({ mock: { status: effect.status, headers, body: responseBody } }); + await bounded( + this.cdp.sendAttached(target, "Fetch.fulfillRequest", { + requestId: event.requestId, + responseCode: effect.status, + responseHeaders: Object.entries(headers).map(([name, value]) => ({ name, value })), + body: base64(responseBody), + }), + ); + } + mark.state = "applied"; + } catch { + mark.state = signal.aborted || !this.alive ? "cancelled" : "failed"; + mark.error = + mark.state === "cancelled" + ? "rule disabled or capture stopped" + : "request control failed; request aborted"; + rule.public.failures += 1; + rule.public.last_error = mark.error; + annotate(); + await bounded( + this.cdp.sendAttached(target, "Fetch.failRequest", { + requestId: event.requestId, + errorReason: "Aborted", + }), + ).catch(() => {}); + } finally { + annotate(); + this.changed(); + } + } + async replay( + source: DebugRequest, + input: DebugReplaySpec, + pageUrl: string, + signal?: AbortSignal, + ): Promise { + this.assertActive(); + if (signal?.aborted) throw new Error("debug action cancelled"); + const spec = validateReplay(input); + const existing = this.replayRuns.get(spec.key); + if (existing) { + if (existing.result.source_request_id !== source.id) + throw new Error("replay key already belongs to another request"); + return { ...(await existing.promise) }; + } + if (this.replayRuns.size >= MAX_REPLAYS) throw new Error("capture replay limit reached (20)"); + const request = replayRequest(source, spec, pageUrl); + const result: DebugReplay = { + id: `${this.runId}-replay-${this.replayRuns.size + 1}`, + key: spec.key, + source_request_id: source.id, + state: "running", + }; + const record: { result: DebugReplay; promise: Promise; context?: number } = { + result, + promise: Promise.resolve(result), + }; + this.replayRuns.set(spec.key, record); + this.changed(); + const cancel = () => { + if (result.state !== "running") return; + result.state = "interrupted"; + result.error = "replay cancelled; the request may have been sent"; + if (record.context !== undefined) + void bounded( + this.cdp.sendAttached({ tabId: this.tabId }, "Runtime.evaluate", { + contextId: record.context, + expression: `globalThis[${JSON.stringify(result.id)}]?.abort()`, + }), + ).catch(() => {}); + this.changed(); + }; + const check = () => { + this.assertActive(); + if (signal?.aborted || result.state !== "running") throw new Error("replay cancelled"); + }; + signal?.addEventListener("abort", cancel, { once: true }); + record.promise = (async () => { + try { + const { frameTree } = await bounded( + this.cdp.sendAttached<{ frameTree: { frame: { id: string } } }>( + { tabId: this.tabId }, + "Page.getFrameTree", + ), + ); + check(); + const { executionContextId } = await bounded( + this.cdp.sendAttached<{ executionContextId: number }>( + { tabId: this.tabId }, + "Page.createIsolatedWorld", + { frameId: frameTree.frame.id, worldName: `bsk-network-${this.runId}` }, + ), + ); + record.context = executionContextId; + check(); + const options = { + method: request.method, + headers: request.headers, + ...(request.postData ? { body: request.postData } : {}), + credentials: "same-origin", + redirect: "error", + cache: "no-store", + }; + // Isolated globals avoid replacing or invoking a site's patched fetch. The + // origin check executes in that same context, closing a navigation race. + const expression = `(${replayInPage.toString()})(${JSON.stringify({ origin: new URL(pageUrl).origin, id: result.id, url: request.url, options })})\n//# sourceURL=bsk-replay://${result.id}`; + const response = await bounded( + this.cdp.sendAttached<{ exceptionDetails?: unknown }>( + { tabId: this.tabId }, + "Runtime.evaluate", + { expression, contextId: executionContextId, awaitPromise: true, returnByValue: true }, + ), + 17000, + ); + if (result.state === "running") { + result.state = response.exceptionDetails ? "failed" : "complete"; + if (response.exceptionDetails) + result.error = "replay failed or timed out; inspect the linked request"; + } + } catch { + if (result.state === "running") { + result.state = this.alive ? "failed" : "interrupted"; + result.error = "replay failed or page changed; the request may have been sent"; + } + } finally { + signal?.removeEventListener("abort", cancel); + this.changed(); + } + return { ...result }; + })(); + return record.promise; + } + stop(): Promise { + if (this.closePromise) return this.closePromise; + this.alive = false; + for (const rule of this.rules.values()) { + if (["enabled", "disabled"].includes(rule.public.state)) rule.public.state = "stopped"; + rule.spec = undefined; + } + for (const item of this.pending.values()) item.abort.abort(); + const aborts: Promise[] = []; + for (const { result, context } of this.replayRuns.values()) { + if (result.state !== "running") continue; + result.state = "interrupted"; + result.error = "capture stopped"; + if (context !== undefined) + aborts.push( + bounded( + this.cdp.sendAttached({ tabId: this.tabId }, "Runtime.evaluate", { + contextId: context, + expression: `globalThis[${JSON.stringify(result.id)}]?.abort()`, + }), + ).catch(() => {}), + ); + } + this.changed(); + this.closePromise = (async () => { + await Promise.allSettled([...aborts, ...[...this.pending.values()].map((item) => item.done)]); + try { + await this.refresh(); + } catch (error) { + // A broken Fetch domain must not outlive the capture and freeze the tab. + // Detach only as a cleanup fallback; normal stop preserves shared domains. + if (this.cdp.detach) await bounded(this.cdp.detach(this.tabId)).catch(() => {}); + throw error; + } + })(); + return this.closePromise; + } +} diff --git a/apps/extension/src/debug/network-store.ts b/apps/extension/src/debug/network-store.ts new file mode 100644 index 00000000..7eb4b621 --- /dev/null +++ b/apps/extension/src/debug/network-store.ts @@ -0,0 +1,714 @@ +import type { CdpDebuggee } from "@/browser-driver/chromium-cdp"; +import type { CdpRunner } from "@/tools/shared"; +import { sendToCdpTarget } from "@/tools/shared"; +import { requestMetadata } from "./journal"; +import { jsonPointer } from "./json-source"; +import { + BODY_CHARS, + redactBody, + redactHeaders, + redactRequestUrl, + redactText, + redactUrl, +} from "./redact"; +import type { DebugBody, DebugIntervention, DebugRequest } from "./types"; + +export const MAX_REQUESTS = 200; +export const MAX_INFLIGHT = 200; +const MAX_BODY_CHARS = 512 * 1024; +const MAX_BODY_JOBS = 4; +const MAX_BODY_QUEUE = 32; + +interface Response { + status?: number; + mimeType?: string; + headers?: Record; + fromDiskCache?: boolean; + fromServiceWorker?: boolean; + timing?: Record; +} +interface Event { + requestId?: string; + request?: { + url: string; + method?: string; + headers?: Record; + postData?: string; + hasPostData?: boolean; + }; + response?: Response; + redirectResponse?: Response; + timestamp?: number; + type?: string; + frameId?: string; + loaderId?: string; + errorText?: string; + encodedDataLength?: number; + dataLength?: number; + headers?: Record; + statusCode?: number; + hasExtraInfo?: boolean; + redirectHasExtraInfo?: boolean; + initiator?: { + type?: string; + stack?: { callFrames?: { url?: string; lineNumber?: number; functionName?: string }[] }; + }; +} +interface RequestRecord { + entry: DebugRequest; + rawId: string; + target: CdpDebuggee & { tabId: number }; + timestamp?: number; + expectsExtra?: boolean; +} +interface Chain { + hops: RequestRecord[]; + requestHeaders: Record[]; + responseHeaders: Record[]; + requestIndex: number; + responseIndex: number; + partial?: boolean; +} + +interface ControlAnnotation { + intervention?: DebugIntervention; + replay_from?: string; + replay_id?: string; + effective?: { + url: string; + method: string; + headers: Record; + postData?: string; + }; + mock?: { status: number; headers?: Record; body: string }; +} + +function headersLimited(headers?: Record): boolean { + const values = Object.entries(headers ?? {}); + return ( + values.length > 80 || + values.some(([key, value]) => key.length > 128 || value.length > 2048) || + values.reduce((total, [key, value]) => total + key.length + value.length, 0) > 4096 + ); +} + +function incompleteMetadata(entry: DebugRequest): void { + entry.truncated = true; + if (entry.integrity) entry.integrity.metadata = "truncated"; +} + +export class DebugNetworkStore { + readonly entries = new Map(); + // Recent traffic must not erase the CDP identity of a slow request or body job. + private readonly inflight = new Map(); + private readonly chains = new Map(); + private readonly queue: RequestRecord[] = []; + private readonly annotations = new Map< + string, + { + value: ControlAnnotation; + requestBody?: ReturnType; + responseBody?: ReturnType; + headersTruncated: boolean; + urlState?: ReturnType["state"]; + } + >(); + private jobs = 0; + private serial = 0; + private retainedChars = 0; + private pendingHeaders = 0; + private alive = true; + private accepting = true; + dropped = 0; + + constructor( + private readonly runId: string, + private readonly cdp: CdpRunner, + private readonly changed: () => number, + private readonly now: () => number = Date.now, + private readonly retain?: (entry: DebugRequest) => void, + ) {} + + private key(source: CdpDebuggee, requestId: string): string { + return `${source.sessionId ?? "root"}:${requestId}`; + } + + private tracked(id: string): boolean { + return this.entries.has(id) || this.inflight.has(id); + } + private unsettled(record: RequestRecord): boolean { + return record.entry.state === "pending" || record.entry.response_body.state === "pending"; + } + private *records(): IterableIterator { + yield* this.inflight.values(); + yield* this.entries.values(); + } + get size(): number { + return this.entries.size + this.inflight.size; + } + + onEvent(source: CdpDebuggee & { tabId: number }, method: string, raw: unknown): void { + if (!this.accepting || !method.startsWith("Network.")) return; + const event = raw as Event; + if (!event?.requestId) return; + const key = this.key(source, event.requestId); + let chain = this.chains.get(key); + if (!chain) { + if ( + ![ + "Network.requestWillBeSent", + "Network.requestWillBeSentExtraInfo", + "Network.responseReceivedExtraInfo", + ].includes(method) + ) + return; + // ExtraInfo can arrive before requestWillBeSent. Bound unmatched chains too. + chain = { + hops: [], + requestHeaders: [], + responseHeaders: [], + requestIndex: 0, + responseIndex: 0, + }; + this.chains.set(key, chain); + while (this.chains.size > MAX_REQUESTS * 2 + MAX_INFLIGHT) { + // At most MAX_REQUESTS + MAX_INFLIGHT records are tracked, leaving room + // for bounded unmatched ExtraInfo chains without dropping pending work. + const oldestKey = [...this.chains].find( + ([, value]) => !value.hops.some((hop) => this.unsettled(hop)), + )![0]; + const oldest = this.chains.get(oldestKey)!; + this.pendingHeaders -= oldest.requestHeaders.length + oldest.responseHeaders.length; + this.chains.delete(oldestKey); + } + } + if (method === "Network.requestWillBeSent" && event.request) { + const previous = chain.hops.at(-1); + if (previous && event.redirectResponse) { + previous.expectsExtra = event.redirectHasExtraInfo === true; + this.response(previous, event.redirectResponse); + this.finish(previous, event, "redirected"); + previous.entry.response_body = { state: "unavailable", reason: "redirect" }; + this.retain?.(previous.entry); + } + const headers = redactHeaders(event.request.headers); + const url = redactRequestUrl(event.request.url); + const metadataTruncated = chain.partial === true || headersLimited(event.request.headers); + const id = `${this.runId}:n${++this.serial}`; + const entry: DebugRequest = { + id, + run_id: this.runId, + sequence: this.changed(), + started_at: this.now(), + method: redactText(event.request.method ?? "GET", 24), + url: url.text, + integrity: { url: url.state, metadata: metadataTruncated ? "truncated" : "complete" }, + resource_type: event.type, + frame_id: event.frameId, + loader_id: event.loaderId, + state: "pending", + truncated: metadataTruncated || url.state === "truncated", + request_headers: headers, + request_body: { + state: event.request.hasPostData ? "unavailable" : "empty", + replay_safe: !event.request.hasPostData, + ...(event.request.hasPostData ? { reason: "not_in_event" } : {}), + }, + response_body: { state: "pending" }, + ...(previous && event.redirectResponse ? { redirect_from: previous.entry.id } : {}), + }; + const frame = event.initiator?.stack?.callFrames?.[0]; + if (frame) + entry.initiator = redactText( + `${frame.functionName ?? ""} ${redactUrl(frame.url ?? "")}:${(frame.lineNumber ?? 0) + 1}`, + 1024, + ); + else if (event.initiator?.type) entry.initiator = event.initiator.type; + const record: RequestRecord = { + entry, + rawId: event.requestId, + target: { ...source }, + timestamp: event.timestamp, + }; + this.entries.set(id, record); + chain.hops.push(record); + if (typeof event.request.postData === "string") { + this.saveBody( + record, + "request_body", + event.request.postData, + headers["content-type"] ?? "", + ); + } + this.applyExtra(chain); + this.applyAnnotation(key, record); + this.retain?.(entry); + if (previous) this.releaseFinished(previous); + while (this.entries.size > MAX_REQUESTS) { + const oldest = this.entries.values().next().value as RequestRecord; + if (this.unsettled(oldest)) { + this.entries.delete(oldest.entry.id); + this.inflight.set(oldest.entry.id, oldest); + if (this.inflight.size > MAX_INFLIGHT) { + const lost = this.inflight.values().next().value!; + if (lost.entry.state === "pending") { + lost.entry.state = "interrupted"; + lost.entry.finished_at = this.now(); + lost.entry.error = "tracking_limit"; + } + incompleteMetadata(lost.entry); + if (lost.entry.response_body.state === "pending") + lost.entry.response_body = { state: "unavailable", reason: "tracking_limit" }; + lost.entry.sequence = this.changed(); + this.evict(lost); + } + } else this.evict(oldest); + } + return; + } + if ( + method === "Network.requestWillBeSentExtraInfo" || + method === "Network.responseReceivedExtraInfo" + ) { + if (chain.partial) return; + const queue = + method === "Network.requestWillBeSentExtraInfo" + ? chain.requestHeaders + : chain.responseHeaders; + if (queue.length < 8 && this.pendingHeaders < 64) { + if (headersLimited(event.headers)) { + const latest = chain.hops.at(-1); + if (latest) incompleteMetadata(latest.entry); + } + queue.push(redactHeaders(event.headers)); + this.pendingHeaders += 1; + } else { + chain.partial = true; + const latest = chain.hops.at(-1); + if (latest) incompleteMetadata(latest.entry); + } + this.applyExtra(chain); + return; + } + const record = chain.hops.at(-1); + if (!record || !this.tracked(record.entry.id)) return; + const entry = record.entry; + switch (method) { + case "Network.responseReceived": + record.expectsExtra = event.hasExtraInfo === true; + if (event.response) this.response(record, event.response); + this.applyExtra(chain); + break; + case "Network.dataReceived": + if (typeof event.dataLength === "number") + entry.decoded_bytes = (entry.decoded_bytes ?? 0) + Math.max(0, event.dataLength); + // Byte counters are accumulated without notifying on every chunk. + return; + case "Network.loadingFinished": + this.finish(record, event, "complete"); + if (entry.method === "HEAD" || entry.status === 204 || entry.status === 304) + entry.response_body = { state: "empty", chars: 0 }; + else if (entry.intervention?.type === "mock" && entry.response_body.state !== "pending") { + /* Mock body was retained directly. */ + } else if ( + !/^(?:text\/|application\/(?:[\w.+-]*json|javascript|xml|x-www-form-urlencoded))/i.test( + entry.mime_type ?? "", + ) + ) + entry.response_body = { state: "omitted", reason: "non_text" }; + else if ((entry.decoded_bytes ?? 0) > BODY_CHARS * 4) + entry.response_body = { state: "omitted", reason: "body_limit" }; + else if (this.queue.length >= MAX_BODY_QUEUE) + entry.response_body = { state: "omitted", reason: "capture_busy" }; + else { + this.queue.push(record); + this.pump(); + } + break; + case "Network.loadingFailed": + this.finish(record, event, "failed"); + entry.error = redactText(event.errorText ?? "network failed"); + entry.response_body = { state: "unavailable", reason: "request_failed" }; + break; + case "Network.requestServedFromCache": + entry.from_cache = true; + break; + default: + return; + } + entry.sequence = this.changed(); + this.retain?.(entry); + this.releaseFinished(record); + } + + private applyExtra(chain: Chain): void { + for (const side of ["request", "response"] as const) { + const indexKey = side === "request" ? "requestIndex" : "responseIndex"; + const headersQueue = side === "request" ? chain.requestHeaders : chain.responseHeaders; + while (chain[indexKey] < chain.hops.length) { + const record = chain.hops[chain[indexKey]]; + if (record.expectsExtra === undefined) break; + if (!record.expectsExtra) { + chain[indexKey] += 1; + continue; + } + if (!headersQueue.length) break; + chain[indexKey] += 1; + const headers = headersQueue.shift(); + this.pendingHeaders -= 1; + if (!this.tracked(record.entry.id)) continue; + record.entry[side === "request" ? "request_headers" : "response_headers"] = headers; + record.entry.sequence = this.changed(); + this.retain?.(record.entry); + } + } + } + + private response(record: RequestRecord, response: Response): void { + const entry = record.entry; + entry.status = response.status; + entry.mime_type = response.mimeType; + entry.response_headers ??= redactHeaders(response.headers); + entry.from_cache = response.fromDiskCache === true || entry.from_cache; + entry.from_service_worker = response.fromServiceWorker === true; + if (response.timing) + entry.timing = Object.fromEntries( + Object.entries(response.timing) + .filter(([, value]) => typeof value === "number" && Number.isFinite(value)) + .slice(0, 30), + ); + entry.sequence = this.changed(); + this.retain?.(entry); + } + + private finish(record: RequestRecord, event: Event, state: DebugRequest["state"]): void { + record.entry.state = state; + record.entry.finished_at = this.now(); + if (event.timestamp !== undefined && record.timestamp !== undefined) + record.entry.duration_ms = Math.max( + 0, + Math.round((event.timestamp - record.timestamp) * 1000 * 100) / 100, + ); + if (typeof event.encodedDataLength === "number") + record.entry.transfer_bytes = Math.max(0, event.encodedDataLength); + } + + private pump(): void { + while (this.alive && this.jobs < MAX_BODY_JOBS && this.queue.length) { + const record = this.queue.shift() as RequestRecord; + if (!this.tracked(record.entry.id)) continue; + this.jobs += 1; + // Never call send(), which can reattach a returned tab. The production + // runner supplies a direct command guarded by current task ownership. + void sendToCdpTarget<{ body: string; base64Encoded?: boolean }>( + this.cdp, + record.target, + "Network.getResponseBody", + { requestId: record.rawId }, + ) + .then((result) => { + if (!this.alive || !this.tracked(record.entry.id)) return; + let body = result.body; + if (result.base64Encoded) { + if (body.length > BODY_CHARS * 6) { + record.entry.response_body = { state: "omitted", reason: "body_limit" }; + return; + } + body = new TextDecoder().decode( + Uint8Array.from(atob(body), (char) => char.charCodeAt(0)), + ); + } + this.saveBody(record, "response_body", body, record.entry.mime_type ?? ""); + }) + .catch(() => { + if (this.alive && this.tracked(record.entry.id)) + record.entry.response_body = { + state: "unavailable", + reason: "browser_buffer_unavailable", + }; + }) + .finally(() => { + this.jobs -= 1; + if (this.alive && this.tracked(record.entry.id)) { + record.entry.sequence = this.changed(); + this.retain?.(record.entry); + this.releaseFinished(record); + } + this.pump(); + }); + } + } + + private saveBody( + record: RequestRecord, + key: "request_body" | "response_body", + text: string, + mime: string, + retained?: ReturnType, + ): void { + if (/multipart\/form-data/i.test(mime)) { + record.entry[key] = { state: "omitted", reason: "multipart" }; + return; + } + const body = retained ?? redactBody(text, mime); + this.retainedChars -= record.entry[key].text?.length ?? 0; + record.entry[key] = { + state: body.reason + ? "omitted" + : body.truncated + ? "truncated" + : body.text.length + ? "available" + : "empty", + text: body.text, + chars: body.text.length, + redacted: body.redacted, + replay_safe: body.replay_safe, + ...(body.reason ? { reason: body.reason } : body.truncated ? { reason: "body_limit" } : {}), + }; + this.retainedChars += body.text.length; + this.retain?.(record.entry); + for (const item of this.records()) { + if (this.retainedChars <= MAX_BODY_CHARS) break; + for (const part of ["request_body", "response_body"] as const) { + const length = item.entry[part].text?.length ?? 0; + if (!length) continue; + this.retain?.(item.entry); + this.retainedChars -= length; + item.entry[part] = { state: "evicted", reason: "memory_limit" }; + item.entry.sequence = this.changed(); + } + } + } + + private evict(record: RequestRecord): void { + this.retain?.(record.entry); + this.dropped += 1; + this.retainedChars -= + (record.entry.request_body.text?.length ?? 0) + + (record.entry.response_body.text?.length ?? 0); + // Clear text on references still held by a redirect chain or pending job. + record.entry.request_body = { state: "evicted" }; + record.entry.response_body = { state: "evicted" }; + record.entry.request_headers = undefined; + record.entry.response_headers = undefined; + this.entries.delete(record.entry.id); + this.inflight.delete(record.entry.id); + this.annotations.delete(this.key(record.target, record.rawId)); + // A redirect chain must not keep evicted request records alive. If its + // pending ExtraInfo can no longer be matched, retain ordinary headers and + // report partial evidence instead of assigning them to the wrong hop. + const key = this.key(record.target, record.rawId); + const chain = this.chains.get(key); + if (chain) { + const index = chain.hops.indexOf(record); + if (index >= 0) { + chain.hops.splice(index, 1); + if (chain.requestIndex <= index || chain.responseIndex <= index) { + chain.partial = true; + this.pendingHeaders -= chain.requestHeaders.length + chain.responseHeaders.length; + chain.requestHeaders.length = 0; + chain.responseHeaders.length = 0; + for (const hop of chain.hops) incompleteMetadata(hop.entry); + } + if (chain.requestIndex > index) chain.requestIndex -= 1; + if (chain.responseIndex > index) chain.responseIndex -= 1; + } + if (!chain.hops.length) { + this.pendingHeaders -= chain.requestHeaders.length + chain.responseHeaders.length; + this.chains.delete(key); + } + } + } + + private releaseFinished(record: RequestRecord): void { + if (this.inflight.has(record.entry.id) && !this.unsettled(record)) this.evict(record); + } + + /** Called on the CDP event path; annotations can precede requestWillBeSent. */ + annotate(source: CdpDebuggee, rawId: string, annotation: ControlAnnotation): void { + const key = this.key(source, rawId); + let requestBody: ReturnType | undefined; + let responseBody: ReturnType | undefined; + let urlState: ReturnType["state"] | undefined; + const headersTruncated = headersLimited(annotation.effective?.headers); + // Pending annotations must obey the same redaction boundary as live records. + // Preserve the original completeness flags when formatting expands a JSON body. + if (annotation.effective) { + const value = annotation.effective; + const url = redactRequestUrl(value.url); + urlState = url.state; + if (value.postData !== undefined) + requestBody = redactBody(value.postData, value.headers["content-type"] ?? ""); + annotation = { + ...annotation, + effective: { + ...value, + url: url.text, + headers: redactHeaders(value.headers), + ...(value.postData === undefined ? {} : { postData: requestBody!.text }), + }, + }; + } + if (annotation.mock) { + const value = annotation.mock; + responseBody = redactBody(value.body, value.headers?.["content-type"] ?? ""); + annotation = { + ...annotation, + mock: { + ...value, + headers: redactHeaders(value.headers), + body: responseBody.text, + }, + }; + } + this.annotations.set(key, { + value: annotation, + requestBody, + responseBody, + headersTruncated, + urlState, + }); + while (this.annotations.size > 64) + this.annotations.delete(this.annotations.keys().next().value!); + const record = this.chains.get(key)?.hops.at(-1); + if (record) this.applyAnnotation(key, record); + } + private applyAnnotation(key: string, record: RequestRecord): void { + const retained = this.annotations.get(key); + if (!retained) return; + this.annotations.delete(key); + const { effective, mock, ...metadata } = retained.value; + Object.assign(record.entry, metadata); + if (effective) { + if (retained.headersTruncated) incompleteMetadata(record.entry); + record.entry.url = effective.url; + record.entry.integrity!.url = retained.urlState!; + record.entry.truncated = + record.entry.integrity!.metadata === "truncated" || retained.urlState === "truncated"; + record.entry.method = effective.method; + record.entry.request_headers = redactHeaders(effective.headers); + if (effective.postData !== undefined) + this.saveBody( + record, + "request_body", + effective.postData, + effective.headers["content-type"] ?? "", + retained.requestBody, + ); + } + if (mock) { + record.entry.status = mock.status; + record.entry.response_headers = redactHeaders(mock.headers); + record.entry.mime_type = mock.headers?.["content-type"] ?? "text/plain"; + this.saveBody( + record, + "response_body", + mock.body, + record.entry.mime_type, + retained.responseBody, + ); + } + record.entry.sequence = this.changed(); + this.retain?.(record.entry); + } + + checkpoint(): void { + for (const { entry } of this.records()) this.retain?.(entry); + } + + list(): DebugRequest[] { + return Array.from(this.records(), (record) => record.entry); + } + get(id: string): DebugRequest | undefined { + return (this.entries.get(id) ?? this.inflight.get(id))?.entry; + } + + detachTarget(sessionId: string): void { + for (const record of this.records()) { + const { entry, target } = record; + if (target.sessionId !== sessionId || entry.state !== "pending") continue; + entry.state = "interrupted"; + entry.error = "frame_detached"; + entry.finished_at = this.now(); + entry.response_body = { state: "unavailable", reason: "frame_detached" }; + entry.sequence = this.changed(); + this.retain?.(entry); + this.releaseFinished(record); + } + } + + stop(reason: string): void { + this.accepting = false; + this.alive = false; + this.queue.length = 0; + this.chains.clear(); + this.annotations.clear(); + this.pendingHeaders = 0; + for (const record of this.records()) { + const { entry } = record; + if (entry.state === "pending") { + entry.state = "interrupted"; + entry.error = reason; + entry.finished_at = this.now(); + } + if (entry.response_body.state === "pending") + entry.response_body = { state: "unavailable", reason: "capture_stopped" }; + entry.sequence = this.changed(); + this.retain?.(entry); + this.releaseFinished(record); + } + } +} + +export function bodySlice( + body: DebugBody, + offset: number, + maxChars: number, + pointer?: string, +): DebugBody { + if (body.text === undefined) return { ...body }; + let text = body.text; + if (pointer !== undefined) { + if (body.state !== "available" && body.state !== "empty") + throw new Error("JSON pointer requires a complete body"); + text = jsonPointer(text, pointer); + } + const splitsCharacter = (at: number) => + /[\uD800-\uDBFF]/.test(text.charAt(at - 1)) && /[\uDC00-\uDFFF]/.test(text.charAt(at)); + if (splitsCharacter(offset)) + throw new Error("offset splits a Unicode character; use next_offset"); + let end = Math.min(text.length, offset + maxChars); + if (splitsCharacter(end)) end--; + if (end === offset && offset < text.length) + throw new Error("max_chars too small for a complete Unicode character"); + return { + ...body, + text: text.slice(offset, end), + chars: text.length, + offset, + ...(end < text.length ? { next_offset: end } : {}), + }; +} + +export function requestProjection( + entry: DebugRequest, + part: string = "metadata", + offset = 0, + maxChars = 4096, + pointer?: string, +): DebugRequest { + return { + ...requestMetadata(entry), + ...(part === "request" + ? { request_body: bodySlice(entry.request_body, offset, maxChars, pointer) } + : {}), + ...(part === "response" + ? { response_body: bodySlice(entry.response_body, offset, maxChars, pointer) } + : {}), + ...(part === "headers" + ? { request_headers: entry.request_headers, response_headers: entry.response_headers } + : {}), + ...(part === "timing" ? { timing: entry.timing } : {}), + }; +} diff --git a/apps/extension/src/debug/observer.ts b/apps/extension/src/debug/observer.ts new file mode 100644 index 00000000..b4bc3249 --- /dev/null +++ b/apps/extension/src/debug/observer.ts @@ -0,0 +1,413 @@ +import type { DebugCdp } from "./manager"; +import { installPerformance } from "./performance-observer"; +import { redactText } from "./redact"; +import type { DebugField } from "./types"; + +export const OBSERVATION_TIMEOUT_MS = 600; + +// This function runs only in a named CDP isolated world of the captured main frame. +// Keep it self-contained: its compiled source is also installed on future documents. +function installObserver( + binding: string, + slot: string, + observePerformance: typeof installPerformance, + early: boolean, +) { + if (window !== window.top) return; + const host = globalThis as unknown as Record; + (host[slot] as { dispose?: () => void } | undefined)?.dispose?.(); + const abort = new AbortController(); + let agent = false; + let agentRevision = 0; + let pending: { target: Element; before: ReturnType; at: number } | undefined; + let timer: ReturnType | undefined; + let changedTimer: ReturnType | undefined; + let original: { target: Element; snapshot: ReturnType } | undefined; + const secret = + /password|passwd|pwd|secret|token|credential|api.?key|session|credit|card.?number|cc-|one-time-code/i; + const controls = "input:not([type=hidden]):not([type=submit]):not([type=button]),textarea,select"; + function snapshot() { + const fields: DebugField[] = []; + const nodes = document.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >(controls); + let partial = nodes.length > 80; + for (const node of Array.from(nodes).slice(0, 80)) { + if (!node.id && !node.name) { + partial = true; + continue; + } + if (fields.length >= 16) { + partial = true; + break; + } + const name = node.name || node.id; + const label = (node.labels?.[0]?.textContent || node.getAttribute("aria-label") || name) + .trim() + .slice(0, 120); + const key = `${location.origin}${location.pathname}|${node.form?.id || node.form?.name || ""}|${node.name ? "name" : "id"}:${name}`; + if (key.length > 256 || name.length > 120) { + partial = true; + continue; + } + const sensitive = + secret.test(`${node.type} ${node.name} ${node.id} ${node.autocomplete} ${label}`) || + node.type === "file"; + const value = sensitive + ? undefined + : node instanceof HTMLInputElement && ["checkbox", "radio"].includes(node.type) + ? String(node.checked) + : node.value; + fields.push({ + key, + name: name.slice(0, 120), + label, + state: sensitive ? "redacted" : (value?.length ?? 0) > 256 ? "truncated" : "available", + ...(value !== undefined ? { value: value.slice(0, 256) } : {}), + }); + } + // Bound traversal rather than reading the entire document's innerText in an + // input handler. Only visible text is retained; form values are separate. + const lines: string[] = []; + let chars = 0; + let visited = 0; + let textPartial = false; + if (document.body) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + if (++visited > 600 || lines.length === 100 || chars >= 6000) { + textPartial = true; + break; + } + const parent = node.parentElement; + const line = node.textContent?.trim(); + if ( + line && + parent && + !parent.closest( + "script,style,noscript,template,textarea,select,[hidden],[aria-hidden=true]", + ) && + parent.getClientRects().length + ) { + const retained = line.slice(0, Math.min(500, 6000 - chars)); + textPartial ||= retained.length < line.length; + lines.push(retained); + chars += retained.length; + } + node = walker.nextNode(); + } + } + const text = lines.join("\n"); + return { + text: text.slice(0, 6000), + truncated: textPartial, + fields, + fields_partial: partial, + navigation: ( + performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined + )?.type, + }; + } + const emit = (data: object) => { + try { + (host[binding] as (payload: string) => void)(JSON.stringify(data)); + } catch { + /* detached */ + } + }; + const label = (node: Element) => + ( + node.getAttribute("aria-label") || + (node as HTMLInputElement).labels?.[0]?.textContent || + node.textContent?.trim() || + node.getAttribute("name") || + node.id || + node.tagName + ) + .trim() + .slice(0, 120); + function flush() { + clearTimeout(timer); + if (!pending) return; + const value = pending; + pending = undefined; + emit({ + kind: "input", + at: value.at, + target: label(value.target), + before: value.before, + after: snapshot(), + }); + original = { target: value.target, snapshot: snapshot() }; + } + const on = (type: string, fn: (event: Event) => void) => + document.addEventListener(type, fn, { capture: true, signal: abort.signal }); + on("focusin", (event) => { + if (event.target instanceof Element && event.target.matches(controls)) + original = { target: event.target, snapshot: snapshot() }; + }); + on("beforeinput", (event) => { + if (!agent && event.target instanceof Element && event.target.matches(controls) && !pending) + original = { target: event.target, snapshot: snapshot() }; + }); + on("input", (event) => { + if ( + !event.isTrusted || + agent || + !(event.target instanceof Element) || + !event.target.matches(controls) + ) + return; + if (pending?.target !== event.target) flush(); + const starting = !pending; + pending ??= { + target: event.target, + at: Date.now(), + before: original?.target === event.target ? original.snapshot : snapshot(), + }; + clearTimeout(timer); + if (starting) + emit({ + kind: "input_start", + at: pending.at, + target: label(event.target), + before: pending.before, + }); + timer = setTimeout(flush, 350); + }); + on("focusout", () => { + if (!agent) flush(); + }); + on("click", (event) => { + if (!event.isTrusted || agent || !(event.target instanceof Element)) return; + const target = event.target.closest( + "button,a,[role=button],input[type=submit],input[type=checkbox],input[type=radio]", + ); + if (!target) return; + flush(); + emit({ kind: "click", at: Date.now(), target: label(target), before: snapshot() }); + }); + on("submit", (event) => { + if (!event.isTrusted || agent || !(event.target instanceof Element)) return; + flush(); + emit({ kind: "submit", at: Date.now(), target: label(event.target), before: snapshot() }); + }); + window.addEventListener( + "pagehide", + () => { + if (agent) return; + flush(); + emit({ kind: "navigate", at: Date.now(), target: "", before: snapshot() }); + }, + { signal: abort.signal }, + ); + const mutations = new MutationObserver(() => { + if (changedTimer) return; + changedTimer = setTimeout(() => { + changedTimer = undefined; + emit({ kind: "changed" }); + }, 700); + }); + mutations.observe(document, { subtree: true, childList: true, characterData: true }); + let performanceCapture: ReturnType | undefined; + try { + performanceCapture = observePerformance((data) => emit({ kind: "performance", data }), early); + } catch { + emit({ kind: "performance_error" }); + } + host[slot] = { + performance: () => performanceCapture?.snapshot(), + finishPerformance: () => performanceCapture?.finish(), + snapshot, + flush, + agent(value: boolean, revision: number, expiresAt: number) { + // A queued pre-read may execute after its command finished or was cancelled. + if (revision <= agentRevision) return; + agentRevision = revision; + if (value && Date.now() >= expiresAt) return; + if (value) flush(); + agent = value; + }, + dispose() { + abort.abort(); + mutations.disconnect(); + performanceCapture?.dispose(); + clearTimeout(timer); + clearTimeout(changedTimer); + delete host[slot]; + }, + }; + emit({ kind: "ready" }); +} + +export interface FieldSnapshot { + fields: DebugField[]; + fields_partial: boolean; + navigation?: string; + text?: string; + truncated?: boolean; +} +export function sanitizeFields(value: unknown): FieldSnapshot { + const data = value as Partial | undefined; + const fields = Array.isArray(data?.fields) + ? data.fields.slice(0, 16).flatMap((field) => { + if (!field || typeof field.key !== "string" || typeof field.label !== "string") return []; + const sensitive = + /password|passwd|pwd|secret|token|credential|api.?key|session|credit|card.?number|cc-|one-time-code/i.test( + `${field.key} ${field.name} ${field.label}`, + ); + const state = + sensitive || field.state === "redacted" + ? "redacted" + : field.state === "truncated" || + (typeof field.value === "string" && field.value.length > 256) + ? "truncated" + : "available"; + return [ + { + key: redactText(field.key, 256), + label: redactText(field.label, 120), + ...(typeof field.name === "string" ? { name: redactText(field.name, 120) } : {}), + state, + ...(state !== "redacted" && typeof field.value === "string" + ? { value: redactText(field.value, 256) } + : {}), + } satisfies DebugField, + ]; + }) + : []; + return { + fields, + fields_partial: !Array.isArray(data?.fields) || !!data?.fields_partial, + ...(typeof data?.text === "string" + ? { + text: redactText(data.text, 6000), + truncated: !!data.truncated || data.text.length > 6000, + } + : {}), + ...(typeof data?.navigation === "string" ? { navigation: data.navigation.slice(0, 30) } : {}), + }; +} + +export class DebugObserver { + readonly world: string; + readonly binding: string; + private context?: number; + private rootFrame?: string; + private script?: string; + private stopped = false; + private agentRevision = 0; + private readonly contexts = new Set(); + constructor( + private readonly cdp: DebugCdp, + private readonly tabId: number, + id: string, + ) { + this.world = `bsk-debug-${id}`; + this.binding = `__bsk_debug_${id}`; + } + contextEvent(method: string, params: unknown): void { + const event = params as { + context?: { id: number; name: string; auxData?: { frameId?: string } }; + executionContextId?: number; + }; + if (method === "Runtime.executionContextsCleared") { + this.contexts.clear(); + this.context = undefined; + } + if (method === "Runtime.executionContextDestroyed" && event.executionContextId !== undefined) { + this.contexts.delete(event.executionContextId); + if (this.context === event.executionContextId) this.context = undefined; + } + if ( + method === "Runtime.executionContextCreated" && + event.context?.name === this.world && + event.context.auxData?.frameId === this.rootFrame + ) { + this.contexts.add(event.context.id); + } + } + accepts(params: { name?: string; executionContextId?: number }): boolean { + const accepted = + !this.stopped && + params.name === this.binding && + this.contexts.has(params.executionContextId ?? -1); + // Chrome can create more than one context with the same world name. Select + // the context that actually installed our observer, not the last created one. + if (accepted) this.context = params.executionContextId; + return accepted; + } + isRoot(frameId?: string): boolean { + return !!frameId && frameId === this.rootFrame; + } + private send(method: string, params?: object): Promise { + return this.cdp.sendAttached({ tabId: this.tabId }, method, params); + } + async start(): Promise { + const tree = await this.send<{ frameTree: { frame: { id: string } } }>("Page.getFrameTree"); + if (this.stopped) return; + this.rootFrame = tree.frameTree.frame.id; + const source = (early: boolean) => + `(${installObserver.toString()})(${JSON.stringify(this.binding)},${JSON.stringify(this.world)},(${installPerformance.toString()}),${early})`; + await this.send("Runtime.addBinding", { name: this.binding, executionContextName: this.world }); + if (this.stopped) { + await this.dispose(); + return; + } + const script = await this.send<{ identifier: string }>( + "Page.addScriptToEvaluateOnNewDocument", + { source: source(true), worldName: this.world }, + ); + this.script = script.identifier; + if (this.stopped) { + await this.dispose(); + return; + } + const created = await this.send<{ executionContextId: number }>("Page.createIsolatedWorld", { + frameId: tree.frameTree.frame.id, + worldName: this.world, + }); + this.context = created.executionContextId; + this.contexts.add(created.executionContextId); + if (!this.stopped) + await this.send("Runtime.evaluate", { expression: source(false), contextId: this.context }); + else await this.dispose(); + } + async call( + method: "snapshot" | "agent" | "flush" | "performance" | "finishPerformance", + value?: boolean, + ): Promise { + if (this.context === undefined || this.stopped) return undefined; + const args = + method === "agent" + ? `${value},${++this.agentRevision},${Date.now() + OBSERVATION_TIMEOUT_MS}` + : value === undefined + ? "" + : String(value); + const result = await this.send<{ result?: { value?: T } }>("Runtime.evaluate", { + expression: `globalThis[${JSON.stringify(this.world)}]?.${method}(${args})`, + contextId: this.context, + returnByValue: true, + }); + return result.result?.value; + } + async dispose(): Promise { + this.stopped = true; + const work: Promise[] = []; + if (this.script) { + work.push(this.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: this.script })); + this.script = undefined; + } + for (const contextId of this.contexts) + work.push( + this.send("Runtime.evaluate", { + expression: `globalThis[${JSON.stringify(this.world)}]?.dispose()`, + contextId, + }), + ); + work.push(this.send("Runtime.removeBinding", { name: this.binding })); + await Promise.allSettled(work); + this.contexts.clear(); + } +} diff --git a/apps/extension/src/debug/performance-observer.ts b/apps/extension/src/debug/performance-observer.ts new file mode 100644 index 00000000..6243771f --- /dev/null +++ b/apps/extension/src/debug/performance-observer.ts @@ -0,0 +1,269 @@ +/** Runs in the existing isolated main-frame debug world. No page objects leave it. */ +export function installPerformance(emit: (value: unknown) => void, early: boolean) { + const origin = performance.timeOrigin; + const abort = new AbortController(); + const observers: { + observer: PerformanceObserver; + consume: (entries: PerformanceEntry[]) => void; + }[] = []; + const supported = new Set( + typeof PerformanceObserver === "function" + ? (PerformanceObserver.supportedEntryTypes ?? []) + : [], + ); + let timer: ReturnType | undefined; + let disposed = false; + let visit = 0; + let since = early ? 0 : performance.now(); + let restored = false; + let ended = false; + let stopped = false; + let lcpClosed = false; + let initiallyHidden = document.visibilityState !== "visible"; + let firstHidden = initiallyHidden ? 0 : Infinity; + let visibility = [{ at: origin + since, state: document.visibilityState }]; + let fcp: number | undefined, lcp: number | undefined; + let cls = 0, + burst = 0, + burstStart = 0, + lastShift = -Infinity; + let taskCount = 0, + taskTotal = 0, + taskMax = 0; + let tasks: { at: number; duration_ms: number }[] = []; + const gaps = new Set(); + const valid = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0; + const schedule = () => { + if (!disposed && !timer) + timer = setTimeout(() => { + timer = undefined; + emit(snapshot()); + }, 500); + }; + function observe(type: string, consume: (entries: PerformanceEntry[]) => void) { + if (!supported.has(type)) return; + try { + const observer = new PerformanceObserver( + (list, _observer, options?: { droppedEntriesCount?: number }) => { + if (disposed || ended) return; + if (options?.droppedEntriesCount) gaps.add("browser_entry_buffer_full"); + const entries = list.getEntries(); + if (entries.length > 1000) gaps.add("performance_entry_limit"); + consume(entries.slice(0, 1000)); + schedule(); + }, + ); + observer.observe({ type, buffered: true }); + observers.push({ observer, consume }); + } catch { + supported.delete(type); + } + } + const inVisit = (entry: PerformanceEntry) => + valid(entry.startTime) && (!restored || entry.startTime >= since); + observe("paint", (entries) => { + for (const e of entries) + if (inVisit(e) && e.name === "first-contentful-paint" && e.startTime < firstHidden) + fcp = e.startTime; + }); + observe("largest-contentful-paint", (entries) => { + for (const e of entries) if (inVisit(e) && e.startTime < firstHidden) lcp = e.startTime; + }); + observe("layout-shift", (entries) => { + for (const e of entries) { + const shift = e as PerformanceEntry & { value?: number; hadRecentInput?: boolean }; + if (!inVisit(e) || shift.hadRecentInput || !valid(shift.value)) continue; + if (e.startTime - lastShift < 1000 && e.startTime - burstStart < 5000) burst += shift.value; + else { + burst = shift.value; + burstStart = e.startTime; + } + lastShift = e.startTime; + cls = Math.max(cls, burst); + } + }); + observe("longtask", (entries) => { + for (const e of entries) { + if (!inVisit(e) || !valid(e.duration)) continue; + taskCount++; + taskTotal += e.duration; + taskMax = Math.max(taskMax, e.duration); + tasks.push({ at: origin + e.startTime, duration_ms: e.duration }); + } + // Keep the most expensive tasks, while counters cover all observed entries. + tasks.sort((a, b) => b.duration_ms - a.duration_ms || a.at - b.at); + tasks = tasks.slice(0, 50); + }); + function drain() { + for (const item of observers) { + const entries = item.observer.takeRecords(); + if (entries.length > 1000) gaps.add("performance_entry_limit"); + item.consume(entries.slice(0, 1000)); + } + } + function snapshot(final = false) { + drain(); + const nav = performance.getEntriesByType("navigation")[0] as + | (PerformanceNavigationTiming & { activationStart?: number }) + | undefined; + const prerendered = + (nav?.activationStart ?? 0) > 0 || + !!(document as Document & { prerendering?: boolean }).prerendering; + const metric = (value: number | undefined, type: string, dynamic = false, paint = false) => { + const reasons: string[] = []; + let state = dynamic && !ended && !final ? "provisional" : "available"; + if (!supported.has(type)) { + state = "unsupported"; + value = undefined; + reasons.push("api_unsupported"); + } else if (restored && type !== "longtask" && type !== "layout-shift") { + state = "unsupported"; + reasons.push("back_forward_cache"); + value = undefined; + } else if (prerendered && type !== "longtask") { + state = "unsupported"; + reasons.push("prerendered_page"); + value = undefined; + } else if (paint && initiallyHidden) { + state = "unavailable"; + reasons.push("initially_hidden"); + value = undefined; + } else if (value === undefined) { + state = "unavailable"; + reasons.push(ended || final ? "not_observed" : "not_observed_yet"); + } else if ((!early && type !== "navigation") || gaps.size) { + state = "partial"; + if (!early) reasons.push("started_late"); + reasons.push(...gaps); + } + if (stopped && dynamic && state === "available") { + state = "partial"; + reasons.push("capture_stopped_before_final"); + } + return { ...(valid(value) ? { value } : {}), state, reasons }; + }; + const navigation = (value?: number) => + metric(value && value > 0 ? value : undefined, "navigation"); + return { + document_key: `${origin}:${visit}`, + time_origin: origin, + started_at: origin + since, + observed_at: Date.now(), + url: location.href, + navigation: restored ? "back_forward_cache" : (nav?.type ?? "unknown"), + state: ended || final ? "completed" : "capturing", + early, + scope: "main_frame", + visibility, + visibility_truncated: gaps.has("visibility_history_limit"), + metrics: { + ttfb_ms: navigation(nav?.responseStart), + dom_content_loaded_ms: navigation(nav?.domContentLoadedEventEnd), + load_ms: navigation(nav?.loadEventEnd), + fcp_ms: metric(fcp, "paint", false, true), + lcp_ms: metric(lcp, "largest-contentful-paint", !lcpClosed, true), + cls: metric(cls, "layout-shift", true), + long_task_count: metric(taskCount, "longtask"), + long_task_total_ms: metric(taskTotal, "longtask"), + long_task_max_ms: metric(taskMax, "longtask"), + }, + long_tasks: tasks, + long_tasks_truncated: taskCount > tasks.length, + coverage: [ + ...new Set([ + "main_frame_only", + ...(!early ? ["started_late", "visibility_before_capture_unknown"] : []), + ...gaps, + ]), + ], + }; + } + function send(final = false) { + if (disposed) return; + clearTimeout(timer); + timer = undefined; + emit(snapshot(final)); + } + document.addEventListener( + "visibilitychange", + () => { + if (visibility.length < 64) + visibility.push({ at: Date.now(), state: document.visibilityState }); + else gaps.add("visibility_history_limit"); + if (document.visibilityState === "hidden") { + firstHidden = Math.min(firstHidden, performance.now()); + lcpClosed = true; + } + send(); + }, + { signal: abort.signal }, + ); + for (const type of ["pointerdown", "keydown", "scroll"]) + document.addEventListener( + type, + () => { + if (!lcpClosed) { + lcpClosed = true; + send(); + } + }, + { capture: true, passive: true, signal: abort.signal }, + ); + window.addEventListener("load", schedule, { signal: abort.signal }); + document.addEventListener("DOMContentLoaded", schedule, { signal: abort.signal }); + window.addEventListener( + "pagehide", + () => { + send(true); + ended = true; + }, + { signal: abort.signal }, + ); + window.addEventListener( + "pageshow", + (event) => { + if (!event.persisted) return; + visit++; + since = performance.now(); + restored = true; + early = true; + ended = false; + stopped = false; + fcp = lcp = undefined; + cls = burst = burstStart = taskCount = taskTotal = taskMax = 0; + lastShift = -Infinity; + tasks = []; + gaps.clear(); + lcpClosed = false; + initiallyHidden = document.visibilityState !== "visible"; + firstHidden = initiallyHidden ? 0 : Infinity; + visibility = [{ at: origin + since, state: document.visibilityState }]; + send(); + }, + { signal: abort.signal }, + ); + send(); + return { + snapshot, + finish: () => { + clearTimeout(timer); + timer = undefined; + stopped = true; + const value = snapshot(); + value.state = "completed"; + ended = true; + for (const item of Object.values(value.metrics)) + if (item.state === "provisional") { + item.state = "partial"; + item.reasons.push("capture_stopped_before_final"); + } + return value; + }, + dispose: () => { + disposed = true; + abort.abort(); + clearTimeout(timer); + for (const item of observers) item.observer.disconnect(); + }, + }; +} diff --git a/apps/extension/src/debug/performance.ts b/apps/extension/src/debug/performance.ts new file mode 100644 index 00000000..33d4c99f --- /dev/null +++ b/apps/extension/src/debug/performance.ts @@ -0,0 +1,93 @@ +import { redactUrl } from "./redact"; +import type { DebugMetric, DebugPerformance } from "./types"; + +export const PERFORMANCE_LIMIT = 20; +export const PERFORMANCE_METRICS = [ + "ttfb_ms", + "dom_content_loaded_ms", + "load_ms", + "fcp_ms", + "lcp_ms", + "cls", + "long_task_count", + "long_task_total_ms", + "long_task_max_ms", +] as const; +const finite = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0; +const strings = (value: unknown): string[] => + Array.isArray(value) + ? value + .filter((s): s is string => typeof s === "string") + .slice(0, 16) + .map((s) => s.slice(0, 80)) + : []; + +/** Bindings are isolated, but still validate lengths/types before persistent storage. */ +export function performanceSnapshot( + value: unknown, +): Omit | undefined { + if (!value || typeof value !== "object") return; + const data = value as DebugPerformance; + if ( + typeof data.document_key !== "string" || + !/^\d+(?:\.\d+)?:\d+$/.test(data.document_key) || + data.document_key.length > 64 || + !finite(data.started_at) || + !finite(data.time_origin) || + !finite(data.observed_at) || + typeof data.url !== "string" || + !/^https?:/.test(data.url) + ) + return; + const metrics: Record = {}; + for (const key of PERFORMANCE_METRICS) { + const entry = data.metrics?.[key]; + if ( + !entry || + !["available", "provisional", "partial", "unavailable", "unsupported"].includes(entry.state) + ) + continue; + metrics[key] = { + ...(finite(entry.value) ? { value: entry.value } : {}), + state: entry.state, + reasons: strings(entry.reasons), + }; + } + return { + document_key: data.document_key, + time_origin: data.time_origin, + started_at: data.started_at, + observed_at: data.observed_at, + url: redactUrl(data.url), + navigation: typeof data.navigation === "string" ? data.navigation.slice(0, 40) : "unknown", + state: data.state === "completed" ? "completed" : "capturing", + early: data.early === true, + scope: "main_frame", + metrics, + visibility: Array.isArray(data.visibility) + ? data.visibility + .slice(0, 64) + .filter((v) => v && finite(v.at) && ["hidden", "visible"].includes(v.state)) + .map((v) => ({ at: v.at, state: v.state })) + : [], + visibility_truncated: !!data.visibility_truncated, + long_tasks: Array.isArray(data.long_tasks) + ? data.long_tasks + .slice(0, 50) + .filter((t) => t && finite(t.at) && finite(t.duration_ms)) + .map((t) => ({ at: t.at, duration_ms: t.duration_ms })) + : [], + long_tasks_truncated: !!data.long_tasks_truncated, + coverage: strings(data.coverage), + }; +} +export function interruptPerformance(entry: DebugPerformance, reason: string): void { + if (entry.state !== "capturing") return; + entry.state = "interrupted"; + entry.coverage = [...new Set([...entry.coverage, reason])]; + for (const metric of Object.values(entry.metrics)) + if (metric.state === "provisional") { + metric.state = "partial"; + metric.reasons = [...new Set([...metric.reasons, reason])]; + } +} diff --git a/apps/extension/src/debug/query.ts b/apps/extension/src/debug/query.ts new file mode 100644 index 00000000..9d941766 --- /dev/null +++ b/apps/extension/src/debug/query.ts @@ -0,0 +1,203 @@ +import { DEBUG_FIELDS, QUERY_LIMITS } from "./capabilities"; +import { requestKind } from "./evidence-model"; +import type { DebugParams, DebugRequest, DebugResult } from "./types"; + +export function matchesRequest(entry: DebugRequest, params: DebugParams): boolean { + return ( + (!params.url || entry.url.includes(params.url)) && + (!params.method || entry.method === params.method) && + (!params.resource_type || entry.resource_type === params.resource_type) && + (params.status === undefined || entry.status === params.status) && + (!params.state || entry.state === params.state) && + (!params.kind || params.kind === "all" || requestKind(entry) === params.kind) + ); +} +export function projectFields(entry: DebugRequest, fields?: string[]): DebugRequest { + if (!fields) return entry; + const result = { ...entry }; + for (const field of DEBUG_FIELDS) if (!fields.includes(field)) delete result[field]; + return result; +} +const prefix = (text: string, end: number): string => + text.slice( + 0, + /[\uD800-\uDBFF]/.test(text.charAt(end - 1)) && /[\uDC00-\uDFFF]/.test(text.charAt(end)) + ? end - 1 + : end, + ); +const compactUrl = (url: string): string => + url.startsWith("data:") + ? `${prefix(url, Math.min(url.indexOf(",") < 0 ? 40 : url.indexOf(","), 80))},[inline content omitted]` + : url; + +/** Trim only the response projection, never the stored recording. Always valid JSON. */ +export function budgetResult(value: DebugResult, params: DebugParams): DebugResult { + if (params.action === "export") return value; + const result = structuredClone(value); + const budget = params.budget ?? QUERY_LIMITS.budget.default; + const output = { budget, truncated: false, omitted: [] as string[] }; + result.output = output; + const omit = (path: string) => { + output.truncated = true; + if (!output.omitted.includes(path)) { + if (output.omitted.length < 12) output.omitted.push(path); + else output.omitted[11] = "additional_fields"; + } + }; + // Count the pretty JSON returned by the CLI too, not merely raw string lengths. + const integerCount = (value: unknown): number => { + if (typeof value === "number") return Number.isInteger(value) ? 1 : 0; + if (!value || typeof value !== "object") return 0; + return Object.values(value).reduce((sum, child) => sum + integerCount(child), 0); + }; + // Rust serializes integral f64 timestamps/timings with `.0`. Reserve for every + // integer (a conservative upper bound), plus CLI discovery metadata. + const size = () => + new TextEncoder().encode(JSON.stringify(result, null, 2)).byteLength + + integerCount(result) * 2 + + (params.action === "capabilities" ? 1024 : 0); + for (const request of [...(result.requests ?? []), ...(result.request ? [result.request] : [])]) { + if (request.url.startsWith("data:")) { + request.url = compactUrl(request.url); + omit("inline_url_content"); + } + const projected = projectFields(request, params.fields); + for (const key of DEBUG_FIELDS) if (!(key in projected)) delete request[key]; + } + if (size() <= budget) return result; + // Large narrative page snapshots are independently available through pages/request reads. + const shorten = (object: unknown, path: string): void => { + if (!object || typeof object !== "object") return; + for (const [key, value] of Object.entries(object)) { + const at = path ? `${path}.${key}` : key; + if ( + typeof value === "string" && + value.length > 240 && + !["id", "run_id", "session_id"].includes(key) + ) { + (object as Record)[key] = `${prefix(value, 200)}…`; + omit(at); + } else if (value && typeof value === "object") shorten(value, at); + } + }; + // Lists paginate at whole entries so the continuation never skips hidden rows. + if (params.action === "requests" || params.action === "operations") { + const entries = params.action === "requests" ? result.requests! : result.operations!; + while (entries.length > 1 && size() > budget) { + entries.pop(); + omit(params.action); + } + if (output.omitted.includes(params.action)) { + result.next_since = entries.at(-1)?.sequence ?? value.next_since; + result.truncated = true; + } + } + if (["pages", "console", "performance", "aggregate", "duplicates"].includes(params.action)) { + const key = + params.action === "aggregate" + ? "aggregates" + : (params.action as "pages" | "console" | "performance" | "duplicates"); + const entries = result[key] ?? []; + while (entries.length > 1 && size() > budget) { + entries.pop(); + omit(params.action); + } + if (output.omitted.includes(params.action)) { + result.next_offset = (params.offset ?? 0) + entries.length; + result.truncated = true; + } + } + if (size() > budget) { + shorten(result.run, "run"); + for (const key of [ + "operation", + "operations", + "evidence", + "pages", + "console", + "rules", + "requests", + ] as const) { + if (size() <= budget) break; + shorten(result[key], key); + } + } + // Free metadata space first; selected body slices must always make progress. + if (result.request && size() > budget) { + const entry = result.request; + for (const key of ["request_headers", "response_headers", "timing", "initiator"] as const) { + if (size() <= budget) break; + if (entry[key] !== undefined) { + delete entry[key]; + omit(`request.${key}`); + } + } + if (entry.url.length > 200) { + entry.url = prefix(entry.url, 200); + omit("request.url"); + } + shorten(result.run, "run"); + // Body reads preserve exact content and return an adjusted UTF-16 offset. + for (const key of ["request_body", "response_body"] as const) { + const body = entry[key]; + if (body.text === undefined || size() <= budget) continue; + const original = body.text; + const originalNext = body.next_offset; + omit(`request.${key}.text`); + let low = 0, + high = original.length; + body.next_offset = (body.offset ?? 0) + original.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + body.text = original.slice(0, middle); + if (size() <= budget) low = middle; + else high = middle - 1; + } + if (!low && original.length) + throw new Error( + "output budget too small for body slice; increase budget or select fewer fields", + ); + low = prefix(original, low).length; + if (!low && original.length) + throw new Error("increase budget for a complete Unicode character"); + body.text = original.slice(0, low); + body.next_offset = low < original.length ? (body.offset ?? 0) + low : originalNext; + } + } + for (const key of [ + "evidence", + "pages", + "console", + "rules", + "replays", + "requests", + "operations", + "runs", + ] as const) { + if (size() <= budget) break; + if ( + key === params.action && + ["requests", "operations", "pages", "console", "rules"].includes(params.action) + ) + continue; + if (result[key] !== undefined) { + delete result[key]; + omit(key); + } + } + if (size() > budget && result.operation) { + const op = result.operation; + delete op.before; + delete op.after; + delete op.observations; + op.request_ids = []; + op.console_ids = []; + omit("operation.details"); + } + if (size() > budget && result.run) { + delete result.run; + omit("run"); + } + if (size() > budget) throw new Error("output budget too small; increase budget"); + return result; +} diff --git a/apps/extension/src/debug/recording.ts b/apps/extension/src/debug/recording.ts new file mode 100644 index 00000000..026c6e45 --- /dev/null +++ b/apps/extension/src/debug/recording.ts @@ -0,0 +1,113 @@ +import { analyzeRecording } from "./analysis"; +import { operationContext, operationEvidence } from "./evidence-model"; +import { requestProjection } from "./network-store"; +import { matchesRequest, projectFields } from "./query"; +import type { DebugParams, DebugRecording, DebugResult } from "./types"; + +/** Read retained data only. This path never attaches to or evaluates a website. */ +export function readRecording(recording: DebugRecording, params: DebugParams): DebugResult { + const result: DebugResult = { session_id: recording.run.session_id, run: recording.run }; + const since = params.since ?? 0; + const limit = params.limit ?? 30; + if (["aggregate", "duplicates"].includes(params.action)) + return analyzeRecording(recording, params); + if (params.action === "performance") { + const all = recording.performance ?? []; + const offset = params.offset ?? 0, + end = Math.min(all.length, offset + (params.limit ?? 30)); + return { + ...result, + performance: all.slice(offset, end), + ...(end < all.length ? { next_offset: end, truncated: true } : {}), + }; + } + if (params.action === "export") + return { + ...result, + recording: { + ...recording, + operations: recording.operations.map( + (entry) => operationContext(recording, entry).operation, + ), + }, + }; + if (params.action === "rules") + return { ...result, rules: recording.rules ?? [], replays: recording.replays ?? [] }; + if (params.action === "pages" || params.action === "console") { + const values = recording[params.action]; + const offset = params.offset ?? 0; + const end = Math.min(values.length, offset + (params.limit ?? values.length)); + return { + ...result, + [params.action]: values.slice(offset, end), + ...(end < values.length ? { next_offset: end, truncated: true } : {}), + }; + } + if (params.action === "request") { + const entry = recording.requests.find((item) => item.id === params.id); + if (!entry) throw new Error("request not found or evicted"); + return { + ...result, + request: requestProjection( + entry, + params.part, + params.offset, + params.max_chars, + params.pointer, + ), + }; + } + if (params.action === "requests" || params.action === "operations") { + const requests = params.action === "requests"; + const entries = ( + requests + ? recording.requests.filter((entry) => matchesRequest(entry, params)) + : recording.operations + ) + .filter((entry) => entry.sequence > since) + .sort((a, b) => a.sequence - b.sequence); + const page = entries.slice(0, limit); + const data = requests + ? { + requests: recording.requests + .filter((item) => page.includes(item)) + .sort((a, b) => a.sequence - b.sequence) + .map((item) => projectFields(requestProjection(item), params.fields)), + } + : { + operations: recording.operations + .filter((item) => page.includes(item)) + .sort((a, b) => a.sequence - b.sequence) + .map((entry) => { + const { + before: _before, + after: _after, + observations: _observations, + ...item + } = operationContext(recording, entry).operation; + return item; + }), + }; + return { + ...result, + ...data, + next_since: page.at(-1)?.sequence ?? recording.run.next_since, + truncated: + entries.length > limit || + (requests ? recording.run.dropped_requests : recording.run.dropped_operations) > 0, + }; + } + if (params.action === "operation") { + const operation = recording.operations.find((entry) => entry.id === params.id); + if (!operation) throw new Error("operation not found or evicted"); + const context = operationContext(recording, operation); + return { + ...result, + operation: context.operation, + evidence: operationEvidence(recording, operation, context), + requests: context.requests.map((entry) => requestProjection(entry)), + console: context.console, + }; + } + throw new Error("unsupported history action"); +} diff --git a/apps/extension/src/debug/redact.ts b/apps/extension/src/debug/redact.ts new file mode 100644 index 00000000..6de77e90 --- /dev/null +++ b/apps/extension/src/debug/redact.ts @@ -0,0 +1,173 @@ +import { type JsonSource, parseJsonSource } from "./json-source"; + +/** Redact before retaining evidence, including URLs and JSON/form values. */ +const SECRET = + /^(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|pwd|secret|client[_-]?secret|(?:(?:access|refresh|id|auth|csrf|xsrf)[_-]?)?token|api[_-]?key|x-api-key|x-auth-token|x-csrf-token|x-xsrf-token|session[_-]?(?:id|key)|credentials?)$/i; +const MASK = "[redacted]"; +export const BODY_CHARS = 64 * 1024; +export const URL_CHARS = 2048; + +function secretField(key: string): boolean { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .split(/[.[\]]+/) + .some((part) => SECRET.test(part) || /(?:^|[_-])(?:password|passwd|pwd)(?:[_-]|$)/i.test(part)); +} + +export function redactText(value: string, cap = 4096): string { + return value + .slice(0, cap) + .replace(/\b(Bearer|Basic)\s+[\w.+/~=-]+/gi, `$1 ${MASK}`) + .replace( + /((?:password|passwd|pwd|secret|(?:(?:access|refresh|id|auth|csrf|xsrf)[_-]?)?token|api[_-]?key)["']?\s*[=:]\s*)(?:"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|[^\s,;&}]+)/gi, + `$1${MASK}`, + ); +} + +export function redactRequestUrl(value: string): { + text: string; + state: "complete" | "redacted" | "truncated"; +} { + let result: string; + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if (secretField(key)) url.searchParams.set(key, MASK); + } + result = url.href; + } catch { + result = value; + } + const text = redactText(result, URL_CHARS); + return { + text: text.slice(0, URL_CHARS), + state: + Math.max(result.length, text.length) > URL_CHARS + ? "truncated" + : text !== value + ? "redacted" + : "complete", + }; +} + +export function redactUrl(value: string): string { + return redactRequestUrl(value).text; +} + +export function redactHeaders(value: unknown): Record { + const result: Record = {}; + if (!value || typeof value !== "object") return result; + let remaining = 4 * 1024; + for (const [key, raw] of Object.entries(value).slice(0, 80)) { + if (remaining <= 0) break; + const name = key.slice(0, 128).toLowerCase(); + const text = SECRET.test(name) ? MASK : redactText(String(raw), Math.min(2048, remaining)); + // Define avoids the legacy __proto__ setter for untrusted header names. + Object.defineProperty(result, name, { value: text, enumerable: true, configurable: true }); + remaining -= name.length + text.length; + } + return result; +} + +function redactJson(text: string, bounds: { truncated: boolean }): string { + const root = parseJsonSource(text); + const parts: string[] = []; + let cursor = 0; + const replace = (node: JsonSource, value: string) => { + parts.push(text.slice(cursor, node.start), JSON.stringify(value)); + cursor = node.end; + }; + const visit = (node: JsonSource, depth: number, secret = false) => { + if (secret) replace(node, MASK); + else if (depth > 24) { + bounds.truncated = true; + replace(node, "[depth limit]"); + } else if (node.children) { + for (const child of node.children) + visit(child.value, depth + 1, node.kind === "object" && secretField(child.key)); + } else if (node.kind === "string") { + const value: string = JSON.parse(text.slice(node.start, node.end)); + const redacted = redactText(value, BODY_CHARS); + if (redacted !== value) replace(node, redacted); + } + }; + visit(root, 0); + parts.push(text.slice(cursor)); + return parts.join(""); +} + +export function redactBody( + text: string, + mime: string, +): { + text: string; + redacted: boolean; + truncated: boolean; + replay_safe: boolean; + reason?: "unparsed_json"; +} { + const truncated = text.length > BODY_CHARS; + // Structured payloads must be redacted before truncation. Refuse oversized JSON + // rather than retaining a prefix that may contain a cut-off secret value. + if (truncated && /json|x-www-form-urlencoded/i.test(mime)) { + return { text: "", redacted: true, truncated: true, replay_safe: false }; + } + let result: string; + const bounds = { truncated: false }; + if (/json/i.test(mime) || /^[\s]*[\[{]/.test(text)) { + try { + if (truncated) return { text: "", redacted: true, truncated: true, replay_safe: false }; + result = redactJson(text, bounds); + } catch { + // An unparsed structured payload cannot be safely inspected for nested secrets. + return { + text: "", + redacted: true, + truncated: true, + replay_safe: false, + reason: "unparsed_json", + }; + } + } else if (/html/i.test(mime)) { + // A password can also occur in a server-rendered input's value attribute. + // Remove that input from retained HTML rather than exposing its initial value. + result = redactText( + text.replace(/"']|"[^"]*"|'[^']*')*>/gi, (tag) => { + const attributes = tag.matchAll( + /\b(type|name|id|autocomplete)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, + ); + for (const match of attributes) { + const value = match[2] ?? match[3] ?? match[4]; + if ( + secretField(value) || + /^(?:current-password|new-password|one-time-code|cc-.+)$/i.test(value) + ) + return ''; + } + return tag; + }), + BODY_CHARS, + ); + } else if (/x-www-form-urlencoded/i.test(mime)) { + // Preserve untouched bytes, ordering and duplicates (including signed form bodies). + result = text + .split("&") + .map((field) => { + const key = new URLSearchParams(field).keys().next().value; + if (key === undefined || !secretField(key)) return field; + return `${field.split("=", 1)[0]}=${encodeURIComponent(MASK)}`; + }) + .join("&"); + } else { + result = redactText(text, BODY_CHARS); + } + return { + text: result.slice(0, BODY_CHARS), + redacted: result !== text, + truncated: truncated || bounds.truncated || result.length > BODY_CHARS, + replay_safe: !truncated && !bounds.truncated && result === text, + }; +} diff --git a/apps/extension/src/debug/types.ts b/apps/extension/src/debug/types.ts new file mode 100644 index 00000000..c1e48c8f --- /dev/null +++ b/apps/extension/src/debug/types.ts @@ -0,0 +1,406 @@ +/** Opt-in, task-owned evidence. All timestamps are epoch milliseconds. */ +export type DebugAction = + | "performance" + | "aggregate" + | "duplicates" + | "capabilities" + | "activity" + | "wait" + | "pin" + | "unpin" + | "start" + | "stop" + | "status" + | "requests" + | "request" + | "operations" + | "operation" + | "console" + | "pages" + | "export" + | "rules" + | "rule_add" + | "rule_enable" + | "rule_disable" + | "rule_remove" + | "replay"; + +export interface DebugRequestEdit { + url?: string; + method?: string; + headers?: Record; + body?: string; + /** Top-level JSON object edits; applied to the live request, before redaction. */ + json?: { set?: Record; remove?: string[]; rename?: Record }; +} +export interface DebugRuleSpec { + name?: string; + match: { url: string; method?: string; resource_type?: "Fetch" | "XHR" | "Document" }; + effect: + | { type: "block" } + | ({ type: "modify" } & DebugRequestEdit) + | { + type: "mock"; + status: number; + headers?: Record; + body: string; + delay_ms?: number; + }; + /** Defaults to one match. Zero means until disabled or capture ends. */ + times?: number; +} +export interface DebugRule extends DebugRuleSpec { + id: string; + state: "enabled" | "disabled" | "exhausted" | "removed" | "stopped"; + hits: number; + failures: number; + created_at: number; + last_error?: string; +} +export interface DebugReplaySpec extends Omit { + /** Reusing a key in the same capture never sends another request. */ + key: string; +} +export interface DebugReplay { + id: string; + key: string; + source_request_id: string; + request_id?: string; + state: "running" | "complete" | "failed" | "interrupted"; + error?: string; +} +export interface DebugIntervention { + rule_id: string; + type: "block" | "modify" | "mock"; + state: "pending" | "applied" | "failed" | "cancelled"; + error?: string; + /** Small redacted change summary; retained request body is the effective body. */ + changes?: string[]; +} + +export interface DebugParams { + session_id: string; + action: DebugAction; + tab_id?: number; + run_id?: string; + id?: string; + name?: string; + since?: number; + limit?: number; + /** Detail projection. Metadata never includes body text. */ + part?: "metadata" | "request" | "response" | "headers" | "timing"; + offset?: number; + max_chars?: number; + /** RFC 6901 JSON pointer, applied to a complete redacted body. */ + pointer?: string; + rule?: DebugRuleSpec; + replay?: DebugReplaySpec; + /** UTF-8 bytes of JSON output (default 65536; export is exempt). */ + budget?: number; + slow_ms?: number; + window_ms?: number; + include_controlled?: boolean; + url?: string; + method?: string; + resource_type?: string; + status?: number; + state?: DebugRequest["state"]; + kind?: "business" | "resource" | "extension" | "all"; + fields?: string[]; + wait_ms?: number; + command_id?: string; +} + +export interface DebugBody { + state: "pending" | "available" | "empty" | "truncated" | "unavailable" | "omitted" | "evicted"; + reason?: string; + text?: string; + chars?: number; + offset?: number; + next_offset?: number; + redacted?: boolean; + /** True only when complete retained text is identical to the captured body. */ + replay_safe?: boolean; +} + +export interface DebugRequest { + id: string; + run_id: string; + sequence: number; + started_at: number; + finished_at?: number; + method: string; + url: string; + /** Absent on older evidence, whose URL/body fidelity cannot be established. */ + integrity?: { + url: "complete" | "redacted" | "truncated"; + metadata: "complete" | "truncated"; + }; + resource_type?: string; + frame_id?: string; + loader_id?: string; + state: "pending" | "complete" | "failed" | "redirected" | "interrupted"; + status?: number; + error?: string; + mime_type?: string; + duration_ms?: number; + transfer_bytes?: number; + decoded_bytes?: number; + from_cache?: boolean; + from_service_worker?: boolean; + redirect_from?: string; + initiator?: string; + request_headers?: Record; + response_headers?: Record; + timing?: Record; + request_body: DebugBody; + response_body: DebugBody; + truncated?: boolean; + intervention?: DebugIntervention; + replay_from?: string; + replay_id?: string; + pinned?: boolean; +} + +export interface DebugConsole { + id: string; + at: number; + level: string; + text: string; + count: number; + last_at: number; + stack?: string; + source?: "website" | "extension" | "browser" | "unknown"; + source_url?: string; + relation?: "window" | "delayed"; +} + +export interface DebugField { + key: string; + name?: string; + label: string; + value?: string; + state: "available" | "redacted" | "truncated"; +} + +export interface DebugPage { + at: number; + url?: string; + title?: string; + text?: string; + state: "available" | "unavailable"; + truncated?: boolean; + fields?: DebugField[]; + fields_partial?: boolean; + navigation?: string; +} + +export interface DebugOperation { + id: string; + run_id: string; + sequence: number; + method: string; + target?: string; + source?: "human" | "agent"; + started_at: number; + finished_at?: number; + /** Time-window correlation, never a causal assertion. */ + window_end?: number; + state: "running" | "completed" | "error" | "interrupted"; + error?: string; + before?: DebugPage; + after?: DebugPage; + observations?: DebugPage[]; + observation_end?: number; + observation_limited?: boolean; + request_ids: string[]; + console_ids: string[]; + truncated: boolean; +} + +export interface DebugRun { + id: string; + session_id: string; + tab_id: number; + name: string; + url: string; + started_at: number; + stopped_at?: number; + state: "capturing" | "stopped"; + stop_reason?: string; + requests: number; + operations: number; + errors: number; + dropped_requests: number; + dropped_operations: number; + dropped_console: number; + coverage: string[]; + next_since: number; + active_rules?: number; + saved_at?: number; + storage_error?: string; + storage?: { requests: number; bytes: number; dropped: number; pins: number }; + environment?: { extension_version?: string; user_agent?: string }; +} + +export interface DebugValue { + state: string; + value?: string; + source?: string; + at?: number; +} +export interface DebugFieldTrace { + key: string; + label: string; + before: DebugValue; + input: DebugValue; + submitted: DebugValue[]; + response: DebugValue[]; + later: DebugValue; +} +export interface DebugEvidence { + fields: DebugFieldTrace[]; + payloads: { + request_id: string; + part: string; + path: string; + value: string; + truncated?: boolean; + }[]; + links: { request_id: string; relation: "window" | "delayed" }[]; + gaps: string[]; + changes: { added: string[]; removed: string[]; truncated: boolean }; + observations: DebugPage[]; +} + +/** Portable, already-redacted snapshot. Also used by browser-local history. */ +export interface DebugRecording { + version: 1; + saved_at: number; + run: DebugRun; + requests: DebugRequest[]; + operations: DebugOperation[]; + console: DebugConsole[]; + pages: DebugPage[]; + rules?: DebugRule[]; + replays?: DebugReplay[]; + performance?: DebugPerformance[]; +} + +export interface DebugResult { + session_id: string; + run?: DebugRun; + runs?: DebugRun[]; + requests?: DebugRequest[]; + request?: DebugRequest; + operations?: DebugOperation[]; + operation?: DebugOperation; + console?: DebugConsole[]; + pages?: DebugPage[]; + recording?: DebugRecording; + evidence?: DebugEvidence; + rules?: DebugRule[]; + replays?: DebugReplay[]; + performance?: DebugPerformance[]; + replay?: DebugReplay; + aggregates?: DebugEndpoint[]; + duplicates?: DebugDuplicate[]; + analysis?: DebugAnalysis; + next_offset?: number; + next_since?: number; + truncated?: boolean; + capabilities?: Record; + output?: { budget: number; truncated: boolean; omitted: string[] }; + activity?: { + state: string; + command_id?: string; + method?: string; + started_at?: number; + elapsed_ms?: number; + wait_complete?: boolean; + wait_timed_out?: boolean; + }; +} + +export interface DebugTask { + session_id: string; + created_at: number; + tab_id?: number; + title?: string; + url?: string; + run?: DebugRun; +} + +export interface DebugMetric { + value?: number; + state: "available" | "provisional" | "partial" | "unavailable" | "unsupported"; + reasons: string[]; +} +export interface DebugPerformance { + id: string; + document_key: string; + sequence: number; + time_origin: number; + started_at: number; + observed_at: number; + url: string; + navigation: string; + state: "capturing" | "completed" | "interrupted"; + early: boolean; + scope: "main_frame"; + visibility: { at: number; state: string }[]; + visibility_truncated: boolean; + metrics: Record; + long_tasks: { at: number; duration_ms: number }[]; + long_tasks_truncated: boolean; + coverage: string[]; +} +export interface DebugEndpoint { + id: string; + method: string; + endpoint: string; + count: number; + failed: number; + http_errors: number; + pending: number; + interrupted: number; + statuses: Record; + slow: number; + timing_samples: number; + duration_ms?: { min: number; mean: number; p50: number; p95: number; max: number; total: number }; + transfer_bytes: number; + transfer_samples: number; + cached: number; + service_worker: number; + controlled: number; + replayed: number; + request_ids: string[]; + refs_truncated: boolean; +} +export interface DebugDuplicate { + id: string; + method: string; + url: string; + count: number; + extra_requests: number; + started_at: number; + ended_at: number; + overlap_count: number; + possible_retry: boolean; + request_ids: string[]; + operation_ids: string[]; + refs_truncated: boolean; +} +export interface DebugAnalysis { + retained: number; + matched: number; + included: number; + excluded_controlled: number; + uncomparable: number; + groups: number; + suspected_extra_requests: number; + window_ms: number; + slow_ms: number; + coverage: string[]; + semantics: string; +} diff --git a/apps/extension/src/debug/use-tasks.ts b/apps/extension/src/debug/use-tasks.ts new file mode 100644 index 00000000..73a81294 --- /dev/null +++ b/apps/extension/src/debug/use-tasks.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; +import { debugTasks } from "./client"; +import type { DebugTask } from "./types"; + +/** Poll only while extension UI is visible; never wake a closed popup. */ +export function useDebugTasks(enabled = true) { + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(""); + const [loaded, setLoaded] = useState(false); + const [revision, setRevision] = useState(0); + const refresh = useCallback(() => setRevision((value) => value + 1), []); + useEffect(() => { + if (!enabled) { + setTasks([]); + setLoaded(false); + return; + } + if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return; + let live = true; + let busy = false; + const update = async () => { + if (busy || document.hidden) return; + busy = true; + try { + const result = await debugTasks(); + if (live) { + setTasks(result.tasks ?? []); + setError(""); + } + } catch (err) { + if (live) { + setTasks([]); + setError(err instanceof Error ? err.message : "unavailable"); + } + } finally { + busy = false; + if (live) setLoaded(true); + } + }; + void update(); + const timer = setInterval(() => void update(), 2000); + document.addEventListener("visibilitychange", update); + return () => { + live = false; + clearInterval(timer); + document.removeEventListener("visibilitychange", update); + }; + }, [enabled, revision]); + return { tasks, error, loaded, refresh }; +} diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 830bbbe9..46644c1f 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -1,5 +1,8 @@ import { i18n } from "@browser-skill/i18n"; import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import { LocalDebugArchive } from "@/debug/archive"; +import { attachDebugBridge } from "@/debug/bridge"; +import { DebugManager } from "@/debug/manager"; import { getAuditEnabled } from "@/lib/audit"; import { attachAuditBridge } from "@/lib/audit-bridge"; import { ConnectionController } from "@/lib/connection-controller"; @@ -79,6 +82,11 @@ export default defineBackground(() => { return session !== null && (!session.remote || isAgentControlledTab(session, tabId)); }, }); + const debug = new DebugManager(sessions, cdp, chrome.tabs, Date.now, new LocalDebugArchive()); + attachDebugBridge(sessions, debug); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId !== undefined) debug.stopTab(source.tabId, "debugger_detached"); + }); const sessionsLive = attachSessionsLiveFlag({ manager: sessions }); let overlayGeneration = 0; const controlModes = new Map(); @@ -196,6 +204,7 @@ export default defineBackground(() => { } function onOverlaySessionStateChanged(): void { + debug.sync(); void sessionsLive.syncFromManager(); const liveSessionIds = new Set(sessions.list().map((ctx) => ctx.sessionId)); for (const sessionId of controlModes.keys()) { @@ -231,8 +240,11 @@ export default defineBackground(() => { if (!sessions.findByWindowId(tab.windowId)) return; void pushOverlayStateForTab(tab.id, tab.windowId); }); + chrome.tabs.onDetached.addListener((tabId) => debug.releaseTab(tabId)); chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { + debug.stopTab(tabId, "tab_closed"); sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing }); + debug.sync(); }); // Re-sync the storage.session flag on SW startup so a previous SW's // stale `true` does not keep waking us on every page load until the @@ -290,6 +302,7 @@ export default defineBackground(() => { }); void interactionPreferences.readyOrFallback(); const dispatcher = new ToolDispatcher({ + debug, interactionPreferences, transport, sessions, @@ -410,6 +423,7 @@ export default defineBackground(() => { ]); controller.setAuditEnabled(auditEnabled); const cleanup = async () => { + debug.dispose(); const report = await cleanupAfterDisconnect(); if (report.failures.length > 0) { throw new Error( diff --git a/apps/extension/src/entrypoints/debug/App.test.tsx b/apps/extension/src/entrypoints/debug/App.test.tsx new file mode 100644 index 00000000..c11e6bee --- /dev/null +++ b/apps/extension/src/entrypoints/debug/App.test.tsx @@ -0,0 +1,400 @@ +import { i18n } from "@browser-skill/i18n"; +import { cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + debugHistory, + debugRequest, + debugTasks, + deleteRecording, + recordingRequest, +} from "@/debug/client"; +import type { DebugOperation, DebugRequest, DebugRun } from "@/debug/types"; +import { DebugApp } from "./App"; +import { useRequests } from "./use-requests"; + +vi.mock("@/debug/client", () => ({ + debugHistory: vi.fn(), + debugRequest: vi.fn(), + debugTasks: vi.fn(), + deleteRecording: vi.fn(), + recordingRequest: vi.fn(), +})); +const run: DebugRun = { + id: "d1", + session_id: "s1", + tab_id: 7, + name: "Save fails", + url: "http://localhost:3000", + started_at: 1000, + state: "capturing", + requests: 1, + operations: 2, + errors: 1, + dropped_requests: 0, + dropped_console: 0, + dropped_operations: 0, + coverage: [], + next_since: 2, +}; +const request: DebugRequest = { + id: "d1:n1", + run_id: "d1", + sequence: 2, + started_at: 1010, + method: "POST", + url: "http://localhost:3000/api/save", + state: "complete", + status: 200, + request_body: { state: "available" }, + response_body: { state: "available" }, +}; +const operation: DebugOperation = { + id: "d1:a1", + run_id: "d1", + sequence: 1, + method: "tool.click", + target: "#save", + started_at: 1000, + state: "completed", + request_ids: [request.id], + console_ids: [], + truncated: false, + before: { at: 1000, state: "available", text: "Ready" }, + after: { at: 1100, state: "available", text: "Save failed" }, +}; +const second: DebugOperation = { + ...operation, + id: "d1:a2", + sequence: 2, + started_at: 2000, + after: { at: 2100, state: "available", text: "Saved" }, +}; + +beforeEach(async () => { + await i18n.changeLanguage("zh-CN"); + vi.stubGlobal("chrome", { runtime: { sendMessage: vi.fn() } }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); + vi.mocked(debugTasks).mockResolvedValue({ + tasks: [{ session_id: "s1", created_at: 1000, tab_id: 7, title: "App", run }], + }); + history.replaceState(null, "", "/debug.html?session=s1&run=d1"); + vi.mocked(debugHistory).mockResolvedValue({ runs: [run] }); + vi.mocked(debugRequest).mockResolvedValue({ + session_id: "s1", + run: { ...run, state: "stopped" }, + }); + vi.mocked(recordingRequest).mockImplementation(async (params) => { + const base = { session_id: "s1" }; + if (params.action === "status") return { ...base, runs: [run] }; + if (params.action === "requests") return { ...base, requests: [request], next_since: 2 }; + if (params.action === "operations") return { ...base, operations: [operation, second] }; + if (params.action === "operation") + return { + ...base, + operation: params.id === second.id ? second : operation, + requests: [request], + console: [], + }; + if (params.action === "request") + return { + ...base, + request: { + ...request, + response_body: { + state: "available", + text: '{"ok":false}', + offset: params.offset ?? 0, + ...(params.offset ? {} : { next_offset: 4096 }), + }, + request_headers: { authorization: "[redacted]" }, + response_headers: { "content-type": "application/json" }, + }, + }; + if (params.action === "console") + return { + ...base, + console: [ + { id: "c1", at: 1000, last_at: 1000, count: 1, level: "error", text: "Startup failure" }, + ], + }; + if (params.action === "pages") + return { + ...base, + pages: [{ at: 1000, state: "available", title: "Page on load", text: "Loaded context" }], + }; + if (params.action === "export") + return { + ...base, + recording: { + version: 1, + saved_at: 3000, + run, + requests: [request], + operations: [operation, second], + console: [], + pages: [], + }, + }; + return { ...base, run: { ...run, state: "stopped" } }; + }); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("website evidence workspace", () => { + it("drills into request projections, paginates bodies and returns to the timeline", async () => { + render(); + fireEvent.click(await screen.findByText("/api/save")); + expect(await screen.findByText('{"ok":false}')).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "下一段" })); + await waitFor(() => + expect(recordingRequest).toHaveBeenCalledWith( + expect.objectContaining({ action: "request", id: "d1:n1", part: "response", offset: 4096 }), + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Headers" })); + expect(await screen.findByText("[redacted]")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "返回操作" })); + fireEvent.click(screen.getAllByRole("button", { name: /点击 · #save/ })[0]); + expect(await screen.findByText("Save failed")).toBeTruthy(); + expect(screen.getByText("Ready")).toBeTruthy(); + }); + it("keeps history readable after the task ends, including global console and page context", async () => { + vi.mocked(debugTasks).mockResolvedValue({ tasks: [] }); + vi.mocked(debugHistory).mockResolvedValue({ + runs: [{ ...run, state: "stopped", stopped_at: 3000 }], + }); + render(); + fireEvent.click(await screen.findByText("/api/save")); + expect(await screen.findByText('{"ok":false}')).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "返回操作" })); + fireEvent.click(screen.getByRole("button", { name: /Console/ })); + expect(await screen.findByText("Startup failure")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /页面上下文/ })); + expect(await screen.findByText("Loaded context")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /对比/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "开启调试" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "删除记录" })); + fireEvent.click(screen.getByRole("alertdialog").querySelectorAll("button")[1]); + await waitFor(() => expect(deleteRecording).toHaveBeenCalledWith("d1")); + }); + + it("exports a historical record after the task ends", async () => { + vi.mocked(debugTasks).mockResolvedValue({ tasks: [] }); + const create = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:recording"); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(); + fireEvent.click(await screen.findByRole("button", { name: "导出 JSON" })); + await waitFor(() => + expect(recordingRequest).toHaveBeenCalledWith({ + action: "export", + session_id: "s1", + run_id: "d1", + }), + ); + expect(create.mock.calls[0][0]).toBeInstanceOf(Blob); + expect(click).toHaveBeenCalledOnce(); + create.mockRestore(); + click.mockRestore(); + }); + + it("stops capture without stopping the task", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "停止采集" })); + await waitFor(() => + expect(debugRequest).toHaveBeenCalledWith({ action: "stop", session_id: "s1", run_id: "d1" }), + ); + }); + + it("renders the empty task state without issuing capture calls", async () => { + vi.mocked(debugTasks).mockResolvedValue({ tasks: [] }); + vi.mocked(debugHistory).mockResolvedValue({ runs: [] }); + history.replaceState(null, "", "/debug.html"); + render(); + expect(await screen.findByText("还没有调试记录")).toBeTruthy(); + expect(debugRequest).not.toHaveBeenCalled(); + }); +}); + +describe("incremental request list", () => { + it("merges updates, resyncs on retention, and pauses when its panel is hidden", async () => { + let current = { ...run, next_since: 101 }; + let retained = Array.from({ length: 101 }, (_, i) => ({ + ...request, + id: `d1:n${i + 1}`, + sequence: i + 1, + started_at: 1000 + i, + })); + vi.mocked(recordingRequest).mockImplementation(async (params) => { + const page = retained + .filter((entry) => entry.sequence > (params.since ?? 0)) + .sort((a, b) => a.sequence - b.sequence) + .slice(0, params.limit); + return { + session_id: "s1", + run: current, + requests: page, + next_since: page.at(-1)?.sequence ?? current.next_since, + }; + }); + const { result, rerender } = renderHook(({ run, enabled }) => useRequests(run, enabled), { + initialProps: { run: current, enabled: true }, + }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 100]); + retained[0] = { ...retained[0], status: 500, sequence: 102 }; + retained.push({ ...request, id: "d1:new", sequence: 103 }); + current = { ...current, next_since: 103 }; + rerender({ run: current, enabled: true }); + await waitFor(() => expect(result.current.requests).toHaveLength(102)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 101 })); + expect(result.current.requests.find((entry) => entry.id === "d1:n1")!.status).toBe(500); + retained = retained.filter((entry) => entry.id !== "d1:n2"); + current = { ...current, next_since: 104, dropped_requests: 1 }; + const before = vi.mocked(recordingRequest).mock.calls.length; + rerender({ run: current, enabled: true }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(vi.mocked(recordingRequest).mock.calls[before][0].since).toBe(0); + expect(result.current.requests.some((entry) => entry.id === "d1:n2")).toBe(false); + const calls = vi.mocked(recordingRequest).mock.calls.length; + current = { ...current, next_since: 105 }; + rerender({ run: current, enabled: false }); + expect(recordingRequest).toHaveBeenCalledTimes(calls); + rerender({ run: current, enabled: true }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(calls + 1)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 103 })); + }); + + it("serializes overlapping refreshes and keeps progress made by a superseded page", async () => { + let finish: (value: Awaited>) => void = () => {}; + vi.mocked(recordingRequest) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ + session_id: "s1", + requests: [{ ...request, id: "d1:n2", sequence: 3 }], + next_since: 3, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(1)); + rerender({ ...run, next_since: 3 }); + expect(recordingRequest).toHaveBeenCalledTimes(1); + finish({ session_id: "s1", requests: [request], next_since: 2 }); + await waitFor(() => expect(result.current.requests).toHaveLength(2)); + expect(recordingRequest).toHaveBeenLastCalledWith(expect.objectContaining({ since: 2 })); + }); + + it("restarts if retention changes during a read, without keeping removed rows", async () => { + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ session_id: "s1", requests: [request], next_since: 2 }) + .mockResolvedValueOnce({ + session_id: "s1", + run: { ...run, dropped_requests: 1 }, + requests: [], + next_since: 3, + }) + .mockResolvedValueOnce({ + session_id: "s1", + run: { ...run, dropped_requests: 1 }, + requests: [{ ...request, id: "d1:n2", sequence: 3 }], + next_since: 3, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe(request.id)); + rerender({ ...run, next_since: 3 }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe("d1:n2")); + expect(result.current.requests).toHaveLength(1); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 2, 0]); + }); + + it("rechecks older evidence after an incomplete storage fallback", async () => { + const fallbackRun = { ...run, coverage: ["evidence_read_failed"] }; + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ + session_id: "s1", + run: fallbackRun, + requests: [{ ...request, id: "d1:new", sequence: 50 }], + next_since: 50, + }) + .mockResolvedValueOnce({ + session_id: "s1", + run: fallbackRun, + requests: [request, { ...request, id: "d1:new", sequence: 50 }], + next_since: 51, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.requests).toHaveLength(1)); + rerender({ ...run, next_since: 51 }); + await waitFor(() => expect(result.current.requests).toHaveLength(2)); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 0]); + }); + + it("resumes after a failed page instead of discarding earlier progress", async () => { + const page = Array.from({ length: 100 }, (_, i) => ({ + ...request, + id: `d1:n${i + 1}`, + sequence: i + 1, + })); + vi.mocked(recordingRequest) + .mockResolvedValueOnce({ session_id: "s1", requests: page, next_since: 100 }) + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockResolvedValueOnce({ + session_id: "s1", + requests: [{ ...request, id: "d1:last", sequence: 101 }], + next_since: 101, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(result.current.error).toBe("storage unavailable")); + expect(result.current.requests).toHaveLength(100); + rerender({ ...run, next_since: 101 }); + await waitFor(() => expect(result.current.requests).toHaveLength(101)); + expect(result.current.error).toBe(""); + expect(vi.mocked(recordingRequest).mock.calls.map(([p]) => p.since)).toEqual([0, 100, 100]); + }); + + it("ignores a previous run's late response after the selection changes", async () => { + let finish: (value: Awaited>) => void = () => {}; + vi.mocked(recordingRequest) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ + session_id: "s2", + requests: [{ ...request, id: "d2:n1", run_id: "d2" }], + next_since: 2, + }); + const { result, rerender } = renderHook((value) => useRequests(value, true), { + initialProps: run, + }); + await waitFor(() => expect(recordingRequest).toHaveBeenCalledTimes(1)); + rerender({ ...run, id: "d2", session_id: "s2" }); + await waitFor(() => expect(result.current.requests[0]?.id).toBe("d2:n1")); + finish({ session_id: "s1", requests: [request], next_since: 2 }); + await Promise.resolve(); + expect(result.current.requests.map((entry) => entry.id)).toEqual(["d2:n1"]); + expect(recordingRequest).toHaveBeenLastCalledWith( + expect.objectContaining({ session_id: "s2", run_id: "d2", since: 0 }), + ); + }); +}); diff --git a/apps/extension/src/entrypoints/debug/App.tsx b/apps/extension/src/entrypoints/debug/App.tsx new file mode 100644 index 00000000..340a8337 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/App.tsx @@ -0,0 +1,820 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { Button } from "@browser-skill/ui"; +import { + RiArrowLeftLine, + RiArrowRightUpLine, + RiBugLine, + RiDeleteBinLine, + RiDownloadLine, + RiHistoryLine, + RiShieldCheckLine, + RiStopCircleLine, +} from "@remixicon/react"; +import { useEffect, useRef, useState } from "react"; +import { debugHistory, debugRequest, deleteRecording, recordingRequest } from "@/debug/client"; +import type { + DebugConsole, + DebugOperation, + DebugPage, + DebugRequest, + DebugResult, + DebugRun, +} from "@/debug/types"; +import { useDebugTasks } from "@/debug/use-tasks"; +import { AnalysisPanel, PerformancePanel } from "./analysis"; +import { + ConsoleList, + clock, + OperationName, + PageChanges, + PageState, + Quiet, + RequestDetail, + RequestList, +} from "./evidence"; +import { ReplayEditor, RuleEditor, RulesPanel } from "./network-controls"; +import { OperationEvidence } from "./operation-evidence"; +import { useRequests } from "./use-requests"; + +export function DebugApp() { + const { t } = useTranslation("extension"); + const { tasks, error: taskError, refresh } = useDebugTasks(); + const [selection, setSelection] = useState(() => { + const query = new URLSearchParams(location.search); + return { session: query.get("session") ?? "", run: query.get("run") ?? "" }; + }); + const [runs, setRuns] = useState([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(""); + const [historyError, setHistoryError] = useState(""); + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [filter, setFilter] = useState(""); + const [revision, setRevision] = useState(0); + const [operations, setOperations] = useState([]); + const [messages, setMessages] = useState([]); + const [pages, setPages] = useState([]); + const [operationId, setOperationId] = useState(""); + const [mode, setMode] = useState< + "requests" | "console" | "pages" | "rules" | "performance" | "analysis" + >("requests"); + const [detail, setDetail] = useState(); + const [selectedRequest, setSelectedRequest] = useState(); + const [requestPart, setRequestPart] = useState<"request" | "response">("response"); + const [controlEditor, setControlEditor] = useState<"replay" | "block" | "modify" | "mock">(); + const initialOperation = useRef(""); + const run = selection.run + ? runs.find((item) => item.id === selection.run) + : selection.session + ? runs.find((item) => item.session_id === selection.session) + : undefined; + const runId = run?.id; + const sessionId = run?.session_id ?? selection.session; + const task = tasks.find( + (item) => item.session_id === sessionId && (!run || run.started_at >= item.created_at), + ); + const isHistory = !selection.session && !selection.run; + const pulse = run?.next_since ?? 0; + const { requests, error: requestError } = useRequests( + run, + mode === "requests" && !operationId && !selectedRequest, + ); + const errorCounts = messages + .filter((entry) => entry.level === "error") + .reduce>((counts, entry) => { + const source = entry.source ?? "unknown"; + counts[source] = (counts[source] ?? 0) + entry.count; + return counts; + }, {}); + + function select(session = "", id = "") { + setSelection({ session, run: id }); + setError(""); + setConfirmDelete(false); + const query = new URLSearchParams(); + if (session) query.set("session", session); + if (id) query.set("run", id); + history.replaceState(null, "", `${location.pathname}${query.size ? `?${query}` : ""}`); + } + function openRequest(request: DebugRequest, part: "request" | "response" = "response") { + setControlEditor(undefined); + setRequestPart(part); + setSelectedRequest(request); + } + + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => document.documentElement.classList.toggle("dark", media.matches); + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, []); + + useEffect(() => { + let cancelled = false; + let pending = false; + const poll = async () => { + if (cancelled || document.hidden || pending) return; + pending = true; + try { + const result = await debugHistory(); + if (!cancelled) { + setRuns(result.runs); + setHistoryError(result.error ?? ""); + } + } catch (reason) { + if (!cancelled) setHistoryError(reason instanceof Error ? reason.message : String(reason)); + } finally { + pending = false; + if (!cancelled) setLoaded(true); + } + }; + void poll(); + const timer = setInterval(() => void poll(), 3000); + document.addEventListener("visibilitychange", poll); + return () => { + cancelled = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", poll); + }; + }, [revision]); + + useEffect(() => { + initialOperation.current = ""; + setOperations([]); + setMessages([]); + setPages([]); + setOperationId(""); + setDetail(undefined); + setSelectedRequest(undefined); + setControlEditor(undefined); + setMode("requests"); + }, [runId]); + + useEffect(() => { + if (!runId) return; + let cancelled = false; + const base = { session_id: sessionId, run_id: runId }; + void (async () => { + const [actions, consoleResult, pageResult] = await Promise.all([ + recordingRequest({ ...base, action: "operations", limit: 100 }), + recordingRequest({ ...base, action: "console" }), + recordingRequest({ ...base, action: "pages" }), + ]); + if (cancelled) return; + setOperations((actions.operations ?? []).sort((a, b) => a.started_at - b.started_at)); + if (initialOperation.current !== runId && actions.operations?.length) { + initialOperation.current = runId; + setOperationId(actions.operations.at(-1)!.id); + } + setMessages(consoleResult.console ?? []); + setPages(pageResult.pages ?? []); + })().catch((reason) => { + if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => { + cancelled = true; + }; + }, [runId, sessionId, pulse]); + + useEffect(() => { + if (!runId || !operationId) { + setDetail(undefined); + return; + } + let cancelled = false; + void recordingRequest({ + action: "operation", + session_id: sessionId, + run_id: runId, + id: operationId, + }).then( + (result) => { + if (!cancelled) setDetail(result); + }, + (reason: Error) => { + if (!cancelled) { + setDetail(undefined); + setError(reason.message); + } + }, + ); + return () => { + cancelled = true; + }; + }, [runId, sessionId, operationId, pulse]); + + async function capture() { + if (!task) return; + setBusy(true); + setError(""); + try { + const result = await debugRequest({ + session_id: task.session_id, + action: run?.state === "capturing" ? "stop" : "start", + ...(run?.state === "capturing" + ? { run_id: run.id } + : { tab_id: task.tab_id, name: task.title }), + }); + if (result.run) { + setRuns((current) => [ + result.run!, + ...current.filter((item) => item.id !== result.run!.id), + ]); + select(task.session_id, result.run.id); + } + setRevision((value) => value + 1); + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + + async function exportRecord() { + if (!run) return; + setBusy(true); + setError(""); + try { + const result = await recordingRequest({ + action: "export", + session_id: run.session_id, + run_id: run.id, + }); + if (!result.recording) throw new Error(t("debug.recordMissing")); + const blob = new Blob([JSON.stringify(result.recording, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `browser-debug-${run.id}.json`; + document.body.append(link); + try { + link.click(); + } finally { + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30_000); + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + + async function removeRecord() { + if (!run || run.state === "capturing") return; + setBusy(true); + setError(""); + try { + await deleteRecording(run.id); + setRuns((current) => current.filter((item) => item.id !== run.id)); + select(); + setRevision((value) => value + 1); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + + const visible = runs.filter((item) => + `${item.name} ${item.url} ${item.id}`.toLocaleLowerCase().includes(filter.toLocaleLowerCase()), + ); + return ( +
+
+
+ + BrowserSkill + / + {t("debug.title")} + {!isHistory && ( + + )} +
+
+
+
+
+

+ + {t(isHistory ? "debug.localHistory" : "debug.evidence")} +

+

+ {isHistory ? t("debug.history") : run?.name || task?.title || t("debug.title")} +

+

+ {isHistory ? t("debug.historyHelp") : run?.url || task?.url || t("debug.emptyHelp")} +

+
+ {!isHistory && ( +
+ {run && ( + + )} + {task && ( + + )} + {run && run.state !== "capturing" && ( + + )} +
+ )} +
+ {(error || requestError || historyError || taskError || run?.storage_error) && ( +

+ {run?.storage_error + ? `${t("debug.storageFailed")} ${run.storage_error}` + : historyError + ? `${t("debug.storageFailed")} ${historyError}` + : error || requestError || taskError} +

+ )} + {confirmDelete && ( +
+

{t("debug.deleteConfirm")}

+ + +
+ )} + {!loaded ? ( + {t("debug.loading")} + ) : isHistory ? ( + <> + {tasks.length > 0 && ( +
+

{t("debug.currentTasks")}

+
+ {tasks.map((item) => ( + + ))} +
+
+ )} + {runs.length > 0 && ( +
+ setFilter(event.target.value)} + className="w-full max-w-sm rounded-lg border border-input bg-card px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ring" + /> + + {visible.length} / {runs.length} + +
+ )} + {visible.length ? ( +
+ {visible.map((item) => ( + + ))} +
+ ) : ( +
+ +

+ {t(runs.length ? "debug.noMatches" : "debug.noHistory")} +

+ {t("debug.emptyHelp")} +
+ )} + + ) : !run ? ( +
+

+ {t( + selection.run + ? "debug.recordMissing" + : task + ? "debug.notCapturing" + : "debug.noSession", + )} +

+ {t("debug.emptyHelp")} + +
+ ) : ( + <> +
+ + {t(run.state === "capturing" ? "debug.capturing" : "debug.stopped")} + + + {run.id} + {run.environment?.extension_version && ( + BrowserSkill {run.environment.extension_version} + )} + {run.saved_at && {t("debug.savedAt", { time: clock(run.saved_at) })}} + {run.stop_reason && ( + + {t("debug.stopReason")}:{" "} + {t(`debug.reason_${run.stop_reason}` as "debug.reason_requested", { + defaultValue: run.stop_reason, + })} + + )} +
+
+ {( + [ + [run.requests, "requests"], + [run.operations, "operations"], + [errorCounts.website ?? 0, "websiteErrors"], + ] as const + ).map(([value, key]) => ( +
+ + {t(`debug.${key}`)} + + + {value} + +
+ ))} +
+

+ {(["website", "extension", "browser", "unknown"] as const).map((source) => ( + + {t(`debug.source_${source}`)} · {errorCounts[source] ?? 0} + + ))} +

+ {(run.dropped_requests + run.dropped_operations + run.dropped_console > 0 || + run.coverage.includes("page_context_limit") || + run.coverage.includes("interrupted_checkpoint")) && ( +

{t("debug.partial")}

+ )} + {run.storage && ( +

+ {t("debug.storageSaved", { count: run.storage.requests })} +

+ )} + {run.coverage + .filter((gap) => gap.startsWith("evidence_")) + .map((gap) => ( +

+ {t(`debug.gap_${gap}` as "debug.partial", { defaultValue: gap })} +

+ ))} +
+ +
+ {selectedRequest ? ( +
+ { + setSelectedRequest(undefined); + setControlEditor(undefined); + }} + onControl={task && run.state === "capturing" ? setControlEditor : undefined} + /> + {task && + run.state === "capturing" && + controlEditor && + (controlEditor === "replay" ? ( + setRevision((value) => value + 1)} + onCancel={() => setControlEditor(undefined)} + onRequest={(id) => { + void recordingRequest({ + action: "request", + session_id: sessionId, + run_id: run.id, + id, + }) + .then((value) => { + if (value.request) openRequest(value.request); + }) + .catch((reason) => setError(String(reason))); + }} + /> + ) : ( + setControlEditor(undefined)} + onDone={() => { + setControlEditor(undefined); + setSelectedRequest(undefined); + setOperationId(""); + setMode("rules"); + setRevision((value) => value + 1); + }} + /> + ))} +
+ ) : operationId ? ( + detail?.operation ? ( + <> +
+

+ +

+

+ {detail.operation.id} +

+ {detail.operation.error && ( +

+ {detail.operation.error} +

+ )} +
+ + {!detail.evidence && ( + + + + )} + + + +
+ + {t("debug.rawPageEvidence")} + + +
+

{t("debug.correlation")}

+ + ) : ( + {t("debug.loading")} + ) + ) : mode === "rules" ? ( + setRevision((value) => value + 1)} + /> + ) : mode === "performance" ? ( + + ) : mode === "analysis" ? ( + { + const request = requests.find((entry) => entry.id === id); + if (request) openRequest(request); + else + void recordingRequest({ + session_id: sessionId, + run_id: run.id, + action: "request", + id, + }).then( + (result) => { + if (result.request) openRequest(result.request); + }, + (reason) => setError(String(reason)), + ); + }} + /> + ) : mode === "requests" ? ( + + + + ) : mode === "console" ? ( + + + + ) : ( + + {pages.length ? ( + pages.map((page, index) => ( +
+ + {clock(page.at)} · {page.title || page.url || t("debug.unavailable")} + + +
+ )) + ) : ( + {t("debug.noData")} + )} +
+ )} +
+
+ + )} +
+ + + {t("debug.retention")} + +
+ {t("debug.coverage")} +

{t("debug.coverageHelp")}

+ {run?.coverage.some((value) => value.startsWith("child_capture")) && ( +

{t("debug.coveragePartial")}

+ )} +
+
+
+
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/apps/extension/src/entrypoints/debug/analysis.tsx b/apps/extension/src/entrypoints/debug/analysis.tsx new file mode 100644 index 00000000..625fc52f --- /dev/null +++ b/apps/extension/src/entrypoints/debug/analysis.tsx @@ -0,0 +1,371 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { Button } from "@browser-skill/ui"; +import { useEffect, useState } from "react"; +import { recordingRequest } from "@/debug/client"; +import type { DebugResult } from "@/debug/types"; +import { clock, Quiet } from "./evidence"; + +const number = (value?: number) => + value === undefined + ? "—" + : new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value); +const panel = "rounded-2xl border border-border/80 bg-card"; +export function AnalysisPanel({ + session, + run, + pulse, + onRequest, +}: { + session: string; + run: string; + pulse: number; + onRequest: (id: string) => void; +}) { + const { t } = useTranslation("extension"); + const [mode, setMode] = useState<"aggregate" | "duplicates">("aggregate"); + const [data, setData] = useState(); + const [offsets, setOffsets] = useState([0]); + const offset = offsets.at(-1)!; + const [error, setError] = useState(""); + const refresh = offset === 0 ? pulse : 0; + useEffect(() => { + let cancelled = false; + void recordingRequest({ + session_id: session, + run_id: run, + action: mode, + offset, + limit: 20, + }).then( + (value) => { + if (!cancelled) { + setData(value); + setError(""); + } + }, + (reason) => { + if (!cancelled) setError(String(reason)); + }, + ); + return () => { + cancelled = true; + }; + }, [session, run, mode, offset, refresh]); + const refs = (ids: string[], truncated: boolean) => ( +
+ {t("debug.analysisReferences")} + {ids.map((id) => ( + + ))} + {truncated && ( + + {t("debug.analysisReferencesLimited")} + + )} +
+ ); + return ( +
+
+
+ {(["aggregate", "duplicates"] as const).map((value) => ( + + ))} +
+

+ {t(mode === "aggregate" ? "debug.aggregateHint" : "debug.duplicatesHint")} +

+ {data?.analysis && ( + <> +

+ {t("debug.analysisScope", { + count: data.analysis.included, + excluded: data.analysis.excluded_controlled, + groups: data.analysis.groups, + })} +

+ {mode === "duplicates" && ( +

+ {t("debug.duplicatesCount", { + count: data.analysis.suspected_extra_requests, + uncomparable: data.analysis.uncomparable, + })} +

+ )} + {data.analysis.coverage.some( + (gap) => + gap.startsWith("evidence_") || + gap === "request_retention_limit" || + gap === "initial_load_not_recorded", + ) && ( +

+ {t("debug.analysisPartial")} +

+ )} + + )} +
+ {error && ( +

+ {error} +

+ )} + {!data ? ( + {t("debug.loading")} + ) : mode === "aggregate" ? ( + data.aggregates?.length ? ( + data.aggregates.map((group) => ( +
+

+ {group.method} + {group.endpoint} +

+
+ {( + [ + ["analysisCalls", group.count], + ["analysisErrors", group.http_errors], + ["analysisSlow", group.slow], + ["analysisP95", group.duration_ms?.p95], + ] as const + ).map(([key, value]) => ( +
+

+ {t(`debug.${key}` as "debug.analysisCalls")} +

+

{number(value as number | undefined)}

+
+ ))} +
+

+ {t("debug.analysisTiming", { + mean: number(group.duration_ms?.mean), + max: number(group.duration_ms?.max), + samples: group.timing_samples, + failed: group.failed, + pending: group.pending + group.interrupted, + })} +

+

+ {Object.entries(group.statuses) + .map(([status, count]) => `${status} × ${count}`) + .join(" · ")} +

+ {refs(group.request_ids, group.refs_truncated)} +
+ )) + ) : ( + {t("debug.noData")} + ) + ) : data.duplicates?.length ? ( + data.duplicates.map((group) => ( +
+

+ {group.method} {group.url} +

+

+ {t("debug.duplicateGroup", { + count: group.count, + extra: group.extra_requests, + ms: Math.round(group.ended_at - group.started_at), + overlap: group.overlap_count, + })} +

+ {group.possible_retry && ( +

{t("debug.duplicateRetry")}

+ )} + {refs(group.request_ids, group.refs_truncated)} +
+ )) + ) : ( + {t("debug.noDuplicates")} + )} +
+ + +
+
+ ); +} +export function PerformancePanel({ + session, + run, + pulse, +}: { + session: string; + run: string; + pulse: number; +}) { + const { t } = useTranslation("extension"); + const [data, setData] = useState(); + const [error, setError] = useState(""); + useEffect(() => { + let cancelled = false; + void recordingRequest({ + session_id: session, + run_id: run, + action: "performance", + limit: 100, + budget: 262144, + }).then( + (value) => { + if (!cancelled) { + setData(value); + setError(""); + } + }, + (reason) => { + if (!cancelled) setError(String(reason)); + }, + ); + return () => { + cancelled = true; + }; + }, [session, run, pulse]); + return ( +
+
+

{t("debug.performance")}

+

+ {t("debug.performanceHint")} +

+ {data?.run?.coverage.some((gap) => gap.startsWith("performance_")) && ( +

+ {t("debug.performancePartial")}:{" "} + {data.run.coverage.filter((gap) => gap.startsWith("performance_")).join(", ")} +

+ )} +
+ {error && ( +

+ {error} +

+ )} + {!data ? ( + {t("debug.loading")} + ) : !data.performance?.length ? ( + {t("debug.noPerformance")} + ) : ( + [...data.performance].reverse().map((load) => ( +
+
+ + {clock(load.started_at)} · {load.navigation} + + {load.id} +
+

{load.url}

+ {(!load.early || load.state === "interrupted") && ( +

+ {t("debug.performancePartial")} +

+ )} +
+ {Object.entries(load.metrics).map(([key, metric]) => ( +
+

+ {t(`debug.metric_${key}` as "debug.metric_cls")} +

+

+ {number(metric.value)} + {metric.value !== undefined && key.endsWith("_ms") && ( + ms + )} +

+

+ {t(`debug.metricState_${metric.state}` as "debug.metricState_available")} +

+ {metric.reasons.map((reason) => ( +

+ {t(`debug.perfReason_${reason}` as "debug.performancePartial", { + defaultValue: reason, + })} +

+ ))} +
+ ))} +
+
+ + {t("debug.performanceVisibility")} + +
+ {load.visibility.map((item, index) => ( + + {clock(item.at)} ·{" "} + {t( + item.state === "visible" + ? "debug.performanceVisible" + : "debug.performanceHidden", + )} + + ))} +
+ {load.visibility_truncated && ( +

{t("debug.performanceLimited")}

+ )} +
+ {!!load.long_tasks.length && ( +
+ + {t("debug.performanceLongTasks")} + +
+ {load.long_tasks.map((task, index) => ( +
+ {clock(task.at)} + {number(task.duration_ms)} ms +
+ ))} +
+ {load.long_tasks_truncated && ( +

+ {t("debug.performanceLimited")} +

+ )} +
+ )} +
+ )) + )} +
+ ); +} diff --git a/apps/extension/src/entrypoints/debug/evidence.tsx b/apps/extension/src/entrypoints/debug/evidence.tsx new file mode 100644 index 00000000..2410ee51 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/evidence.tsx @@ -0,0 +1,495 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { Button } from "@browser-skill/ui"; +import { + RiArrowLeftLine, + RiArrowRightLine, + RiArrowRightUpLine, + RiCheckLine, +} from "@remixicon/react"; +import { useEffect, useState } from "react"; +import { debugRequest, recordingRequest } from "@/debug/client"; +import { requestKind } from "@/debug/evidence-model"; +import type { + DebugBody, + DebugConsole, + DebugOperation, + DebugPage, + DebugParams, + DebugRequest, +} from "@/debug/types"; + +export const clock = (at: number) => new Date(at).toLocaleTimeString([], { hour12: false }); + +import { RequestBadges } from "./network-controls"; + +export function requestPath(url: string): string { + try { + const parsed = new URL(url); + return parsed.pathname + parsed.search; + } catch { + return url; + } +} +export function OperationName({ operation }: { operation: DebugOperation }) { + const { t } = useTranslation("extension"); + const key = operation.method.replace("tool.", ""); + return ( + <> + {t(`debug.method_${key}` as "debug.method_click", { defaultValue: key })} + {operation.target && <> · {operation.target}} + + ); +} +export function Quiet({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} +export function RequestList({ + requests, + onSelect, + delayedIds = [], +}: { + requests: DebugRequest[]; + onSelect: (request: DebugRequest) => void; + delayedIds?: string[]; +}) { + const { t } = useTranslation("extension"); + const [showNoise, setShowNoise] = useState(false); + const [visibleCount, setVisibleCount] = useState(100); + const primary = requests.filter((request) => requestKind(request) === "business"); + const noise = requests.length - primary.length; + const visible = showNoise ? requests : primary; + if (!requests.length) return {t("debug.noRequests")}; + return ( +
+ {!primary.length && !showNoise && ( +

{t("debug.noPrimaryRequests")}

+ )} + {visible.slice(0, visibleCount).map((request) => ( + + ))} + {visible.length > visibleCount && ( + + )} + {noise > 0 && ( + + )} +
+ ); +} +export function ConsoleList({ entries }: { entries: DebugConsole[] }) { + const { t } = useTranslation("extension"); + const [showOther, setShowOther] = useState(false); + const other = entries.filter((entry) => + ["extension", "browser"].includes(entry.source ?? "unknown"), + ); + if (!entries.length) return {t("debug.noConsole")}; + return ( +
+ {(showOther ? entries : entries.filter((entry) => !other.includes(entry))).map((entry) => ( +
+
+ + {entry.level} + {entry.count > 1 && ×{entry.count}} + {entry.relation === "delayed" && {t("debug.delayedAssociation")}} + + {t(`debug.source_${entry.source ?? "unknown"}` as "debug.source_unknown")} + +
+
+            {entry.text}
+          
+ {entry.source_url && ( +

+ {entry.source_url} +

+ )} + {entry.stack && ( +
+ Stack +
{entry.stack}
+
+ )} +
+ ))} + {other.length > 0 && ( + + )} +
+ ); +} +export function PageState({ page }: { page?: DebugPage }) { + const { t } = useTranslation("extension"); + return ( +
+

{page?.title}

+ {page?.url && ( +

{page.url}

+ )} +
+        {page?.state === "available" ? page.text || t("debug.noData") : t("debug.unavailable")}
+      
+ {page?.truncated && ( +

{t("debug.partial")}

+ )} + {!!page?.fields?.length && ( +
+ {t("debug.capturedFields")} +
+ {page.fields.map((field, index) => ( +
+
{field.name || field.label}
+
+ {field.state === "available" + ? field.value === "" + ? t("debug.emptyValue") + : field.value + : t(`debug.value_${field.state}` as "debug.value_redacted")} +
+
+ ))} +
+
+ )} +
+ ); +} +export function PageChanges({ operation }: { operation: DebugOperation }) { + const { t } = useTranslation("extension"); + return ( +
+ {(["before", "after"] as const).map((side) => ( +
+

+ {t(`debug.${side}`)} +

+ +
+ ))} +
+ ); +} +function BodyView({ body, onOffset }: { body: DebugBody; onOffset: (offset: number) => void }) { + const { t } = useTranslation("extension"); + const label = + body.state === "pending" + ? "pendingBody" + : body.state === "empty" + ? "emptyBody" + : body.state === "truncated" + ? "truncatedBody" + : body.state === "available" + ? "completeBody" + : "missingBody"; + return ( + <> +
+ {t(`debug.${label}`)} + {body.reason && · {body.reason}} + {body.redacted && ( + + + {t("debug.redacted")} + + )} +
+ {body.text ? ( +
+          {body.text}
+        
+ ) : ( + {t(`debug.${label}`)} + )} + {((body.offset ?? 0) > 0 || body.next_offset !== undefined) && ( +
+ + + {body.offset ?? 0}–{(body.offset ?? 0) + (body.text?.length ?? 0)} / {body.chars} + + +
+ )} + + ); +} +function Fields({ values }: { values?: Record }) { + const { t } = useTranslation("extension"); + return values && Object.keys(values).length ? ( +
+ {Object.entries(values).map(([key, value]) => ( +
+
{key}
+
{String(value ?? "—")}
+
+ ))} +
+ ) : ( +

{t("debug.noData")}

+ ); +} +export function RequestDetail({ + session, + request, + pulse, + onClose, + initialPart = "response", + onControl, +}: { + session: string; + request: DebugRequest; + pulse: number; + onClose: () => void; + initialPart?: NonNullable; + onControl?: (action: "replay" | "block" | "modify" | "mock") => void; +}) { + const { t } = useTranslation("extension"); + const [part, setPart] = useState>(initialPart); + const [offset, setOffset] = useState(0); + const [data, setData] = useState(); + const [error, setError] = useState(""); + useEffect(() => { + let cancelled = false; + void recordingRequest({ + action: "request", + session_id: session, + run_id: request.run_id, + id: request.id, + part, + offset, + max_chars: 4096, + }).then( + (result) => { + if (!cancelled) { + setData(result.request); + setError(""); + } + }, + (err: Error) => { + if (!cancelled) { + setData(undefined); + setError(err.message); + } + }, + ); + return () => { + cancelled = true; + }; + }, [session, request.run_id, request.id, part, offset, pulse]); + return ( +
+
+ +
+ + {data?.status ?? request.status ?? request.state} + + {request.method} + {request.id} +
+

{data?.url ?? request.url}

+ {(data ?? request).integrity?.url === "truncated" && ( +

{t("debug.urlIncomplete")}

+ )} +
+ +
+ {(data ?? request).intervention && ( +

+ {(data ?? request).intervention?.rule_id} ·{" "} + {(data ?? request).intervention?.error || + (data ?? request).intervention?.changes?.join(", ")} +

+ )} + {(data ?? request).replay_from && ( +

+ {t("debug.originalRequest")} · {(data ?? request).replay_from} +

+ )} + {onControl && ( +
+ + {(["replay", "modify", "mock", "block"] as const).map((action) => ( + + ))} +
+ )} +
+ + {error ? ( +

+ {error} +

+ ) : !data ? ( + {t("debug.loading")} + ) : part === "request" || part === "response" ? ( + { + setData(undefined); + setOffset(value); + }} + /> + ) : part === "headers" ? ( +
+
+

{t("debug.requestHeaders")}

+ +
+
+

{t("debug.responseHeaders")}

+ +
+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/extension/src/entrypoints/debug/index.html b/apps/extension/src/entrypoints/debug/index.html new file mode 100644 index 00000000..aece9a05 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/index.html @@ -0,0 +1,12 @@ + + + + + + BrowserSkill · Website debugging + + +
+ + + diff --git a/apps/extension/src/entrypoints/debug/main.tsx b/apps/extension/src/entrypoints/debug/main.tsx new file mode 100644 index 00000000..a7e3b6d3 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/main.tsx @@ -0,0 +1,16 @@ +import { i18n } from "@browser-skill/i18n"; +import { I18nextProvider } from "@browser-skill/i18n/react"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { DebugApp } from "./App"; +import "./style.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Debug root is missing"); +ReactDOM.createRoot(root).render( + + + + + , +); diff --git a/apps/extension/src/entrypoints/debug/network-controls.test.tsx b/apps/extension/src/entrypoints/debug/network-controls.test.tsx new file mode 100644 index 00000000..656b9a02 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/network-controls.test.tsx @@ -0,0 +1,174 @@ +import { i18n } from "@browser-skill/i18n"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { debugRequest, recordingRequest } from "@/debug/client"; +import type { DebugRequest, DebugRule } from "@/debug/types"; +import { ReplayEditor, RuleEditor, RulesPanel } from "./network-controls"; + +afterEach(cleanup); +vi.mock("@/debug/client", () => ({ debugRequest: vi.fn(), recordingRequest: vi.fn() })); +const request: DebugRequest = { + id: "d1:n1", + run_id: "d1", + sequence: 1, + started_at: 0, + url: "https://site.test/save", + method: "POST", + integrity: { url: "complete", metadata: "complete" }, + state: "complete", + request_headers: { cookie: "[redacted]", "content-type": "application/json" }, + request_body: { state: "available", replay_safe: true, text: '{"name":"Alice"}' }, + response_body: { state: "empty" }, +}; +beforeEach(async () => { + await i18n.changeLanguage("zh-CN"); + vi.clearAllMocks(); + vi.mocked(debugRequest).mockResolvedValue({ session_id: "s1" }); + vi.mocked(recordingRequest).mockResolvedValue({ session_id: "s1", request, rules: [] }); +}); +describe("request control UI", () => { + it("does not turn a truncated URL or legacy body draft into an implicit replacement", async () => { + const entry: DebugRequest = { + ...request, + integrity: { url: "truncated", metadata: "complete" }, + request_body: { state: "available", text: '{"orderId":9007199254740992}' }, + }; + vi.mocked(recordingRequest).mockResolvedValue({ session_id: "s1", request: entry }); + render( + {}} + onCancel={() => {}} + onRequest={() => {}} + />, + ); + await waitFor(() => + expect((screen.getByLabelText("正文") as HTMLTextAreaElement).value).toContain( + "9007199254740992", + ), + ); + const url = screen.getByRole("textbox", { name: /^URL/ }) as HTMLInputElement; + const send = screen.getByRole("button", { name: "发送一次" }) as HTMLButtonElement; + expect(url.value).toBe(""); + expect(send.disabled).toBe(true); + fireEvent.change(url, { target: { value: "https://site.test/save?mode=dry-run" } }); + expect(send.disabled).toBe(true); + fireEvent.change(screen.getByLabelText("正文"), { + target: { value: '{"orderId":9007199254740993}' }, + }); + fireEvent.click(send); + await waitFor(() => expect(debugRequest).toHaveBeenCalledTimes(1)); + expect(debugRequest).toHaveBeenCalledWith( + expect.objectContaining({ + replay: expect.objectContaining({ + url: "https://site.test/save?mode=dry-run", + body: '{"orderId":9007199254740993}', + }), + }), + ); + }); + + it("reuses verified evidence by ID without supplying untouched drafts as overrides", async () => { + render( + {}} + onCancel={() => {}} + onRequest={() => {}} + />, + ); + await waitFor(() => + expect((screen.getByLabelText("正文") as HTMLTextAreaElement).value).toContain("Alice"), + ); + fireEvent.click(screen.getByRole("button", { name: "发送一次" })); + await waitFor(() => expect(debugRequest).toHaveBeenCalledTimes(1)); + const replay = vi.mocked(debugRequest).mock.calls[0][0].replay!; + expect(replay).not.toHaveProperty("body"); + expect(replay).not.toHaveProperty("url"); + }); + it("creates a one-shot mock from a selected request without changing its URL/method", async () => { + const done = vi.fn(); + render( + {}} />, + ); + fireEvent.change(screen.getByLabelText("正文"), { target: { value: '{"name":"Mock"}' } }); + fireEvent.click(screen.getByRole("button", { name: "启用规则" })); + await waitFor(() => expect(done).toHaveBeenCalled()); + expect(debugRequest).toHaveBeenCalledWith( + expect.objectContaining({ + action: "rule_add", + session_id: "s1", + run_id: "d1", + rule: expect.objectContaining({ + match: { url: request.url, method: "POST" }, + effect: expect.objectContaining({ type: "mock", status: 503, body: '{"name":"Mock"}' }), + times: 1, + }), + }), + ); + }); + it("keeps archived rules visible without showing execution controls", async () => { + const rule: DebugRule = { + id: "d1:r1", + name: "Mock saved response", + match: { url: request.url }, + effect: { type: "mock", status: 200, body: "{}" }, + state: "stopped", + hits: 1, + failures: 0, + created_at: 0, + times: 1, + }; + vi.mocked(recordingRequest).mockResolvedValue({ session_id: "s1", rules: [rule] }); + render( {}} />); + expect(await screen.findByText("Mock saved response")).toBeDefined(); + expect(screen.queryByRole("button", { name: "添加规则" })).toBeNull(); + expect(screen.queryByRole("button", { name: "启用" })).toBeNull(); + expect(debugRequest).not.toHaveBeenCalled(); + }); + it("loads a replay draft and sends one attempt with an idempotency key and source linkage", async () => { + const open = vi.fn(); + vi.mocked(debugRequest).mockResolvedValue({ + session_id: "s1", + replay: { + id: "replay1", + key: "key", + source_request_id: request.id, + request_id: "d1:n2", + state: "complete", + }, + }); + render( + {}} + onCancel={() => {}} + onRequest={open} + />, + ); + await waitFor(() => + expect((screen.getByLabelText("正文") as HTMLTextAreaElement).value).toContain("Alice"), + ); + fireEvent.change(screen.getByLabelText("正文"), { target: { value: '{"name":"Bob"}' } }); + fireEvent.click(screen.getByRole("button", { name: "发送一次" })); + await screen.findByRole("button", { name: "查看重放请求" }); + expect(debugRequest).toHaveBeenCalledTimes(1); + expect(debugRequest).toHaveBeenCalledWith( + expect.objectContaining({ + action: "replay", + id: request.id, + replay: expect.objectContaining({ + key: expect.any(String), + body: '{"name":"Bob"}', + headers: { "content-type": "application/json" }, + }), + }), + ); + expect(screen.queryByRole("button", { name: "发送一次" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "查看重放请求" })); + expect(open).toHaveBeenCalledWith("d1:n2"); + }); +}); diff --git a/apps/extension/src/entrypoints/debug/network-controls.tsx b/apps/extension/src/entrypoints/debug/network-controls.tsx new file mode 100644 index 00000000..5cac3359 --- /dev/null +++ b/apps/extension/src/entrypoints/debug/network-controls.tsx @@ -0,0 +1,668 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { Button } from "@browser-skill/ui"; +import { RiAddLine, RiArrowRightUpLine, RiEqualizerLine } from "@remixicon/react"; +import { useEffect, useRef, useState } from "react"; +import { debugRequest, recordingRequest } from "@/debug/client"; +import type { + DebugParams, + DebugReplay, + DebugRequest, + DebugRule, + DebugRuleSpec, +} from "@/debug/types"; + +const inputClass = + "mt-2 w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ring"; +const codeClass = `${inputClass} resize-y font-mono leading-relaxed`; +const labelClass = "block min-w-0 text-[11px] text-muted-foreground"; +const methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; +type Effect = DebugRuleSpec["effect"]["type"]; +function objectJson(text: string): Record { + const value = JSON.parse(text || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("JSON must be an object"); + return value; +} +export function RequestBadges({ request }: { request: DebugRequest }) { + const { t } = useTranslation("extension"); + return ( + <> + {request.intervention && ( + + {t(`debug.${request.intervention.type}`)} ·{" "} + {t( + request.intervention.state === "pending" + ? "debug.working" + : request.intervention.state === "applied" + ? "debug.controlApplied" + : request.intervention.state === "failed" + ? "debug.controlFailed" + : "debug.controlCancelled", + )} + + )} + {request.replay_from && ( + + {t("debug.replay")} + + )} + + ); +} +export function RuleEditor({ + session, + run, + request, + initial = "mock", + onDone, + onCancel, +}: { + session: string; + run: string; + request?: DebugRequest; + initial?: Effect; + onDone: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation("extension"); + const [effect, setEffect] = useState(initial); + const [name, setName] = useState(""); + const [url, setUrl] = useState(request?.url ?? ""); + const [method, setMethod] = useState(request?.method ?? ""); + const [times, setTimes] = useState(1); + const [status, setStatus] = useState(503); + const [delay, setDelay] = useState(0); + const [headers, setHeaders] = useState("{}"); + const [body, setBody] = useState('{\n "error": "Temporarily unavailable"\n}'); + const [json, setJson] = useState(""); + const [replacement, setReplacement] = useState(false); + const [newUrl, setNewUrl] = useState(""); + const [newMethod, setNewMethod] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function submit() { + setBusy(true); + setError(""); + try { + let value: DebugRuleSpec["effect"] = { type: "block" }; + if (effect === "mock") + value = { + type: "mock", + status, + body, + delay_ms: delay, + headers: { "content-type": "application/json", ...objectJson(headers) }, + }; + if (effect === "modify") + value = { + type: "modify", + ...(headers.trim() && headers.trim() !== "{}" ? { headers: objectJson(headers) } : {}), + ...(replacement ? { body } : json.trim() ? { json: objectJson(json) } : {}), + ...(newUrl ? { url: newUrl } : {}), + ...(newMethod ? { method: newMethod } : {}), + }; + await debugRequest({ + session_id: session, + run_id: run, + action: "rule_add", + rule: { name, match: { url, ...(method ? { method } : {}) }, effect: value, times }, + }); + onDone(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + return ( +
{ + event.preventDefault(); + void submit(); + }} + > +
+

+ + {t("debug.addRule")} +

+ + {t(times === 1 ? "debug.once" : "debug.untilStop")} + +
+
+ {(["block", "modify", "mock"] as const).map((value) => ( + + ))} +
+ +
+ + +
+ {effect === "mock" && ( +
+ + +
+ )} + {effect === "modify" && ( + <> +