mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-10-02 07:34:35 +08:00
fix(debug): unify retained evidence reads and incremental refresh
This commit is contained in:
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_")),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": "原正文不完整;发送前请提供完整正文。",
|
||||
|
||||
Reference in New Issue
Block a user