fix(debug): unify retained evidence reads and incremental refresh

This commit is contained in:
drakezhang
2026-09-20 16:29:17 +08:00
parent 01c6c1c20a
commit da106d1305
16 changed files with 579 additions and 219 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { consoleSource, operationEvidence, requestKind } from "../evidence-model";
import { consoleSource, operationContext, operationEvidence, requestKind } from "../evidence-model";
import { sanitizeFields } from "../observer";
import { redactBody } from "../redact";
import type { DebugOperation, DebugPage, DebugRecording, DebugRequest } from "../types";
@@ -86,6 +86,34 @@ const fixture = (): DebugRecording => ({
});
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 [
'<input type="password" value="private">',
@@ -31,7 +31,7 @@ describe("bounded evidence journal", () => {
expect.objectContaining({ response_body: { state: "available", text: "saved" } }),
]);
expect(failed).not.toHaveBeenCalled();
await journal.stop();
await journal.flush();
});
it("reports storage failures and bounds the queue while a transaction is stalled", async () => {
let release: () => void = () => {};
@@ -50,7 +50,7 @@ describe("bounded evidence journal", () => {
throw new Error("disk full");
});
release();
await journal.stop();
await journal.flush();
expect(failed).toHaveBeenCalledWith("evidence_write_failed");
});
it("preserves pins and complete stored bodies when merging a metadata-only read", () => {
@@ -145,6 +145,42 @@ describe("task-scoped debug lifecycle", () => {
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);
@@ -490,6 +526,12 @@ describe("persistent debugging records", () => {
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}`,
@@ -505,6 +547,7 @@ describe("persistent debugging records", () => {
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",
@@ -518,6 +561,27 @@ describe("persistent debugging records", () => {
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(
@@ -534,6 +598,66 @@ describe("persistent debugging records", () => {
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<DebugRequest | undefined>((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<never> => {
+5 -15
View File
@@ -4,6 +4,7 @@ import {
JOURNAL_REQUESTS,
jsonBytes,
mergeRequest,
requestMetadata,
retentionPriority,
} from "./journal";
import { interruptPerformance } from "./performance";
@@ -16,17 +17,6 @@ interface StoredRequest {
bytes: number;
priority: number;
}
function metadata(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 };
}
export const HISTORY_LIMIT = 50;
export const HISTORY_BYTES = 50 * 1024 * 1024;
@@ -193,7 +183,7 @@ export class LocalDebugArchive implements DebugArchive {
requests: [saved.entry],
}).requests[0];
item.update({ ...saved, entry: recovered });
tx.objectStore("request_index").put(metadata(recovered));
tx.objectStore("request_index").put(requestMetadata(recovered));
item.continue();
};
}
@@ -270,7 +260,7 @@ export class LocalDebugArchive implements DebugArchive {
),
]);
const merged = new Map(
recording.requests.map((entry) => [entry.id, bodies ? entry : metadata(entry)]),
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);
@@ -470,7 +460,7 @@ export class LocalDebugArchive implements DebugArchive {
bytes,
priority: retentionPriority(entry),
} satisfies StoredRequest);
index.put(metadata(entry));
index.put(requestMetadata(entry));
if (--remaining === 0) finish();
};
}
@@ -519,7 +509,7 @@ export class LocalDebugArchive implements DebugArchive {
saved.bytes = bytes;
saved.priority = retentionPriority(saved.entry);
tx.objectStore("requests").put(saved);
tx.objectStore("request_index").put(metadata(saved.entry));
tx.objectStore("request_index").put(requestMetadata(saved.entry));
tx.objectStore("runs").put(run);
};
await done;
+27 -19
View File
@@ -38,22 +38,29 @@ export function consoleSource(
return "unknown";
}
export function relatedRequests(
recording: DebugRecording,
operation: DebugOperation,
): DebugEvidence["links"] {
/** 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);
return recording.requests
.filter(
(request) =>
request.started_at >= operation.started_at &&
request.started_at <= end &&
request.started_at < next_start,
)
.map((request) => ({
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: request.started_at <= immediate ? "window" : "delayed",
}));
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,
@@ -61,7 +68,10 @@ export function operationWindow(
): { 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.started_at;
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,
@@ -131,11 +141,9 @@ function payload(request: DebugRequest, part: "request" | "response"): Leaf[] {
export function operationEvidence(
record: DebugRecording,
operation: DebugOperation,
context = operationContext(record, operation),
): DebugEvidence {
const links = relatedRequests(record, operation);
const requests = record.requests.filter((request) =>
links.some((link) => link.request_id === request.id),
);
const { links, requests } = context;
const gaps = new Set<string>(
record.run.coverage.filter((value) => value.startsWith("evidence_")),
);
+13 -6
View File
@@ -9,6 +9,19 @@ 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,
@@ -48,14 +61,12 @@ export class DebugJournal {
private bytes = 0;
private timer?: ReturnType<typeof setTimeout>;
private writing?: Promise<void>;
private stopped = false;
constructor(
private readonly archive: DebugArchive,
private readonly run: () => DebugRun,
private readonly failed: (reason: string) => void,
) {}
retain = (value: DebugRequest): void => {
if (this.stopped) return;
const entry = structuredClone(mergeRequest(this.pending.get(value.id)?.entry, value));
const bytes = jsonBytes(entry);
const previous = this.pending.get(entry.id);
@@ -95,8 +106,4 @@ export class DebugJournal {
if (this.writing === writing) this.writing = undefined;
if (this.pending.size) await this.flush();
}
async stop(): Promise<void> {
this.stopped = true;
await this.flush();
}
}
+53 -95
View File
@@ -147,7 +147,10 @@ export class DebugManager {
}
private change(state: RunState): number {
const sequence = ++state.run.next_since;
if (state.current && this.now() <= (state.current.window_end ?? this.now()))
if (
state.current &&
this.now() <= (state.current.observation_end ?? state.current.window_end ?? this.now())
)
state.current.sequence = sequence;
this.scheduleSave(state);
return sequence;
@@ -950,31 +953,6 @@ export class DebugManager {
);
}
private operation(state: RunState, operation: DebugOperation, details = true): DebugOperation {
const end = Math.min(operation.window_end ?? this.now(), this.now());
const nextStart =
state.operations[state.operations.indexOf(operation) + 1]?.started_at ?? Infinity;
const requests = state.network
.list()
.filter(
(entry) =>
entry.started_at >= operation.started_at &&
entry.started_at <= end &&
entry.started_at < nextStart,
);
const messages = state.console.filter(
(entry) => entry.at >= operation.started_at && entry.at <= end && entry.at < nextStart,
);
const { before, after, observations, ...rest } = operation;
return {
...rest,
...(details ? { before, after, observations } : {}),
request_ids: requests.map((entry) => entry.id),
console_ids: messages.map((entry) => entry.id),
truncated: state.network.dropped > 0 || state.run.dropped_console > 0,
};
}
private async capturePage(state: RunState, loaded = false): Promise<void> {
if (state.pagePending || state.run.state !== "capturing") return;
state.pagePending = true;
@@ -1040,7 +1018,7 @@ export class DebugManager {
saved_at: this.now(),
run: this.summary(state),
requests: state.network.list(),
operations: state.operations.map((operation) => this.operation(state, operation)),
operations: state.operations,
console: state.console,
pages: state.pages,
performance: state.performance,
@@ -1135,10 +1113,15 @@ export class DebugManager {
async readHistory(params: DebugParams): Promise<DebugResult> {
this.sync();
if (!params.run_id) throw new Error("recording ID is required");
const live = this.runs.get(params.run_id);
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<DebugResult> {
const runId = params.run_id!;
if (params.action === "requests" && (!live || live.journal)) {
await live?.journal?.flush();
const indexed = await this.archive?.query?.(params.run_id, params).catch((error) => {
const indexed = await this.archive?.query?.(runId, params).catch((error) => {
if (!live) throw error;
this.coverage(live, "evidence_read_failed");
return undefined;
@@ -1165,18 +1148,18 @@ export class DebugManager {
const bodies = ["operation", "export", "duplicates"].includes(params.action);
const recording = live
? await this.evidenceRecording(live, bodies)
: await this.archive?.get(params.run_id, bodies);
: await this.archive?.get(runId, bodies);
if (recording && params.action === "request" && params.id) {
const stored = await this.archive?.request?.(params.run_id, params.id).catch((error) => {
const stored = await this.archive?.request?.(runId, params.id).catch((error) => {
if (!live) throw error;
this.coverage(live, "evidence_read_failed");
recording.run.coverage = [...live.run.coverage];
recording.run.coverage = [...new Set([...recording.run.coverage, ...live.run.coverage])];
return undefined;
});
if (stored) {
const current = recording.requests.find((entry) => entry.id === params.id);
recording.requests = [current ? mergeRequest(stored, current) : stored];
}
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);
@@ -1258,19 +1241,23 @@ export class DebugManager {
if (!this.archive?.pin) throw new Error("persistent evidence unavailable");
await this.archive.pin(runId, params.id!, params.action === "pin");
}
const saved = await this.readHistory({
...params,
run_id: runId,
action:
params.action === "stop"
? "rules"
: ["pin", "unpin"].includes(params.action)
? "request"
: params.action,
});
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)
!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;
@@ -1323,56 +1310,27 @@ export class DebugManager {
);
return { ...result, run: this.summary(state), rules: state.controls.list() };
}
if (params.action === "requests" && state.journal) {
await state.journal.flush();
const indexed = await this.archive?.query?.(state.run.id, params).catch(() => {
this.coverage(state, "evidence_read_failed");
return undefined;
});
if (indexed) {
state.run.storage = indexed.run?.storage;
return {
...indexed,
run: {
...this.summary(state),
requests: indexed.run!.requests,
dropped_requests: indexed.run!.dropped_requests,
},
};
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);
}
}
if (params.action !== "operation") {
if (params.action === "performance") {
await this.refreshPerformance(state);
return readRecording(this.recording(state), params);
}
const recording = await this.evidenceRecording(
state,
["export", "duplicates"].includes(params.action),
);
if (params.action === "request" && params.id) {
const saved = await this.archive?.request?.(state.run.id, params.id).catch(() => {
this.coverage(state, "evidence_read_failed");
return undefined;
});
const current = state.network.get(params.id);
if (saved || current)
recording.requests = [current ? mergeRequest(saved, current) : saved!];
}
return readRecording(recording, params);
}
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.
const evidence = await this.readEvidence({ ...params, run_id: state.run.id }, 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);
}
return readRecording(await this.evidenceRecording(state), params);
this.sessions.get(params.session_id) !== context ||
state.released ||
!isAgentControlledTab(context, state.run.tab_id)
)
throw new Error("session not found");
return evidence;
}
}
+12 -17
View File
@@ -1,6 +1,7 @@
import type { CdpDebuggee } from "@/browser-driver/chromium-cdp";
import type { CdpRunner } from "@/tools/shared";
import { sendToCdpTarget } from "@/tools/shared";
import { requestMetadata } from "./journal";
import { BODY_CHARS, redactBody, redactHeaders, redactText, redactUrl } from "./redact";
import type { DebugBody, DebugIntervention, DebugRequest } from "./types";
@@ -670,23 +671,17 @@ export function requestProjection(
maxChars = 4096,
pointer?: string,
): DebugRequest {
const { request_headers, response_headers, timing, request_body, response_body, ...metadata } =
entry;
const summary = (body: DebugBody): DebugBody => {
const { text: _text, ...rest } = body;
return rest;
};
return {
...metadata,
request_body:
part === "request"
? bodySlice(request_body, offset, maxChars, pointer)
: summary(request_body),
response_body:
part === "response"
? bodySlice(response_body, offset, maxChars, pointer)
: summary(response_body),
...(part === "headers" ? { request_headers, response_headers } : {}),
...(part === "timing" ? { timing } : {}),
...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 } : {}),
};
}
+25 -23
View File
@@ -1,5 +1,5 @@
import { analyzeRecording } from "./analysis";
import { operationEvidence, operationWindow } from "./evidence-model";
import { operationContext, operationEvidence } from "./evidence-model";
import { requestProjection } from "./network-store";
import { matchesRequest, projectFields } from "./query";
import type { DebugParams, DebugRecording, DebugResult } from "./types";
@@ -21,7 +21,16 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D
...(end < all.length ? { next_offset: end, truncated: true } : {}),
};
}
if (params.action === "export") return { ...result, recording };
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") {
@@ -69,9 +78,15 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D
operations: recording.operations
.filter((item) => page.includes(item))
.sort((a, b) => a.sequence - b.sequence)
.map(
({ before: _before, after: _after, observations: _observations, ...item }) => item,
),
.map((entry) => {
const {
before: _before,
after: _after,
observations: _observations,
...item
} = operationContext(recording, entry).operation;
return item;
}),
};
return {
...result,
@@ -85,26 +100,13 @@ export function readRecording(recording: DebugRecording, params: DebugParams): D
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 evidence = operationEvidence(recording, operation);
const window = operationWindow(recording, operation);
const context = operationContext(recording, operation);
return {
...result,
operation,
evidence,
requests: recording.requests
.filter((entry) => evidence.links.some((link) => link.request_id === entry.id))
.map((entry) => requestProjection(entry)),
console: recording.console
.filter(
(entry) =>
entry.at >= operation.started_at &&
entry.at <= window.end &&
entry.at < window.next_start,
)
.map((entry) => ({
...entry,
relation: entry.at <= window.immediate ? "window" : "delayed",
})),
operation: context.operation,
evidence: operationEvidence(recording, operation, context),
requests: context.requests.map((entry) => requestProjection(entry)),
console: context.console,
};
}
throw new Error("unsupported history action");
@@ -1,5 +1,5 @@
import { i18n } from "@browser-skill/i18n";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
debugHistory,
@@ -10,6 +10,7 @@ import {
} 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(),
@@ -220,3 +221,180 @@ describe("website evidence workspace", () => {
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<ReturnType<typeof recordingRequest>>) => 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<ReturnType<typeof recordingRequest>>) => 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 }),
);
});
});
+7 -18
View File
@@ -34,6 +34,7 @@ import {
} 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");
@@ -51,7 +52,6 @@ export function DebugApp() {
const [filter, setFilter] = useState("");
const [revision, setRevision] = useState(0);
const [operations, setOperations] = useState<DebugOperation[]>([]);
const [requests, setRequests] = useState<DebugRequest[]>([]);
const [messages, setMessages] = useState<DebugConsole[]>([]);
const [pages, setPages] = useState<DebugPage[]>([]);
const [operationId, setOperationId] = useState("");
@@ -75,6 +75,10 @@ export function DebugApp() {
);
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<Record<string, number>>((counts, entry) => {
@@ -138,7 +142,6 @@ export function DebugApp() {
useEffect(() => {
initialOperation.current = "";
setOperations([]);
setRequests([]);
setMessages([]);
setPages([]);
setOperationId("");
@@ -158,26 +161,12 @@ export function DebugApp() {
recordingRequest({ ...base, action: "console" }),
recordingRequest({ ...base, action: "pages" }),
]);
const entries: DebugRequest[] = [];
let since = 0;
for (let page = 0; page < 20; page++) {
if (cancelled) return;
const batch = await recordingRequest({ ...base, action: "requests", since, limit: 100 });
entries.push(...(batch.requests ?? []));
since = batch.next_since ?? since;
if ((batch.requests?.length ?? 0) < 100) break;
}
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);
}
setRequests(
[...new Map(entries.map((item) => [item.id, item])).values()].sort(
(a, b) => a.started_at - b.started_at,
),
);
setMessages(consoleResult.console ?? []);
setPages(pageResult.pages ?? []);
})().catch((reason) => {
@@ -362,7 +351,7 @@ export function DebugApp() {
</div>
)}
</div>
{(error || historyError || taskError || run?.storage_error) && (
{(error || requestError || historyError || taskError || run?.storage_error) && (
<p
role="alert"
className="mb-5 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-xs text-destructive"
@@ -371,7 +360,7 @@ export function DebugApp() {
? `${t("debug.storageFailed")} ${run.storage_error}`
: historyError
? `${t("debug.storageFailed")} ${historyError}`
: error || taskError}
: error || requestError || taskError}
</p>
)}
{confirmDelete && (
@@ -5,7 +5,6 @@ import {
RiArrowRightLine,
RiArrowRightUpLine,
RiCheckLine,
RiCodeSSlashLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import { debugRequest, recordingRequest } from "@/debug/client";
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import { recordingRequest } from "@/debug/client";
import type { DebugRequest, DebugRun } from "@/debug/types";
interface RequestCache {
key: string;
since: number;
dropped: number;
entries: Map<string, DebugRequest>;
busy: boolean;
resync?: boolean;
refresh?: () => Promise<void>;
}
/** Keep one selected run, serialize reads, and reuse successfully read pages across refreshes. */
export function useRequests(run: DebugRun | undefined, enabled: boolean) {
const cache = useRef<RequestCache | undefined>(undefined);
const [view, setView] = useState({ key: "", requests: [] as DebugRequest[], error: "" });
const session = run?.session_id ?? "";
const id = run?.id ?? "";
const key = `${session}/${id}`;
const pulse = run?.next_since ?? 0;
const dropped = run?.dropped_requests ?? 0;
useEffect(() => {
if (cache.current?.key !== key)
cache.current = { key, since: 0, dropped, entries: new Map(), busy: false };
const state = cache.current;
if (!id || !enabled) return;
let cancelled = false;
const publish = (error = "") => {
if (!cancelled)
setView({
key,
requests: [...state.entries.values()].sort(
(a, b) => a.started_at - b.started_at || a.sequence - b.sequence,
),
error,
});
};
const reset = (removed: number) => {
state.entries.clear();
state.since = 0;
state.dropped = removed;
state.resync = false;
publish();
};
const update = async () => {
if (cancelled) return;
// The protocol has no deletion cursor: resync when retention removed requests.
if (state.resync || dropped > state.dropped) reset(Math.max(dropped, state.dropped));
for (let page = 0; page < 21; page++) {
if (cancelled) return;
const since = state.since;
const batch = await recordingRequest({
session_id: session,
run_id: id,
action: "requests",
since,
limit: 100,
});
if (cache.current !== state) return;
const removed = batch.run?.dropped_requests ?? state.dropped;
if (removed > state.dropped) {
reset(removed);
if (since > 0) continue;
}
// A fallback read may omit older stored rows; never advance past them permanently.
if (batch.run?.coverage.includes("evidence_read_failed")) state.resync = true;
const entries = batch.requests ?? [];
for (const entry of entries) state.entries.set(entry.id, entry);
state.since = Math.max(since, batch.next_since ?? since);
// Commit progress even when a newer pulse queued another read.
if (entries.length < 100 || state.since === since) break;
}
publish();
};
state.refresh = () =>
update().catch((reason) =>
publish(reason instanceof Error ? reason.message : String(reason)),
);
// Coalesce pulses to one pending refresh even if a browser read stalls.
if (!state.busy) {
state.busy = true;
void (async () => {
try {
while (state.refresh) {
const refresh = state.refresh;
state.refresh = undefined;
await refresh();
}
} finally {
state.busy = false;
}
})();
}
return () => {
cancelled = true;
};
}, [key, session, id, pulse, dropped, enabled]);
return view.key === key ? view : { requests: [], error: "" };
}
@@ -326,10 +326,8 @@
"timeline": "Action timeline",
"noOperations": "No recorded actions.",
"noSession": "This task has ended. Open history to review saved debugging records.",
"chooseOperation": "Select an action to inspect its evidence.",
"duringRequests": "Requests in this window",
"console": "Console",
"pageChanges": "Page changes",
"noRequests": "No retained requests.",
"noConsole": "No retained console messages.",
"correlation": "Grouped by time. Temporal proximity does not establish causality.",
@@ -359,10 +357,7 @@
"missingBody": "Body was not captured, was evicted, or is unavailable from the browser.",
"redacted": "Common credentials redacted",
"run": "Capture run",
"selectRun": "Choose a capture run",
"noData": "No data yet",
"elapsed": "{{ms}} ms",
"repeat": "Repeated {{count}} times",
"state_running": "Running",
"state_completed": "Completed",
"state_error": "Failed",
@@ -378,7 +373,6 @@
"method_hover": "Hover",
"method_evaluate": "Evaluate script",
"coverage": "Capture coverage",
"retry": "Retry",
"method_wheel": "Scroll",
"method_scroll_to": "Scroll to element",
"method_focus": "Focus",
@@ -515,7 +509,6 @@
"controlFailed": "Failed",
"controlCancelled": "Cancelled",
"originalRequest": "Original request",
"appliedChanges": "Changed fields",
"captureRequired": "Start a new capture to add rules or replay requests.",
"controlDetails": "View rule definition",
"bodyIncomplete": "Original body is incomplete. Supply the complete body before sending.",
@@ -326,10 +326,8 @@
"timeline": "동작 타임라인",
"noOperations": "기록된 동작이 없습니다.",
"noSession": "작업이 종료되었습니다. 기록에서 저장된 디버깅 결과를 확인하세요.",
"chooseOperation": "동작을 선택해 관련 기록을 확인하세요.",
"duringRequests": "이 시간대의 요청",
"console": "콘솔",
"pageChanges": "페이지 변경",
"noRequests": "보관된 요청이 없습니다.",
"noConsole": "보관된 Console 메시지가 없습니다.",
"correlation": "시간순으로 묶었습니다. 시간적 인접성이 인과관계를 의미하지는 않습니다.",
@@ -359,10 +357,7 @@
"missingBody": "본문이 수집되지 않았거나 삭제되었거나 브라우저에서 제공하지 않습니다.",
"redacted": "일반적인 인증 정보 가림",
"run": "수집 실행",
"selectRun": "수집 실행 선택",
"noData": "아직 데이터 없음",
"elapsed": "{{ms}} ms",
"repeat": "{{count}}번 반복",
"state_running": "실행 중",
"state_completed": "완료",
"state_error": "실패",
@@ -378,7 +373,6 @@
"method_hover": "마우스 올리기",
"method_evaluate": "스크립트 실행",
"coverage": "수집 범위",
"retry": "다시 시도",
"method_wheel": "스크롤",
"method_scroll_to": "요소로 스크롤",
"method_focus": "포커스",
@@ -515,7 +509,6 @@
"controlFailed": "실패",
"controlCancelled": "취소됨",
"originalRequest": "원본 요청",
"appliedChanges": "변경된 필드",
"captureRequired": "규칙 추가 또는 재전송을 위해 새 캡처를 시작하세요.",
"controlDetails": "규칙 정의 보기",
"bodyIncomplete": "원본 본문이 불완전합니다. 전송 전에 전체 본문을 입력하세요.",
@@ -326,10 +326,8 @@
"timeline": "操作时间线",
"noOperations": "暂无已记录的操作。",
"noSession": "当前任务已结束。请从历史记录查看已保存的调试结果。",
"chooseOperation": "选择一次操作,查看相关证据。",
"duringRequests": "期间请求",
"console": "Console",
"pageChanges": "页面变化",
"noRequests": "暂无已保留的请求。",
"noConsole": "暂无已保留的 Console 消息。",
"correlation": "按发生时间归组;时间相邻不代表存在因果关系。",
@@ -359,10 +357,7 @@
"missingBody": "正文未采集、已淘汰或浏览器未能提供。",
"redacted": "常见凭据已脱敏",
"run": "采集轮次",
"selectRun": "选择采集轮次",
"noData": "暂无数据",
"elapsed": "{{ms}} ms",
"repeat": "重复 {{count}} 次",
"state_running": "执行中",
"state_completed": "已执行",
"state_error": "执行失败",
@@ -378,7 +373,6 @@
"method_hover": "悬停",
"method_evaluate": "执行脚本",
"coverage": "采集范围与限制",
"retry": "重试",
"method_wheel": "滚动",
"method_scroll_to": "滚动到元素",
"method_focus": "聚焦",
@@ -515,7 +509,6 @@
"controlFailed": "执行失败",
"controlCancelled": "已取消",
"originalRequest": "原请求",
"appliedChanges": "修改项",
"captureRequired": "开始新的调试记录后,可以添加规则或重放请求。",
"controlDetails": "查看规则定义",
"bodyIncomplete": "原正文不完整;发送前请提供完整正文。",