fix: address network command review feedback

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BB-fat
2026-07-18 22:52:53 +08:00
co-authored by Cursor
parent 259b4826b7
commit 9799b98937
16 changed files with 488 additions and 289 deletions
@@ -139,6 +139,25 @@ describe("ChromiumCdp", () => {
expect(enableCalls).toHaveLength(2); expect(enableCalls).toHaveLength(2);
}); });
it("retries and surfaces Network.enable failures for explicit capture", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Network.enable") throw new Error("network restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Network.enable",
);
expect(enableCalls).toHaveLength(2);
});
it("records javascriptDialogOpening and auto-accepts", async () => { it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi(); const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
@@ -321,6 +340,30 @@ describe("ChromiumCdp", () => {
truncated: true, truncated: true,
entries: [{ sequence: 2, text: "message 2" }], entries: [{ sequence: 2, text: "message 2" }],
}); });
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
next_since: 3,
truncated: true,
entries: [{ sequence: 3, text: "message 3" }],
});
});
it("reports dropped buffered entries only when they affect the cursor", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(25);
for (let i = 1; i <= 201; i += 1) {
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
next_since: 201,
truncated: false,
entries: [{ sequence: 201, text: "message 201" }],
});
}); });
it("clears console state on detach", async () => { it("clears console state on detach", async () => {
@@ -336,6 +379,103 @@ describe("ChromiumCdp", () => {
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0); expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
}); });
it("isolates network request metadata by tab and consumes it", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(31);
await cdp.ensureAttached(32);
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://one.test/api", method: "GET" },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://two.test/script.js", method: "POST" },
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
requestId: "shared-id",
response: { status: 204 },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_FAILED",
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
{
kind: "response",
method: "GET",
url: "https://one.test/api",
status: 204,
},
{
kind: "failure",
url: undefined,
error_text: "net::ERR_ABORTED",
},
]);
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
{
kind: "failure",
method: "POST",
url: "https://two.test/script.js",
error_text: "net::ERR_FAILED",
},
]);
});
it("bounds request metadata and clears it on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(33);
const longUrl = `https://example.test/${"x".repeat(5000)}`;
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long",
request: { url: longUrl, method: "GET" },
});
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "long",
errorText: "net::ERR_FAILED",
});
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
expect(bounded.url).toHaveLength(4096);
expect(bounded.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long-method",
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
});
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
requestId: "long-method",
response: { url: "https://example.test/ok", status: 200 },
});
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
expect(boundedResponse.method).toHaveLength(4096);
expect(boundedResponse.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "stale",
request: { url: "https://example.test/stale", method: "GET" },
});
await cdp.detach(33);
await cdp.ensureAttached(33);
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "stale",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
it("detachSession only detaches tabs no other session owns", async () => { it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi(); const { api } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
+159 -105
View File
@@ -106,11 +106,12 @@ interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {} interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
/** Request metadata remembered from `requestWillBeSent`, keyed by requestId. */ /** Bounded request metadata remembered from `requestWillBeSent`. */
interface NetworkRequestMeta { interface NetworkRequestMeta {
url: string; url: string;
method?: string; method?: string;
resourceType?: string; resourceType?: string;
truncated: boolean;
} }
/** /**
@@ -129,8 +130,8 @@ export class ChromiumCdp {
private readonly consoleDomainsAttemptedTabs = new Set<number>(); private readonly consoleDomainsAttemptedTabs = new Set<number>();
private readonly networkBuffers = new Map<number, NetworkEntry[]>(); private readonly networkBuffers = new Map<number, NetworkEntry[]>();
private readonly networkSequences = new Map<number, number>(); private readonly networkSequences = new Map<number, number>();
private readonly networkDomainsAttemptedTabs = new Set<number>(); private readonly networkDomainsEnabledTabs = new Set<number>();
private readonly networkRequestMeta = new Map<string, NetworkRequestMeta>(); private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
private detachSubscription: { dispose(): void } | null = null; private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null; private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null; private consoleSubscription: { dispose(): void } | null = null;
@@ -156,7 +157,7 @@ export class ChromiumCdp {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION); await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId); await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId); await this.enableConsoleDomains(tabId);
await this.enableNetworkDomains(tabId); await this.enableNetworkDomainBestEffort(tabId);
this.attachedTabs.add(tabId); this.attachedTabs.add(tabId);
})() })()
.catch((err) => { .catch((err) => {
@@ -217,19 +218,13 @@ export class ChromiumCdp {
includeStack: boolean, includeStack: boolean,
): ConsoleResult { ): ConsoleResult {
const buf = this.consoleBuffers.get(tabId) ?? []; const buf = this.consoleBuffers.get(tabId) ?? [];
const hasCursor = typeof since === "number"; const { entries, nextSince, truncated } = readBufferedEntries(
const candidates = hasCursor buf,
? buf.filter((entry) => entry.sequence > since) this.consoleSequences.get(tabId) ?? 0,
: buf.slice(Math.max(0, buf.length - limit)); since,
const limited = hasCursor ? candidates.slice(0, limit) : candidates; limit,
const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack)); (entry) => projectConsoleEntry(entry, maxTextChars, includeStack),
const lastReturned = entries.at(-1)?.sequence; );
const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0;
const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
return { return {
tab_id: tabId, tab_id: tabId,
entries, entries,
@@ -242,9 +237,8 @@ export class ChromiumCdp {
async ensureNetworkCapture(tabId: number): Promise<void> { async ensureNetworkCapture(tabId: number): Promise<void> {
if (!this.attachedTabs.has(tabId)) { if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId); await this.ensureAttached(tabId);
return;
} }
await this.enableNetworkDomains(tabId); await this.enableNetworkDomain(tabId);
} }
/** Network entries observed on `tabId`, bounded for agent context safety. */ /** Network entries observed on `tabId`, bounded for agent context safety. */
@@ -255,19 +249,13 @@ export class ChromiumCdp {
maxTextChars: number, maxTextChars: number,
): NetworkResult { ): NetworkResult {
const buf = this.networkBuffers.get(tabId) ?? []; const buf = this.networkBuffers.get(tabId) ?? [];
const hasCursor = typeof since === "number"; const { entries, nextSince, truncated } = readBufferedEntries(
const candidates = hasCursor buf,
? buf.filter((entry) => entry.sequence > since) this.networkSequences.get(tabId) ?? 0,
: buf.slice(Math.max(0, buf.length - limit)); since,
const limited = hasCursor ? candidates.slice(0, limit) : candidates; limit,
const entries = limited.map((entry) => projectNetworkEntry(entry, maxTextChars)); (entry) => projectNetworkEntry(entry, maxTextChars),
const lastReturned = entries.at(-1)?.sequence; );
const nextSince = lastReturned ?? this.networkSequences.get(tabId) ?? 0;
const droppedEntries = (this.networkSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
return { return {
tab_id: tabId, tab_id: tabId,
entries, entries,
@@ -328,7 +316,7 @@ export class ChromiumCdp {
this.consoleDomainsAttemptedTabs.clear(); this.consoleDomainsAttemptedTabs.clear();
this.networkBuffers.clear(); this.networkBuffers.clear();
this.networkSequences.clear(); this.networkSequences.clear();
this.networkDomainsAttemptedTabs.clear(); this.networkDomainsEnabledTabs.clear();
this.networkRequestMeta.clear(); this.networkRequestMeta.clear();
await Promise.all( await Promise.all(
tabs.map(async (tabId) => { tabs.map(async (tabId) => {
@@ -357,11 +345,15 @@ export class ChromiumCdp {
} }
} }
private async enableNetworkDomains(tabId: number): Promise<void> { private async enableNetworkDomain(tabId: number): Promise<void> {
if (this.networkDomainsAttemptedTabs.has(tabId)) return; if (this.networkDomainsEnabledTabs.has(tabId)) return;
this.networkDomainsAttemptedTabs.add(tabId); await this.api.sendCommand({ tabId }, "Network.enable", {});
this.networkDomainsEnabledTabs.add(tabId);
}
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
try { try {
await this.api.sendCommand({ tabId }, "Network.enable", {}); await this.enableNetworkDomain(tabId);
} catch (err) { } catch (err) {
console.debug("[bsk cdp] network domain enable failed", { tabId, err }); console.debug("[bsk cdp] network domain enable failed", { tabId, err });
} }
@@ -447,12 +439,7 @@ export class ChromiumCdp {
if (!entry) return; if (!entry) return;
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1; const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
this.consoleSequences.set(tabId, sequence); this.consoleSequences.set(tabId, sequence);
const buf = this.consoleBuffers.get(tabId) ?? []; appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER);
buf.push({ ...entry, sequence });
while (buf.length > MAX_CONSOLE_BUFFER) {
buf.shift();
}
this.consoleBuffers.set(tabId, buf);
} }
private clearConsoleState(tabId: number): void { private clearConsoleState(tabId: number): void {
@@ -468,13 +455,19 @@ export class ChromiumCdp {
if (typeof tabId !== "number") return; if (typeof tabId !== "number") return;
switch (method) { switch (method) {
case "Network.requestWillBeSent": case "Network.requestWillBeSent":
this.rememberNetworkRequest(params); this.rememberNetworkRequest(tabId, params);
break; break;
case "Network.responseReceived": case "Network.responseReceived":
this.appendNetwork(tabId, parseNetworkResponse(this.networkRequestMeta, params)); this.appendNetwork(
tabId,
parseNetworkResponse(this.takeNetworkRequest(tabId, params), params),
);
break; break;
case "Network.loadingFailed": case "Network.loadingFailed":
this.appendNetwork(tabId, parseNetworkFailure(this.networkRequestMeta, params)); this.appendNetwork(
tabId,
parseNetworkFailure(this.takeNetworkRequest(tabId, params), params),
);
break; break;
} }
}; };
@@ -488,36 +481,58 @@ export class ChromiumCdp {
if (!entry) return; if (!entry) return;
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1; const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
this.networkSequences.set(tabId, sequence); this.networkSequences.set(tabId, sequence);
const buf = this.networkBuffers.get(tabId) ?? []; appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER);
buf.push({ ...entry, sequence });
while (buf.length > MAX_NETWORK_BUFFER) {
buf.shift();
}
this.networkBuffers.set(tabId, buf);
} }
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */ /** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
private rememberNetworkRequest(params: unknown): void { private rememberNetworkRequest(tabId: number, params: unknown): void {
const raw = (params ?? {}) as Record<string, unknown>; const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined; const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const request = (raw.request ?? {}) as Record<string, unknown>; const request = (raw.request ?? {}) as Record<string, unknown>;
if (!id || typeof request.url !== "string") return; if (!id || typeof request.url !== "string") return;
this.networkRequestMeta.set(id, { const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH);
url: request.url, const method = truncateOptionalText(
method: typeof request.method === "string" ? request.method : undefined, typeof request.method === "string" ? request.method : undefined,
resourceType: typeof raw.type === "string" ? raw.type : undefined, MAX_NETWORK_FIELD_LENGTH,
);
const resourceType = truncateOptionalText(
typeof raw.type === "string" ? raw.type : undefined,
MAX_NETWORK_FIELD_LENGTH,
);
const tabMeta = this.networkRequestMeta.get(tabId) ?? new Map<string, NetworkRequestMeta>();
tabMeta.delete(id);
tabMeta.set(id, {
url: url.text,
method: method?.text,
resourceType: resourceType?.text,
truncated:
url.truncated || (method?.truncated ?? false) || (resourceType?.truncated ?? false),
}); });
while (this.networkRequestMeta.size > MAX_NETWORK_REQUEST_META) { while (tabMeta.size > MAX_NETWORK_REQUEST_META) {
const oldest = this.networkRequestMeta.keys().next().value; const oldest = tabMeta.keys().next().value;
if (oldest === undefined) break; if (oldest === undefined) break;
this.networkRequestMeta.delete(oldest); tabMeta.delete(oldest);
} }
this.networkRequestMeta.set(tabId, tabMeta);
}
/** Consume request metadata once its response or failure has been observed. */
private takeNetworkRequest(tabId: number, params: unknown): NetworkRequestMeta | undefined {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
if (!id) return undefined;
const tabMeta = this.networkRequestMeta.get(tabId);
const info = tabMeta?.get(id);
tabMeta?.delete(id);
if (tabMeta?.size === 0) this.networkRequestMeta.delete(tabId);
return info;
} }
private clearNetworkState(tabId: number): void { private clearNetworkState(tabId: number): void {
this.networkBuffers.delete(tabId); this.networkBuffers.delete(tabId);
this.networkSequences.delete(tabId); this.networkSequences.delete(tabId);
this.networkDomainsAttemptedTabs.delete(tabId); this.networkDomainsEnabledTabs.delete(tabId);
this.networkRequestMeta.delete(tabId);
} }
private bindAutoDetach(): void { private bindAutoDetach(): void {
@@ -564,6 +579,45 @@ export class ChromiumCdp {
} }
} }
function appendBoundedEntry<T>(
buffers: Map<number, T[]>,
tabId: number,
entry: T,
maxEntries: number,
): void {
const buffer = buffers.get(tabId) ?? [];
buffer.push(entry);
while (buffer.length > maxEntries) buffer.shift();
buffers.set(tabId, buffer);
}
function readBufferedEntries<
TEntry extends { sequence: number },
TProjected extends { sequence: number; truncated: boolean },
>(
buffer: TEntry[],
currentSequence: number,
since: number | undefined,
limit: number,
project: (entry: TEntry) => TProjected,
): { entries: TProjected[]; nextSince: number; truncated: boolean } {
const hasCursor = typeof since === "number";
const candidates = hasCursor ? buffer.filter((entry) => entry.sequence > since) : buffer;
const limited = hasCursor ? candidates.slice(0, limit) : candidates.slice(-limit);
const entries = limited.map(project);
const oldestSequence = buffer[0]?.sequence ?? currentSequence + 1;
const droppedEntries =
currentSequence > buffer.length && (!hasCursor || (since ?? 0) < oldestSequence - 1);
return {
entries,
nextSince: entries.at(-1)?.sequence ?? currentSequence,
truncated:
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated),
};
}
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening { function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
const raw = (params ?? {}) as Record<string, unknown>; const raw = (params ?? {}) as Record<string, unknown>;
const type = normalizeDialogType(raw.type); const type = normalizeDialogType(raw.type);
@@ -655,7 +709,7 @@ function makeConsoleEntry(input: {
timestamp?: number; timestamp?: number;
stack_trace: ConsoleStackFrame[]; stack_trace: ConsoleStackFrame[];
}): ParsedConsoleEntry { }): ParsedConsoleEntry {
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH); const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH);
return { return {
kind: input.kind, kind: input.kind,
level: input.level, level: input.level,
@@ -679,7 +733,7 @@ function projectConsoleEntry(
maxTextChars: number, maxTextChars: number,
includeStack: boolean, includeStack: boolean,
): ConsoleEntry { ): ConsoleEntry {
const projected = truncateConsoleText(entry.text, maxTextChars); const projected = truncateText(entry.text, maxTextChars);
return { return {
sequence: entry.sequence, sequence: entry.sequence,
kind: entry.kind, kind: entry.kind,
@@ -728,28 +782,30 @@ function firstLine(value: string | undefined): string | undefined {
return value.split(/\r?\n/, 1)[0] || undefined; return value.split(/\r?\n/, 1)[0] || undefined;
} }
function truncateConsoleText( function truncateText(value: string, maxChars: number): { text: string; truncated: boolean } {
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false }; if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true }; return { text: value.slice(0, maxChars), truncated: true };
} }
function truncateOptionalText(
value: string | undefined,
maxChars: number,
): { text: string; truncated: boolean } | undefined {
return value === undefined ? undefined : truncateText(value, maxChars);
}
function truncateOptionalConsoleField(value: string | undefined): string | undefined { function truncateOptionalConsoleField(value: string | undefined): string | undefined {
if (value === undefined) return undefined; return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text;
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
} }
function parseNetworkResponse( function parseNetworkResponse(
meta: Map<string, NetworkRequestMeta>, info: NetworkRequestMeta | undefined,
params: unknown, params: unknown,
): ParsedNetworkEntry | null { ): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>; const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const response = (raw.response ?? {}) as Record<string, unknown>; const response = (raw.response ?? {}) as Record<string, unknown>;
const info = id ? meta.get(id) : undefined; const hasResponseUrl = typeof response.url === "string";
const url = typeof response.url === "string" ? response.url : info?.url; const url = hasResponseUrl ? (response.url as string) : info?.url;
if (!url) return null; if (!url) return null;
return makeNetworkEntry({ return makeNetworkEntry({
kind: "response", kind: "response",
@@ -760,90 +816,88 @@ function parseNetworkResponse(
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined, mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType, resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
}); });
} }
function parseNetworkFailure( function parseNetworkFailure(
meta: Map<string, NetworkRequestMeta>, info: NetworkRequestMeta | undefined,
params: unknown, params: unknown,
): ParsedNetworkEntry | null { ): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>; const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const info = id ? meta.get(id) : undefined;
return makeNetworkEntry({ return makeNetworkEntry({
kind: "failure", kind: "failure",
method: info?.method, method: info?.method,
url: info?.url ?? "(unknown)", url: info?.url,
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined, error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType, resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
}); });
} }
function makeNetworkEntry(input: { function makeNetworkEntry(input: {
kind: NetworkEntryKind; kind: NetworkEntryKind;
method?: string; method?: string;
url: string; url?: string;
status?: number; status?: number;
status_text?: string; status_text?: string;
mime_type?: string; mime_type?: string;
resource_type?: string; resource_type?: string;
error_text?: string; error_text?: string;
timestamp?: number; timestamp?: number;
truncated?: boolean;
}): ParsedNetworkEntry { }): ParsedNetworkEntry {
const projectedUrl = truncateNetworkText(input.url, MAX_NETWORK_FIELD_LENGTH); const projectedMethod = truncateOptionalText(input.method, MAX_NETWORK_FIELD_LENGTH);
const projectedUrl = truncateOptionalText(input.url, MAX_NETWORK_FIELD_LENGTH);
const projectedStatusText = truncateOptionalText(input.status_text, MAX_NETWORK_FIELD_LENGTH);
const projectedMimeType = truncateOptionalText(input.mime_type, MAX_NETWORK_FIELD_LENGTH);
const projectedResourceType = truncateOptionalText(input.resource_type, MAX_NETWORK_FIELD_LENGTH);
const projectedError = const projectedError =
input.error_text !== undefined input.error_text !== undefined
? truncateNetworkText(input.error_text, MAX_NETWORK_FIELD_LENGTH) ? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
: undefined; : undefined;
return { return {
kind: input.kind, kind: input.kind,
method: input.method, method: projectedMethod?.text,
url: projectedUrl.text, url: projectedUrl?.text,
status: input.status, status: input.status,
status_text: truncateOptionalNetworkField(input.status_text), status_text: projectedStatusText?.text,
mime_type: truncateOptionalNetworkField(input.mime_type), mime_type: projectedMimeType?.text,
resource_type: input.resource_type, resource_type: projectedResourceType?.text,
error_text: projectedError?.text, error_text: projectedError?.text,
timestamp: input.timestamp, timestamp: input.timestamp,
truncated: projectedUrl.truncated || (projectedError?.truncated ?? false), truncated:
input.truncated === true ||
(projectedMethod?.truncated ?? false) ||
(projectedUrl?.truncated ?? false) ||
(projectedStatusText?.truncated ?? false) ||
(projectedMimeType?.truncated ?? false) ||
(projectedResourceType?.truncated ?? false) ||
(projectedError?.truncated ?? false),
}; };
} }
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry { function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
const projectedUrl = truncateNetworkText(entry.url, maxTextChars); const projectedUrl = truncateOptionalText(entry.url, maxTextChars);
const projectedError = const projectedError =
entry.error_text !== undefined entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined;
? truncateNetworkText(entry.error_text, maxTextChars)
: undefined;
return { return {
sequence: entry.sequence, sequence: entry.sequence,
kind: entry.kind, kind: entry.kind,
method: entry.method, method: entry.method,
url: projectedUrl.text, url: projectedUrl?.text,
status: entry.status, status: entry.status,
status_text: entry.status_text, status_text: entry.status_text,
mime_type: entry.mime_type, mime_type: entry.mime_type,
resource_type: entry.resource_type, resource_type: entry.resource_type,
error_text: projectedError?.text, error_text: projectedError?.text,
timestamp: entry.timestamp, timestamp: entry.timestamp,
truncated: entry.truncated || projectedUrl.truncated || (projectedError?.truncated ?? false), truncated:
entry.truncated || (projectedUrl?.truncated ?? false) || (projectedError?.truncated ?? false),
}; };
} }
function truncateNetworkText(
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function truncateOptionalNetworkField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateNetworkText(value, MAX_NETWORK_FIELD_LENGTH).text;
}
function normalizeError(err: unknown): Error { function normalizeError(err: unknown): Error {
if (err instanceof Error) return err; if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err); if (typeof err === "string") return new Error(err);
@@ -72,7 +72,7 @@ describe("handleConsole", () => {
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false); expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
}); });
it("reads an explicit tab and forwards bounded options", async () => { it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11"); await sm.start("aa11");
const deps = makeDeps({ const deps = makeDeps({
@@ -85,27 +85,15 @@ describe("handleConsole", () => {
session_id: "aa11", session_id: "aa11",
tab_id: 9, tab_id: 9,
since: 12, since: 12,
limit: 500, limit: 75,
max_text_chars: 9999, max_text_chars: 1500,
include_stack: true, include_stack: true,
}, },
deps, deps,
); );
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9); expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true); expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
});
it("rejects invalid bounds before touching CDP", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps();
const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
expect(deps.consoleEntriesSince).not.toHaveBeenCalled();
}); });
it("hides other sessions' Agent Window tabs", async () => { it("hides other sessions' Agent Window tabs", async () => {
@@ -106,6 +106,13 @@ describe("ToolDispatcher", () => {
send: vi.fn(), send: vi.fn(),
detachSession: vi.fn(async () => {}), detachSession: vi.fn(async () => {}),
ensureConsoleCapture: vi.fn(async () => {}), ensureConsoleCapture: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
consoleEntriesSince: vi.fn( consoleEntriesSince: vi.fn(
() => () =>
({ ({
@@ -149,6 +156,13 @@ describe("ToolDispatcher", () => {
const cdp = { const cdp = {
send: vi.fn(), send: vi.fn(),
detachSession: vi.fn(async () => {}), detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
}; };
const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start(); dispatcher.start();
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import type { NetworkResult } from "@/transport/types"; import type { NetworkResult } from "@/transport/types";
import { handleNetwork } from "../network"; import { handleNetwork, type NetworkCdpRunner } from "../network";
function fakeAgentWindow(ids: number[]) { function fakeAgentWindow(ids: number[]) {
let i = 0; let i = 0;
@@ -22,6 +21,7 @@ function makeDeps(
get?: (tabId: number) => Promise<chrome.tabs.Tab>; get?: (tabId: number) => Promise<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>; query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
result?: NetworkResult; result?: NetworkResult;
captureError?: Error;
} = {}, } = {},
) { ) {
const result = const result =
@@ -32,13 +32,16 @@ function makeDeps(
next_since: 0, next_since: 0,
truncated: false, truncated: false,
} satisfies NetworkResult); } satisfies NetworkResult);
const ensureNetworkCapture = vi.fn(async () => {}); const ensureNetworkCapture = vi.fn(async () => {
if (opts.captureError) throw opts.captureError;
});
const networkEntriesSince = vi.fn(() => result); const networkEntriesSince = vi.fn(() => result);
const trackSessionTab = vi.fn();
const cdp = { const cdp = {
send: vi.fn(), trackSessionTab,
ensureNetworkCapture, ensureNetworkCapture,
networkEntriesSince, networkEntriesSince,
} as unknown as CdpRunner; } satisfies NetworkCdpRunner;
const tabsApi = { const tabsApi = {
get: get:
opts.get ?? opts.get ??
@@ -48,7 +51,7 @@ function makeDeps(
query: query:
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]), opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
}; };
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince }; return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince, trackSessionTab };
} }
describe("handleNetwork", () => { describe("handleNetwork", () => {
@@ -74,7 +77,7 @@ describe("handleNetwork", () => {
expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000); expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000);
}); });
it("reads an explicit tab and forwards bounded options", async () => { it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11"); await sm.start("aa11");
const deps = makeDeps({ const deps = makeDeps({
@@ -87,25 +90,25 @@ describe("handleNetwork", () => {
session_id: "aa11", session_id: "aa11",
tab_id: 9, tab_id: 9,
since: 12, since: 12,
limit: 500, limit: 75,
max_text_chars: 9999, max_text_chars: 1500,
}, },
deps, deps,
); );
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9); expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9);
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096); expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500);
}); });
it("rejects invalid bounds before touching CDP", async () => { it("surfaces network capture failures as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11"); await sm.start("aa11");
const deps = makeDeps(); const deps = makeDeps({ captureError: new Error("Network.enable denied") });
const res = await handleNetwork(sm, { session_id: "aa11", limit: 0 }, deps); const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ }); expect(res).toEqual({ code: "cdp_failed", message: "Network.enable denied" });
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled(); expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(deps.networkEntriesSince).not.toHaveBeenCalled(); expect(deps.networkEntriesSince).not.toHaveBeenCalled();
}); });
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import { lookupSession } from "../shared"; import { lookupSession, parseBufferedReadBounds } from "../shared";
function fakeAgentWindow() { function fakeAgentWindow() {
return { return {
@@ -40,3 +40,30 @@ describe("lookupSession", () => {
expect(result).toBe(ctx); expect(result).toBe(ctx);
}); });
}); });
describe("parseBufferedReadBounds", () => {
it("applies shared defaults and caps", () => {
expect(parseBufferedReadBounds({})).toEqual({
since: undefined,
limit: 50,
maxTextChars: 1000,
});
expect(parseBufferedReadBounds({ since: 12, limit: 500, max_text_chars: 9999 })).toEqual({
since: 12,
limit: 200,
maxTextChars: 4096,
});
});
it.each([
[{ since: -1 }, "since"],
[{ since: 1.5 }, "since"],
[{ limit: 0 }, "limit"],
[{ max_text_chars: 0 }, "max_text_chars"],
])("rejects invalid bounds in %o", (params, field) => {
expect(parseBufferedReadBounds(params)).toMatchObject({
code: "invalid_params",
message: expect.stringContaining(field),
});
});
});
+3 -54
View File
@@ -7,14 +7,10 @@ import {
chromeTabsApi, chromeTabsApi,
isRpcError, isRpcError,
lookupSession, lookupSession,
parseBufferedReadBounds,
resolveTargetTab, resolveTargetTab,
} from "./shared"; } from "./shared";
const DEFAULT_CONSOLE_LIMIT = 50;
const MAX_CONSOLE_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
export interface ConsoleDeps { export interface ConsoleDeps {
cdp: CdpRunner; cdp: CdpRunner;
tabsApi: ChromeTabsApi; tabsApi: ChromeTabsApi;
@@ -34,7 +30,7 @@ export async function handleConsole(
): Promise<ConsoleResult | RpcError> { ): Promise<ConsoleResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "console"); const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseConsoleBounds(params); const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds; if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
@@ -50,7 +46,7 @@ export async function handleConsole(
bounds.since, bounds.since,
bounds.limit, bounds.limit,
bounds.maxTextChars, bounds.maxTextChars,
bounds.includeStack, params.include_stack === true,
); );
} catch (err) { } catch (err) {
return { return {
@@ -59,50 +55,3 @@ export async function handleConsole(
}; };
} }
} }
function parseConsoleBounds(params: ConsoleParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
includeStack: boolean;
}
| RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_CONSOLE_LIMIT,
MAX_CONSOLE_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return {
since,
limit,
maxTextChars,
includeStack: params.include_stack === true,
};
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
+8 -7
View File
@@ -34,7 +34,7 @@ import {
handleNavigateForward, handleNavigateForward,
handleReload, handleReload,
} from "./navigation"; } from "./navigation";
import { handleNetwork } from "./network"; import { handleNetwork, type NetworkCdpRunner } from "./network";
import { import {
type CdpRunner, type CdpRunner,
chromeTabsCaptureApi, chromeTabsCaptureApi,
@@ -66,12 +66,15 @@ import {
} from "./tabs"; } from "./tabs";
import { handleWaitForNavigation } from "./waits"; import { handleWaitForNavigation } from "./waits";
type DispatcherCdpRunner = CdpRunner &
NetworkCdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
export interface DispatcherDeps { export interface DispatcherDeps {
transport: Transport; transport: Transport;
sessions: SessionManager; sessions: SessionManager;
cdp?: CdpRunner & { cdp?: DispatcherCdpRunner;
detachSession(sessionId: string): Promise<void>;
};
/** /**
* Invoked whenever a dispatched RPC may have changed the live * Invoked whenever a dispatched RPC may have changed the live
* session set (currently `tool.session_start` and * session set (currently `tool.session_start` and
@@ -105,9 +108,7 @@ export interface DispatcherDeps {
export class ToolDispatcher { export class ToolDispatcher {
private readonly transport: Transport; private readonly transport: Transport;
private readonly sessions: SessionManager; private readonly sessions: SessionManager;
private readonly cdp?: CdpRunner & { private readonly cdp?: DispatcherCdpRunner;
detachSession(sessionId: string): Promise<void>;
};
private readonly onSessionsChanged?: () => void; private readonly onSessionsChanged?: () => void;
private readonly approveBorrow?: BorrowConfirmationApprover; private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string }; private readonly helpNotificationCopy?: () => { title: string; body: string };
+14 -56
View File
@@ -2,21 +2,27 @@ import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager"; import type { SessionManager } from "@/session-manager/manager";
import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types"; import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types";
import { import {
type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
chromeTabsApi, chromeTabsApi,
isRpcError, isRpcError,
lookupSession, lookupSession,
parseBufferedReadBounds,
resolveTargetTab, resolveTargetTab,
} from "./shared"; } from "./shared";
const DEFAULT_NETWORK_LIMIT = 50; export interface NetworkCdpRunner {
const MAX_NETWORK_LIMIT = 200; trackSessionTab?(sessionId: string, tabId: number): void;
const DEFAULT_MAX_TEXT_CHARS = 1000; ensureNetworkCapture(tabId: number): Promise<void>;
const MAX_TEXT_CHARS = 4096; networkEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
): NetworkResult;
}
export interface NetworkDeps { export interface NetworkDeps {
cdp: CdpRunner; cdp: NetworkCdpRunner;
tabsApi: ChromeTabsApi; tabsApi: ChromeTabsApi;
} }
@@ -34,17 +40,14 @@ export async function handleNetwork(
): Promise<NetworkResult | RpcError> { ): Promise<NetworkResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "network"); const ctxOrErr = lookupSession(manager, params, "network");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseNetworkBounds(params); const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds; if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (!deps.cdp.ensureNetworkCapture || !deps.cdp.networkEntriesSince) {
return { code: "cdp_failed", message: "network capture requires CDP network support" };
}
try { try {
await deps.cdp.ensureNetworkCapture(target.tabId);
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
await deps.cdp.ensureNetworkCapture(target.tabId);
return deps.cdp.networkEntriesSince( return deps.cdp.networkEntriesSince(
target.tabId, target.tabId,
bounds.since, bounds.since,
@@ -58,48 +61,3 @@ export async function handleNetwork(
}; };
} }
} }
function parseNetworkBounds(params: NetworkParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
}
| RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_NETWORK_LIMIT,
MAX_NETWORK_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return {
since,
limit,
maxTextChars,
};
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
+52 -13
View File
@@ -9,14 +9,14 @@
import type { DialogCursor } from "@/browser-driver/chromium-cdp"; import type { DialogCursor } from "@/browser-driver/chromium-cdp";
import type { SessionContext, SessionManager } from "@/session-manager/manager"; import type { SessionContext, SessionManager } from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store"; import { normaliseRef } from "@/session-manager/ref-store";
import type { import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
ConsoleResult,
JavaScriptDialogInfo,
NetworkResult,
RpcError,
} from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
const DEFAULT_BUFFERED_READ_LIMIT = 50;
const MAX_BUFFERED_READ_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
/** /**
* Subset of `chrome.tabs` we depend on across tool handlers. Kept on * Subset of `chrome.tabs` we depend on across tool handlers. Kept on
* a thin interface so vitest can inject a fake without monkey-patching * a thin interface so vitest can inject a fake without monkey-patching
@@ -58,13 +58,52 @@ export interface CdpRunner {
maxTextChars: number, maxTextChars: number,
includeStack: boolean, includeStack: boolean,
): ConsoleResult; ): ConsoleResult;
ensureNetworkCapture?(tabId: number): Promise<void>; }
networkEntriesSince?(
tabId: number, export interface BufferedReadBounds {
since: number | undefined, since: number | undefined;
limit: number, limit: number;
maxTextChars: number, maxTextChars: number;
): NetworkResult; }
/** Parse the common cursor and output bounds used by buffered read tools. */
export function parseBufferedReadBounds(params: {
since?: number;
limit?: number;
max_text_chars?: number;
}): BufferedReadBounds | RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_BUFFERED_READ_LIMIT,
MAX_BUFFERED_READ_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return { since, limit, maxTextChars };
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
} }
/** /**
+1 -1
View File
@@ -178,7 +178,7 @@ export interface NetworkEntry {
sequence: number; sequence: number;
kind: NetworkEntryKind; kind: NetworkEntryKind;
method?: string; method?: string;
url: string; url?: string;
status?: number; status?: number;
status_text?: string; status_text?: string;
mime_type?: string; mime_type?: string;
+31 -2
View File
@@ -89,17 +89,46 @@ fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> {
fn render_entry(entry: &NetworkEntry) -> String { fn render_entry(entry: &NetworkEntry) -> String {
let method = entry.method.as_deref().unwrap_or("?"); let method = entry.method.as_deref().unwrap_or("?");
let url = entry.url.as_deref().unwrap_or("(unknown)");
match entry.kind { match entry.kind {
bsk_protocol::tools::NetworkEntryKind::Failure => { bsk_protocol::tools::NetworkEntryKind::Failure => {
let err = entry.error_text.as_deref().unwrap_or("failed"); let err = entry.error_text.as_deref().unwrap_or("failed");
format!("#{} FAILED {method} {} — {err}", entry.sequence, entry.url) format!("#{} FAILED {method} {url} - {err}", entry.sequence)
} }
bsk_protocol::tools::NetworkEntryKind::Response => { bsk_protocol::tools::NetworkEntryKind::Response => {
let status = entry let status = entry
.status .status
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| "?".to_string()); .unwrap_or_else(|| "?".to_string());
format!("#{} {status} {method} {}", entry.sequence, entry.url) format!("#{} {status} {method} {url}", entry.sequence)
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use bsk_protocol::tools::NetworkEntryKind;
#[test]
fn human_failure_output_uses_ascii_and_displays_unknown_url() {
let entry = NetworkEntry {
sequence: 7,
kind: NetworkEntryKind::Failure,
method: Some("GET".into()),
url: None,
status: None,
status_text: None,
mime_type: None,
resource_type: Some("Fetch".into()),
error_text: Some("net::ERR_FAILED".into()),
timestamp: None,
truncated: false,
};
assert_eq!(
render_entry(&entry),
"#7 FAILED GET (unknown) - net::ERR_FAILED"
);
}
}
+1 -1
View File
@@ -386,7 +386,7 @@ async fn network_returns_buffered_entries() {
sequence: 4, sequence: 4,
kind: NetworkEntryKind::Response, kind: NetworkEntryKind::Response,
method: Some("GET".into()), method: Some("GET".into()),
url: "https://example.test/api".into(), url: Some("https://example.test/api".into()),
status: Some(404), status: Some(404),
status_text: Some("Not Found".into()), status_text: Some("Not Found".into()),
mime_type: Some("application/json".into()), mime_type: Some("application/json".into()),
@@ -4,8 +4,7 @@
"type": "object", "type": "object",
"required": [ "required": [
"kind", "kind",
"sequence", "sequence"
"url"
], ],
"properties": { "properties": {
"error_text": { "error_text": {
@@ -68,7 +67,10 @@
"type": "boolean" "type": "boolean"
}, },
"url": { "url": {
"type": "string" "type": [
"string",
"null"
]
} }
}, },
"definitions": { "definitions": {
@@ -32,8 +32,7 @@
"type": "object", "type": "object",
"required": [ "required": [
"kind", "kind",
"sequence", "sequence"
"url"
], ],
"properties": { "properties": {
"error_text": { "error_text": {
@@ -96,7 +95,10 @@
"type": "boolean" "type": "boolean"
}, },
"url": { "url": {
"type": "string" "type": [
"string",
"null"
]
} }
} }
}, },
+5 -12
View File
@@ -31,22 +31,14 @@ pub enum NetworkEntryKind {
Failure, Failure,
} }
impl NetworkEntryKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Response => "response",
Self::Failure => "failure",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NetworkEntry { pub struct NetworkEntry {
pub sequence: u64, pub sequence: u64,
pub kind: NetworkEntryKind, pub kind: NetworkEntryKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>, pub method: Option<String>,
pub url: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// HTTP status code (`response` entries only). /// HTTP status code (`response` entries only).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u32>, pub status: Option<u32>,
@@ -108,7 +100,7 @@ mod tests {
sequence: 3, sequence: 3,
kind: NetworkEntryKind::Response, kind: NetworkEntryKind::Response,
method: Some("GET".into()), method: Some("GET".into()),
url: "https://example.test/api".into(), url: Some("https://example.test/api".into()),
status: Some(404), status: Some(404),
status_text: Some("Not Found".into()), status_text: Some("Not Found".into()),
mime_type: Some("application/json".into()), mime_type: Some("application/json".into()),
@@ -121,7 +113,7 @@ mod tests {
sequence: 4, sequence: 4,
kind: NetworkEntryKind::Failure, kind: NetworkEntryKind::Failure,
method: Some("GET".into()), method: Some("GET".into()),
url: "https://example.test/blocked".into(), url: None,
status: None, status: None,
status_text: None, status_text: None,
mime_type: None, mime_type: None,
@@ -138,6 +130,7 @@ mod tests {
assert_eq!(value["entries"][0]["kind"], json!("response")); assert_eq!(value["entries"][0]["kind"], json!("response"));
assert_eq!(value["entries"][0]["status"], json!(404)); assert_eq!(value["entries"][0]["status"], json!(404));
assert_eq!(value["entries"][1]["kind"], json!("failure")); assert_eq!(value["entries"][1]["kind"], json!("failure"));
assert!(value["entries"][1].get("url").is_none());
assert_eq!( assert_eq!(
value["entries"][1]["error_text"], value["entries"][1]["error_text"],
json!("net::ERR_BLOCKED_BY_CLIENT") json!("net::ERR_BLOCKED_BY_CLIENT")