From 9799b98937df833811dacbdfda457d59bce38f67 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 18 Jul 2026 22:52:53 +0800 Subject: [PATCH] fix: address network command review feedback Co-authored-by: Cursor --- .../__tests__/chromium-cdp.test.ts | 140 ++++++++++ .../src/browser-driver/chromium-cdp.ts | 264 +++++++++++------- .../src/tools/__tests__/console.test.ts | 20 +- .../src/tools/__tests__/dispatcher.test.ts | 14 + .../src/tools/__tests__/network.test.ts | 33 ++- .../src/tools/__tests__/shared.test.ts | 29 +- apps/extension/src/tools/console.ts | 57 +--- apps/extension/src/tools/dispatcher.ts | 15 +- apps/extension/src/tools/network.ts | 70 +---- apps/extension/src/tools/shared.ts | 65 ++++- apps/extension/src/transport/types.ts | 2 +- crates/bsk-cli/src/cli/network.rs | 33 ++- crates/bsk-cli/tests/tools_ipc.rs | 2 +- .../schema/tool_network_entry.json | 8 +- .../schema/tool_network_result.json | 8 +- crates/bsk-protocol/src/tools/network.rs | 17 +- 16 files changed, 488 insertions(+), 289 deletions(-) diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index 88b8735..f6d88d3 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -139,6 +139,25 @@ describe("ChromiumCdp", () => { expect(enableCalls).toHaveLength(2); }); + it("retries and surfaces Network.enable failures for explicit capture", async () => { + const { api } = fakeApi(); + (api.sendCommand as ReturnType).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).mock.calls.filter( + ([, method]) => method === "Network.enable", + ); + expect(enableCalls).toHaveLength(2); + }); + it("records javascriptDialogOpening and auto-accepts", async () => { const { api, onEvent } = fakeApi(); const cdp = new ChromiumCdp(api); @@ -321,6 +340,30 @@ describe("ChromiumCdp", () => { truncated: true, 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 () => { @@ -336,6 +379,103 @@ describe("ChromiumCdp", () => { 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 () => { const { api } = fakeApi(); const cdp = new ChromiumCdp(api); diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 745fd64..3848e97 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -106,11 +106,12 @@ interface ParsedConsoleEntry extends Omit {} interface ParsedNetworkEntry extends Omit {} -/** Request metadata remembered from `requestWillBeSent`, keyed by requestId. */ +/** Bounded request metadata remembered from `requestWillBeSent`. */ interface NetworkRequestMeta { url: string; method?: string; resourceType?: string; + truncated: boolean; } /** @@ -129,8 +130,8 @@ export class ChromiumCdp { private readonly consoleDomainsAttemptedTabs = new Set(); private readonly networkBuffers = new Map(); private readonly networkSequences = new Map(); - private readonly networkDomainsAttemptedTabs = new Set(); - private readonly networkRequestMeta = new Map(); + private readonly networkDomainsEnabledTabs = new Set(); + private readonly networkRequestMeta = new Map>(); private detachSubscription: { dispose(): void } | null = null; private dialogSubscription: { 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.enablePageDomain(tabId); await this.enableConsoleDomains(tabId); - await this.enableNetworkDomains(tabId); + await this.enableNetworkDomainBestEffort(tabId); this.attachedTabs.add(tabId); })() .catch((err) => { @@ -217,19 +218,13 @@ export class ChromiumCdp { includeStack: boolean, ): ConsoleResult { const buf = this.consoleBuffers.get(tabId) ?? []; - const hasCursor = typeof since === "number"; - const candidates = hasCursor - ? buf.filter((entry) => entry.sequence > since) - : buf.slice(Math.max(0, buf.length - limit)); - const limited = hasCursor ? candidates.slice(0, limit) : candidates; - const entries = limited.map((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); + const { entries, nextSince, truncated } = readBufferedEntries( + buf, + this.consoleSequences.get(tabId) ?? 0, + since, + limit, + (entry) => projectConsoleEntry(entry, maxTextChars, includeStack), + ); return { tab_id: tabId, entries, @@ -242,9 +237,8 @@ export class ChromiumCdp { async ensureNetworkCapture(tabId: number): Promise { if (!this.attachedTabs.has(tabId)) { await this.ensureAttached(tabId); - return; } - await this.enableNetworkDomains(tabId); + await this.enableNetworkDomain(tabId); } /** Network entries observed on `tabId`, bounded for agent context safety. */ @@ -255,19 +249,13 @@ export class ChromiumCdp { maxTextChars: number, ): NetworkResult { const buf = this.networkBuffers.get(tabId) ?? []; - const hasCursor = typeof since === "number"; - const candidates = hasCursor - ? buf.filter((entry) => entry.sequence > since) - : buf.slice(Math.max(0, buf.length - limit)); - const limited = hasCursor ? candidates.slice(0, limit) : candidates; - const entries = limited.map((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); + const { entries, nextSince, truncated } = readBufferedEntries( + buf, + this.networkSequences.get(tabId) ?? 0, + since, + limit, + (entry) => projectNetworkEntry(entry, maxTextChars), + ); return { tab_id: tabId, entries, @@ -328,7 +316,7 @@ export class ChromiumCdp { this.consoleDomainsAttemptedTabs.clear(); this.networkBuffers.clear(); this.networkSequences.clear(); - this.networkDomainsAttemptedTabs.clear(); + this.networkDomainsEnabledTabs.clear(); this.networkRequestMeta.clear(); await Promise.all( tabs.map(async (tabId) => { @@ -357,11 +345,15 @@ export class ChromiumCdp { } } - private async enableNetworkDomains(tabId: number): Promise { - if (this.networkDomainsAttemptedTabs.has(tabId)) return; - this.networkDomainsAttemptedTabs.add(tabId); + private async enableNetworkDomain(tabId: number): Promise { + if (this.networkDomainsEnabledTabs.has(tabId)) return; + await this.api.sendCommand({ tabId }, "Network.enable", {}); + this.networkDomainsEnabledTabs.add(tabId); + } + + private async enableNetworkDomainBestEffort(tabId: number): Promise { try { - await this.api.sendCommand({ tabId }, "Network.enable", {}); + await this.enableNetworkDomain(tabId); } catch (err) { console.debug("[bsk cdp] network domain enable failed", { tabId, err }); } @@ -447,12 +439,7 @@ export class ChromiumCdp { if (!entry) return; const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1; this.consoleSequences.set(tabId, sequence); - const buf = this.consoleBuffers.get(tabId) ?? []; - buf.push({ ...entry, sequence }); - while (buf.length > MAX_CONSOLE_BUFFER) { - buf.shift(); - } - this.consoleBuffers.set(tabId, buf); + appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER); } private clearConsoleState(tabId: number): void { @@ -468,13 +455,19 @@ export class ChromiumCdp { if (typeof tabId !== "number") return; switch (method) { case "Network.requestWillBeSent": - this.rememberNetworkRequest(params); + this.rememberNetworkRequest(tabId, params); break; case "Network.responseReceived": - this.appendNetwork(tabId, parseNetworkResponse(this.networkRequestMeta, params)); + this.appendNetwork( + tabId, + parseNetworkResponse(this.takeNetworkRequest(tabId, params), params), + ); break; case "Network.loadingFailed": - this.appendNetwork(tabId, parseNetworkFailure(this.networkRequestMeta, params)); + this.appendNetwork( + tabId, + parseNetworkFailure(this.takeNetworkRequest(tabId, params), params), + ); break; } }; @@ -488,36 +481,58 @@ export class ChromiumCdp { if (!entry) return; const sequence = (this.networkSequences.get(tabId) ?? 0) + 1; this.networkSequences.set(tabId, sequence); - const buf = this.networkBuffers.get(tabId) ?? []; - buf.push({ ...entry, sequence }); - while (buf.length > MAX_NETWORK_BUFFER) { - buf.shift(); - } - this.networkBuffers.set(tabId, buf); + appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER); } /** 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; const id = typeof raw.requestId === "string" ? raw.requestId : undefined; const request = (raw.request ?? {}) as Record; if (!id || typeof request.url !== "string") return; - this.networkRequestMeta.set(id, { - url: request.url, - method: typeof request.method === "string" ? request.method : undefined, - resourceType: typeof raw.type === "string" ? raw.type : undefined, + const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH); + const method = truncateOptionalText( + typeof request.method === "string" ? request.method : 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(); + 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) { - const oldest = this.networkRequestMeta.keys().next().value; + while (tabMeta.size > MAX_NETWORK_REQUEST_META) { + const oldest = tabMeta.keys().next().value; 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; + 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 { this.networkBuffers.delete(tabId); this.networkSequences.delete(tabId); - this.networkDomainsAttemptedTabs.delete(tabId); + this.networkDomainsEnabledTabs.delete(tabId); + this.networkRequestMeta.delete(tabId); } private bindAutoDetach(): void { @@ -564,6 +579,45 @@ export class ChromiumCdp { } } +function appendBoundedEntry( + buffers: Map, + 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 { const raw = (params ?? {}) as Record; const type = normalizeDialogType(raw.type); @@ -655,7 +709,7 @@ function makeConsoleEntry(input: { timestamp?: number; stack_trace: ConsoleStackFrame[]; }): ParsedConsoleEntry { - const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH); + const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH); return { kind: input.kind, level: input.level, @@ -679,7 +733,7 @@ function projectConsoleEntry( maxTextChars: number, includeStack: boolean, ): ConsoleEntry { - const projected = truncateConsoleText(entry.text, maxTextChars); + const projected = truncateText(entry.text, maxTextChars); return { sequence: entry.sequence, kind: entry.kind, @@ -728,28 +782,30 @@ function firstLine(value: string | undefined): string | undefined { return value.split(/\r?\n/, 1)[0] || undefined; } -function truncateConsoleText( - value: string, - maxChars: number, -): { text: string; truncated: boolean } { +function truncateText(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 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 { - if (value === undefined) return undefined; - return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text; + return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text; } function parseNetworkResponse( - meta: Map, + info: NetworkRequestMeta | undefined, params: unknown, ): ParsedNetworkEntry | null { const raw = (params ?? {}) as Record; - const id = typeof raw.requestId === "string" ? raw.requestId : undefined; const response = (raw.response ?? {}) as Record; - const info = id ? meta.get(id) : undefined; - const url = typeof response.url === "string" ? response.url : info?.url; + const hasResponseUrl = typeof response.url === "string"; + const url = hasResponseUrl ? (response.url as string) : info?.url; if (!url) return null; return makeNetworkEntry({ kind: "response", @@ -760,90 +816,88 @@ function parseNetworkResponse( mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined, resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType, timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, + truncated: info?.truncated === true, }); } function parseNetworkFailure( - meta: Map, + info: NetworkRequestMeta | undefined, params: unknown, ): ParsedNetworkEntry | null { const raw = (params ?? {}) as Record; - const id = typeof raw.requestId === "string" ? raw.requestId : undefined; - const info = id ? meta.get(id) : undefined; return makeNetworkEntry({ kind: "failure", method: info?.method, - url: info?.url ?? "(unknown)", + url: info?.url, error_text: typeof raw.errorText === "string" ? raw.errorText : undefined, resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType, timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, + truncated: info?.truncated === true, }); } function makeNetworkEntry(input: { kind: NetworkEntryKind; method?: string; - url: string; + url?: string; status?: number; status_text?: string; mime_type?: string; resource_type?: string; error_text?: string; timestamp?: number; + truncated?: boolean; }): 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 = input.error_text !== undefined - ? truncateNetworkText(input.error_text, MAX_NETWORK_FIELD_LENGTH) + ? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH) : undefined; return { kind: input.kind, - method: input.method, - url: projectedUrl.text, + method: projectedMethod?.text, + url: projectedUrl?.text, status: input.status, - status_text: truncateOptionalNetworkField(input.status_text), - mime_type: truncateOptionalNetworkField(input.mime_type), - resource_type: input.resource_type, + status_text: projectedStatusText?.text, + mime_type: projectedMimeType?.text, + resource_type: projectedResourceType?.text, error_text: projectedError?.text, 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 { - const projectedUrl = truncateNetworkText(entry.url, maxTextChars); + const projectedUrl = truncateOptionalText(entry.url, maxTextChars); const projectedError = - entry.error_text !== undefined - ? truncateNetworkText(entry.error_text, maxTextChars) - : undefined; + entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined; return { sequence: entry.sequence, kind: entry.kind, method: entry.method, - url: projectedUrl.text, + url: projectedUrl?.text, status: entry.status, status_text: entry.status_text, mime_type: entry.mime_type, resource_type: entry.resource_type, error_text: projectedError?.text, 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 { if (err instanceof Error) return err; if (typeof err === "string") return new Error(err); diff --git a/apps/extension/src/tools/__tests__/console.test.ts b/apps/extension/src/tools/__tests__/console.test.ts index a219ded..a84e2be 100644 --- a/apps/extension/src/tools/__tests__/console.test.ts +++ b/apps/extension/src/tools/__tests__/console.test.ts @@ -72,7 +72,7 @@ describe("handleConsole", () => { 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]) }); await sm.start("aa11"); const deps = makeDeps({ @@ -85,27 +85,15 @@ describe("handleConsole", () => { session_id: "aa11", tab_id: 9, since: 12, - limit: 500, - max_text_chars: 9999, + limit: 75, + max_text_chars: 1500, include_stack: true, }, deps, ); expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9); - expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, 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(); + expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true); }); it("hides other sessions' Agent Window tabs", async () => { diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 8241e61..a0a3ad5 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -106,6 +106,13 @@ describe("ToolDispatcher", () => { send: vi.fn(), detachSession: 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( () => ({ @@ -149,6 +156,13 @@ describe("ToolDispatcher", () => { const cdp = { send: vi.fn(), 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 }); dispatcher.start(); diff --git a/apps/extension/src/tools/__tests__/network.test.ts b/apps/extension/src/tools/__tests__/network.test.ts index bcf6338..ed6f80b 100644 --- a/apps/extension/src/tools/__tests__/network.test.ts +++ b/apps/extension/src/tools/__tests__/network.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { SessionManager } from "@/session-manager/manager"; -import type { CdpRunner } from "@/tools/shared"; import type { NetworkResult } from "@/transport/types"; -import { handleNetwork } from "../network"; +import { handleNetwork, type NetworkCdpRunner } from "../network"; function fakeAgentWindow(ids: number[]) { let i = 0; @@ -22,6 +21,7 @@ function makeDeps( get?: (tabId: number) => Promise; query?: (query: chrome.tabs.QueryInfo) => Promise; result?: NetworkResult; + captureError?: Error; } = {}, ) { const result = @@ -32,13 +32,16 @@ function makeDeps( next_since: 0, truncated: false, } satisfies NetworkResult); - const ensureNetworkCapture = vi.fn(async () => {}); + const ensureNetworkCapture = vi.fn(async () => { + if (opts.captureError) throw opts.captureError; + }); const networkEntriesSince = vi.fn(() => result); + const trackSessionTab = vi.fn(); const cdp = { - send: vi.fn(), + trackSessionTab, ensureNetworkCapture, networkEntriesSince, - } as unknown as CdpRunner; + } satisfies NetworkCdpRunner; const tabsApi = { get: opts.get ?? @@ -48,7 +51,7 @@ function makeDeps( query: 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", () => { @@ -74,7 +77,7 @@ describe("handleNetwork", () => { 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]) }); await sm.start("aa11"); const deps = makeDeps({ @@ -87,25 +90,25 @@ describe("handleNetwork", () => { session_id: "aa11", tab_id: 9, since: 12, - limit: 500, - max_text_chars: 9999, + limit: 75, + max_text_chars: 1500, }, deps, ); 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]) }); 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(deps.ensureNetworkCapture).not.toHaveBeenCalled(); + expect(res).toEqual({ code: "cdp_failed", message: "Network.enable denied" }); + expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 7); expect(deps.networkEntriesSince).not.toHaveBeenCalled(); }); diff --git a/apps/extension/src/tools/__tests__/shared.test.ts b/apps/extension/src/tools/__tests__/shared.test.ts index da7ee8a..c8fa77b 100644 --- a/apps/extension/src/tools/__tests__/shared.test.ts +++ b/apps/extension/src/tools/__tests__/shared.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { SessionManager } from "@/session-manager/manager"; -import { lookupSession } from "../shared"; +import { lookupSession, parseBufferedReadBounds } from "../shared"; function fakeAgentWindow() { return { @@ -40,3 +40,30 @@ describe("lookupSession", () => { 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), + }); + }); +}); diff --git a/apps/extension/src/tools/console.ts b/apps/extension/src/tools/console.ts index efd4784..68cc7e4 100644 --- a/apps/extension/src/tools/console.ts +++ b/apps/extension/src/tools/console.ts @@ -7,14 +7,10 @@ import { chromeTabsApi, isRpcError, lookupSession, + parseBufferedReadBounds, resolveTargetTab, } 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 { cdp: CdpRunner; tabsApi: ChromeTabsApi; @@ -34,7 +30,7 @@ export async function handleConsole( ): Promise { const ctxOrErr = lookupSession(manager, params, "console"); if (isRpcError(ctxOrErr)) return ctxOrErr; - const bounds = parseConsoleBounds(params); + const bounds = parseBufferedReadBounds(params); if (isRpcError(bounds)) return bounds; const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); if (isRpcError(target)) return target; @@ -50,7 +46,7 @@ export async function handleConsole( bounds.since, bounds.limit, bounds.maxTextChars, - bounds.includeStack, + params.include_stack === true, ); } catch (err) { 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); -} diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index a22b767..669727f 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -34,7 +34,7 @@ import { handleNavigateForward, handleReload, } from "./navigation"; -import { handleNetwork } from "./network"; +import { handleNetwork, type NetworkCdpRunner } from "./network"; import { type CdpRunner, chromeTabsCaptureApi, @@ -66,12 +66,15 @@ import { } from "./tabs"; import { handleWaitForNavigation } from "./waits"; +type DispatcherCdpRunner = CdpRunner & + NetworkCdpRunner & { + detachSession(sessionId: string): Promise; + }; + export interface DispatcherDeps { transport: Transport; sessions: SessionManager; - cdp?: CdpRunner & { - detachSession(sessionId: string): Promise; - }; + cdp?: DispatcherCdpRunner; /** * Invoked whenever a dispatched RPC may have changed the live * session set (currently `tool.session_start` and @@ -105,9 +108,7 @@ export interface DispatcherDeps { export class ToolDispatcher { private readonly transport: Transport; private readonly sessions: SessionManager; - private readonly cdp?: CdpRunner & { - detachSession(sessionId: string): Promise; - }; + private readonly cdp?: DispatcherCdpRunner; private readonly onSessionsChanged?: () => void; private readonly approveBorrow?: BorrowConfirmationApprover; private readonly helpNotificationCopy?: () => { title: string; body: string }; diff --git a/apps/extension/src/tools/network.ts b/apps/extension/src/tools/network.ts index 4c72ebd..850f1b6 100644 --- a/apps/extension/src/tools/network.ts +++ b/apps/extension/src/tools/network.ts @@ -2,21 +2,27 @@ import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import type { SessionManager } from "@/session-manager/manager"; import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types"; import { - type CdpRunner, type ChromeTabsApi, chromeTabsApi, isRpcError, lookupSession, + parseBufferedReadBounds, resolveTargetTab, } from "./shared"; -const DEFAULT_NETWORK_LIMIT = 50; -const MAX_NETWORK_LIMIT = 200; -const DEFAULT_MAX_TEXT_CHARS = 1000; -const MAX_TEXT_CHARS = 4096; +export interface NetworkCdpRunner { + trackSessionTab?(sessionId: string, tabId: number): void; + ensureNetworkCapture(tabId: number): Promise; + networkEntriesSince( + tabId: number, + since: number | undefined, + limit: number, + maxTextChars: number, + ): NetworkResult; +} export interface NetworkDeps { - cdp: CdpRunner; + cdp: NetworkCdpRunner; tabsApi: ChromeTabsApi; } @@ -34,17 +40,14 @@ export async function handleNetwork( ): Promise { const ctxOrErr = lookupSession(manager, params, "network"); if (isRpcError(ctxOrErr)) return ctxOrErr; - const bounds = parseNetworkBounds(params); + const bounds = parseBufferedReadBounds(params); if (isRpcError(bounds)) return bounds; const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); if (isRpcError(target)) return target; - if (!deps.cdp.ensureNetworkCapture || !deps.cdp.networkEntriesSince) { - return { code: "cdp_failed", message: "network capture requires CDP network support" }; - } try { - await deps.cdp.ensureNetworkCapture(target.tabId); deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId); + await deps.cdp.ensureNetworkCapture(target.tabId); return deps.cdp.networkEntriesSince( target.tabId, 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); -} diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index f8dc093..00ecde1 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -9,14 +9,14 @@ import type { DialogCursor } from "@/browser-driver/chromium-cdp"; import type { SessionContext, SessionManager } from "@/session-manager/manager"; import { normaliseRef } from "@/session-manager/ref-store"; -import type { - ConsoleResult, - JavaScriptDialogInfo, - NetworkResult, - RpcError, -} from "@/transport/types"; +import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types"; 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 * a thin interface so vitest can inject a fake without monkey-patching @@ -58,13 +58,52 @@ export interface CdpRunner { maxTextChars: number, includeStack: boolean, ): ConsoleResult; - ensureNetworkCapture?(tabId: number): Promise; - networkEntriesSince?( - tabId: number, - since: number | undefined, - limit: number, - maxTextChars: number, - ): NetworkResult; +} + +export interface BufferedReadBounds { + since: number | undefined; + limit: number; + maxTextChars: number; +} + +/** 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); } /** diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index efdf36c..2727491 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -178,7 +178,7 @@ export interface NetworkEntry { sequence: number; kind: NetworkEntryKind; method?: string; - url: string; + url?: string; status?: number; status_text?: string; mime_type?: string; diff --git a/crates/bsk-cli/src/cli/network.rs b/crates/bsk-cli/src/cli/network.rs index c0c84ac..56775af 100644 --- a/crates/bsk-cli/src/cli/network.rs +++ b/crates/bsk-cli/src/cli/network.rs @@ -89,17 +89,46 @@ fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> { fn render_entry(entry: &NetworkEntry) -> String { let method = entry.method.as_deref().unwrap_or("?"); + let url = entry.url.as_deref().unwrap_or("(unknown)"); match entry.kind { bsk_protocol::tools::NetworkEntryKind::Failure => { 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 => { let status = entry .status .map(|s| s.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" + ); + } +} diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index d3b681d..00d821a 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -386,7 +386,7 @@ async fn network_returns_buffered_entries() { sequence: 4, kind: NetworkEntryKind::Response, method: Some("GET".into()), - url: "https://example.test/api".into(), + url: Some("https://example.test/api".into()), status: Some(404), status_text: Some("Not Found".into()), mime_type: Some("application/json".into()), diff --git a/crates/bsk-protocol/schema/tool_network_entry.json b/crates/bsk-protocol/schema/tool_network_entry.json index 08ce4d1..295d092 100644 --- a/crates/bsk-protocol/schema/tool_network_entry.json +++ b/crates/bsk-protocol/schema/tool_network_entry.json @@ -4,8 +4,7 @@ "type": "object", "required": [ "kind", - "sequence", - "url" + "sequence" ], "properties": { "error_text": { @@ -68,7 +67,10 @@ "type": "boolean" }, "url": { - "type": "string" + "type": [ + "string", + "null" + ] } }, "definitions": { diff --git a/crates/bsk-protocol/schema/tool_network_result.json b/crates/bsk-protocol/schema/tool_network_result.json index 747ec8e..324ca09 100644 --- a/crates/bsk-protocol/schema/tool_network_result.json +++ b/crates/bsk-protocol/schema/tool_network_result.json @@ -32,8 +32,7 @@ "type": "object", "required": [ "kind", - "sequence", - "url" + "sequence" ], "properties": { "error_text": { @@ -96,7 +95,10 @@ "type": "boolean" }, "url": { - "type": "string" + "type": [ + "string", + "null" + ] } } }, diff --git a/crates/bsk-protocol/src/tools/network.rs b/crates/bsk-protocol/src/tools/network.rs index 9cf556f..52ece76 100644 --- a/crates/bsk-protocol/src/tools/network.rs +++ b/crates/bsk-protocol/src/tools/network.rs @@ -31,22 +31,14 @@ pub enum NetworkEntryKind { 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)] pub struct NetworkEntry { pub sequence: u64, pub kind: NetworkEntryKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub method: Option, - pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, /// HTTP status code (`response` entries only). #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, @@ -108,7 +100,7 @@ mod tests { sequence: 3, kind: NetworkEntryKind::Response, method: Some("GET".into()), - url: "https://example.test/api".into(), + url: Some("https://example.test/api".into()), status: Some(404), status_text: Some("Not Found".into()), mime_type: Some("application/json".into()), @@ -121,7 +113,7 @@ mod tests { sequence: 4, kind: NetworkEntryKind::Failure, method: Some("GET".into()), - url: "https://example.test/blocked".into(), + url: None, status: None, status_text: None, mime_type: None, @@ -138,6 +130,7 @@ mod tests { assert_eq!(value["entries"][0]["kind"], json!("response")); assert_eq!(value["entries"][0]["status"], json!(404)); assert_eq!(value["entries"][1]["kind"], json!("failure")); + assert!(value["entries"][1].get("url").is_none()); assert_eq!( value["entries"][1]["error_text"], json!("net::ERR_BLOCKED_BY_CLIENT")