From 965e92216a0d06d82d0d76d9da812c6dd14efff3 Mon Sep 17 00:00:00 2001 From: hjxccc <96366907+hjxccc@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:40:34 +0800 Subject: [PATCH] feat: add bsk network read-only command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bsk network` as a sibling of the `console` command from #7, reading the buffered network responses / failures for a tab. Fills the remaining half of the request in #2 (console landed in #7; network did not). Mirrors the console command's conventions exactly: bsk network --session [--tab-id N] [--since C] [--limit N] [--max-text-chars N] The extension enables `Network` on attach (best-effort) and buffers `responseReceived` / `loadingFailed` events per tab with a monotonic sequence, correlating them to `requestWillBeSent` for method/URL. Reads are cursor-paginated (`since` -> `next_since`) and bounded (`limit`, `max_text_chars`) for agent-context safety, same as console. Entries carry the real HTTP status code (response) or the CDP failure reason (failure) — things `performance.getEntriesByType('resource')` can't give. Classified read-only (pass-through under the pending-interrupt gate); defaults to the Agent Window's active tab. Wires bsk-protocol (Method + params/result/entry structs + schema), bsk-cli (network subcommand + daemon dispatch allow-list) and the extension (handler + dispatcher route), with unit / parse / IPC tests paralleling the console command. Refs #2. --- .../src/browser-driver/chromium-cdp.ts | 240 ++++++++++++++++++ .../src/tools/__tests__/network.test.ts | 125 +++++++++ apps/extension/src/tools/dispatcher.ts | 8 + apps/extension/src/tools/network.ts | 105 ++++++++ apps/extension/src/tools/shared.ts | 14 +- apps/extension/src/transport/types.ts | 31 +++ crates/bsk-cli/src/cli/mod.rs | 5 + crates/bsk-cli/src/cli/network.rs | 105 ++++++++ crates/bsk-cli/src/daemon/ipc.rs | 1 + crates/bsk-cli/src/main.rs | 1 + crates/bsk-cli/tests/cli_parse.rs | 35 +++ crates/bsk-cli/tests/tools_ipc.rs | 61 ++++- .../schema/tool_network_entry.json | 94 +++++++ .../schema/tool_network_params.json | 48 ++++ .../schema/tool_network_result.json | 122 +++++++++ crates/bsk-protocol/src/bin/dump-schema.rs | 3 + crates/bsk-protocol/src/method.rs | 11 + crates/bsk-protocol/src/tools/mod.rs | 2 + crates/bsk-protocol/src/tools/network.rs | 148 +++++++++++ 19 files changed, 1156 insertions(+), 3 deletions(-) create mode 100644 apps/extension/src/tools/__tests__/network.test.ts create mode 100644 apps/extension/src/tools/network.ts create mode 100644 crates/bsk-cli/src/cli/network.rs create mode 100644 crates/bsk-protocol/schema/tool_network_entry.json create mode 100644 crates/bsk-protocol/schema/tool_network_params.json create mode 100644 crates/bsk-protocol/schema/tool_network_result.json create mode 100644 crates/bsk-protocol/src/tools/network.rs diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 3d149a7..745fd64 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -27,6 +27,9 @@ import type { ConsoleStackFrame, JavaScriptDialogInfo, JavaScriptDialogType, + NetworkEntry, + NetworkEntryKind, + NetworkResult, } from "@/transport/types"; /** @@ -87,6 +90,9 @@ const MAX_DIALOG_FIELD_LENGTH = 4096; const MAX_CONSOLE_BUFFER = 200; const MAX_CONSOLE_FIELD_LENGTH = 4096; const MAX_CONSOLE_STACK_FRAMES = 20; +const MAX_NETWORK_BUFFER = 200; +const MAX_NETWORK_FIELD_LENGTH = 4096; +const MAX_NETWORK_REQUEST_META = 1024; interface ParsedDialogOpening { type: JavaScriptDialogType; @@ -98,6 +104,15 @@ interface ParsedDialogOpening { interface ParsedConsoleEntry extends Omit {} +interface ParsedNetworkEntry extends Omit {} + +/** Request metadata remembered from `requestWillBeSent`, keyed by requestId. */ +interface NetworkRequestMeta { + url: string; + method?: string; + resourceType?: string; +} + /** * Wrapper around `chrome.debugger` that owns the "attach once per * tabId" cache and exposes typed `send()`. @@ -112,15 +127,21 @@ export class ChromiumCdp { private readonly consoleBuffers = new Map(); private readonly consoleSequences = new Map(); 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 detachSubscription: { dispose(): void } | null = null; private dialogSubscription: { dispose(): void } | null = null; private consoleSubscription: { dispose(): void } | null = null; + private networkSubscription: { dispose(): void } | null = null; constructor(api: CdpDebuggerApi = chromeDebuggerApi) { this.api = api; this.bindAutoDetach(); this.bindDialogHandler(); this.bindConsoleHandler(); + this.bindNetworkHandler(); } /** Attach to `tabId` if we haven't already in this driver. */ @@ -135,6 +156,7 @@ export class ChromiumCdp { await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION); await this.enablePageDomain(tabId); await this.enableConsoleDomains(tabId); + await this.enableNetworkDomains(tabId); this.attachedTabs.add(tabId); })() .catch((err) => { @@ -216,6 +238,44 @@ export class ChromiumCdp { }; } + /** Ensure CDP domains for network capture are enabled for this tab. */ + async ensureNetworkCapture(tabId: number): Promise { + if (!this.attachedTabs.has(tabId)) { + await this.ensureAttached(tabId); + return; + } + await this.enableNetworkDomains(tabId); + } + + /** Network entries observed on `tabId`, bounded for agent context safety. */ + networkEntriesSince( + tabId: number, + since: number | undefined, + limit: number, + 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); + return { + tab_id: tabId, + entries, + next_since: nextSince, + truncated, + }; + } + /** Detach if attached; never throws. */ async detach(tabId: number): Promise { this.attachInFlight.delete(tabId); @@ -223,6 +283,7 @@ export class ChromiumCdp { this.attachedTabs.delete(tabId); this.clearDialogState(tabId); this.clearConsoleState(tabId); + this.clearNetworkState(tabId); try { await this.api.detach({ tabId }); } catch (err) { @@ -265,6 +326,10 @@ export class ChromiumCdp { this.consoleBuffers.clear(); this.consoleSequences.clear(); this.consoleDomainsAttemptedTabs.clear(); + this.networkBuffers.clear(); + this.networkSequences.clear(); + this.networkDomainsAttemptedTabs.clear(); + this.networkRequestMeta.clear(); await Promise.all( tabs.map(async (tabId) => { try { @@ -292,6 +357,16 @@ export class ChromiumCdp { } } + private async enableNetworkDomains(tabId: number): Promise { + if (this.networkDomainsAttemptedTabs.has(tabId)) return; + this.networkDomainsAttemptedTabs.add(tabId); + try { + await this.api.sendCommand({ tabId }, "Network.enable", {}); + } catch (err) { + console.debug("[bsk cdp] network domain enable failed", { tabId, err }); + } + } + private bindDialogHandler(): void { if (this.dialogSubscription) return; const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { @@ -386,6 +461,65 @@ export class ChromiumCdp { this.consoleDomainsAttemptedTabs.delete(tabId); } + private bindNetworkHandler(): void { + if (this.networkSubscription) return; + const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { + const tabId = source.tabId; + if (typeof tabId !== "number") return; + switch (method) { + case "Network.requestWillBeSent": + this.rememberNetworkRequest(params); + break; + case "Network.responseReceived": + this.appendNetwork(tabId, parseNetworkResponse(this.networkRequestMeta, params)); + break; + case "Network.loadingFailed": + this.appendNetwork(tabId, parseNetworkFailure(this.networkRequestMeta, params)); + break; + } + }; + this.api.onEvent.addListener(listener); + this.networkSubscription = { + dispose: () => this.api.onEvent.removeListener(listener), + }; + } + + private appendNetwork(tabId: number, entry: ParsedNetworkEntry | null): void { + 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); + } + + /** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */ + private rememberNetworkRequest(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, + }); + while (this.networkRequestMeta.size > MAX_NETWORK_REQUEST_META) { + const oldest = this.networkRequestMeta.keys().next().value; + if (oldest === undefined) break; + this.networkRequestMeta.delete(oldest); + } + } + + private clearNetworkState(tabId: number): void { + this.networkBuffers.delete(tabId); + this.networkSequences.delete(tabId); + this.networkDomainsAttemptedTabs.delete(tabId); + } + private bindAutoDetach(): void { if (this.detachSubscription) return; const listener = (source: chrome.debugger.Debuggee, _reason: string) => { @@ -395,6 +529,7 @@ export class ChromiumCdp { this.tabOwners.delete(source.tabId); this.clearDialogState(source.tabId); this.clearConsoleState(source.tabId); + this.clearNetworkState(source.tabId); } }; this.api.onDetach.addListener(listener); @@ -411,6 +546,8 @@ export class ChromiumCdp { this.dialogSubscription = null; this.consoleSubscription?.dispose(); this.consoleSubscription = null; + this.networkSubscription?.dispose(); + this.networkSubscription = null; } /** Detach tabs only when no other live session has claimed them. */ @@ -604,6 +741,109 @@ function truncateOptionalConsoleField(value: string | undefined): string | undef return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text; } +function parseNetworkResponse( + meta: Map, + 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; + if (!url) return null; + return makeNetworkEntry({ + kind: "response", + method: info?.method, + url, + status: typeof response.status === "number" ? response.status : undefined, + status_text: typeof response.statusText === "string" ? response.statusText : undefined, + 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, + }); +} + +function parseNetworkFailure( + meta: Map, + 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)", + 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, + }); +} + +function makeNetworkEntry(input: { + kind: NetworkEntryKind; + method?: string; + url: string; + status?: number; + status_text?: string; + mime_type?: string; + resource_type?: string; + error_text?: string; + timestamp?: number; +}): ParsedNetworkEntry { + const projectedUrl = truncateNetworkText(input.url, MAX_NETWORK_FIELD_LENGTH); + const projectedError = + input.error_text !== undefined + ? truncateNetworkText(input.error_text, MAX_NETWORK_FIELD_LENGTH) + : undefined; + return { + kind: input.kind, + method: input.method, + url: projectedUrl.text, + status: input.status, + status_text: truncateOptionalNetworkField(input.status_text), + mime_type: truncateOptionalNetworkField(input.mime_type), + resource_type: input.resource_type, + error_text: projectedError?.text, + timestamp: input.timestamp, + truncated: projectedUrl.truncated || (projectedError?.truncated ?? false), + }; +} + +function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry { + const projectedUrl = truncateNetworkText(entry.url, maxTextChars); + const projectedError = + entry.error_text !== undefined + ? truncateNetworkText(entry.error_text, maxTextChars) + : undefined; + return { + sequence: entry.sequence, + kind: entry.kind, + method: entry.method, + 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), + }; +} + +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__/network.test.ts b/apps/extension/src/tools/__tests__/network.test.ts new file mode 100644 index 0000000..bcf6338 --- /dev/null +++ b/apps/extension/src/tools/__tests__/network.test.ts @@ -0,0 +1,125 @@ +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"; + +function fakeAgentWindow(ids: number[]) { + let i = 0; + return { + create: vi.fn(async () => { + const id = ids[i++]; + if (id === undefined) throw new Error("ran out of fake ids"); + return id; + }), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }; +} + +function makeDeps( + opts: { + get?: (tabId: number) => Promise; + query?: (query: chrome.tabs.QueryInfo) => Promise; + result?: NetworkResult; + } = {}, +) { + const result = + opts.result ?? + ({ + tab_id: 7, + entries: [], + next_since: 0, + truncated: false, + } satisfies NetworkResult); + const ensureNetworkCapture = vi.fn(async () => {}); + const networkEntriesSince = vi.fn(() => result); + const cdp = { + send: vi.fn(), + ensureNetworkCapture, + networkEntriesSince, + } as unknown as CdpRunner; + const tabsApi = { + get: + opts.get ?? + vi.fn( + async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab, + ), + query: + opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]), + }; + return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince }; +} + +describe("handleNetwork", () => { + it("reads the Agent Window active tab with safe defaults", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const deps = makeDeps({ + result: { + tab_id: 7, + entries: [ + { sequence: 3, kind: "response", url: "https://x/api", status: 404, truncated: false }, + ], + next_since: 3, + truncated: false, + }, + }); + + const res = await handleNetwork(sm, { session_id: "aa11" }, deps); + + if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + expect(res.entries[0].status).toBe(404); + expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(7); + expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000); + }); + + it("reads an explicit tab and forwards bounded options", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const deps = makeDeps({ + get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab), + }); + + await handleNetwork( + sm, + { + session_id: "aa11", + tab_id: 9, + since: 12, + limit: 500, + max_text_chars: 9999, + }, + deps, + ); + + expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9); + expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096); + }); + + 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 handleNetwork(sm, { session_id: "aa11", limit: 0 }, deps); + + expect(res).toMatchObject({ code: "invalid_params", message: /limit/ }); + expect(deps.ensureNetworkCapture).not.toHaveBeenCalled(); + expect(deps.networkEntriesSince).not.toHaveBeenCalled(); + }); + + it("hides other sessions' Agent Window tabs", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) }); + await sm.start("aa11"); + await sm.start("bb22"); + const deps = makeDeps({ + get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab), + }); + + const res = await handleNetwork(sm, { session_id: "aa11", tab_id: 9 }, deps); + + expect(res).toMatchObject({ code: "not_found" }); + expect(deps.ensureNetworkCapture).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index ca4c053..a22b767 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -10,6 +10,7 @@ import type { NavigateBackParams, NavigateForwardParams, NavigateParams, + NetworkParams, PressParams, ProtocolFrame, ReloadParams, @@ -33,6 +34,7 @@ import { handleNavigateForward, handleReload, } from "./navigation"; +import { handleNetwork } from "./network"; import { type CdpRunner, chromeTabsCaptureApi, @@ -285,6 +287,12 @@ export class ToolDispatcher { req.params as ConsoleParams, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, ); + case "tool.network": + return handleNetwork( + this.sessions, + req.params as NetworkParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, + ); case "tool.snapshot": return handleSnapshot( this.sessions, diff --git a/apps/extension/src/tools/network.ts b/apps/extension/src/tools/network.ts new file mode 100644 index 0000000..4c72ebd --- /dev/null +++ b/apps/extension/src/tools/network.ts @@ -0,0 +1,105 @@ +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, + 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 NetworkDeps { + cdp: CdpRunner; + tabsApi: ChromeTabsApi; +} + +function defaultNetworkDeps(): NetworkDeps { + return { + cdp: new ChromiumCdp(), + tabsApi: chromeTabsApi, + }; +} + +export async function handleNetwork( + manager: SessionManager, + params: NetworkParams, + deps: NetworkDeps = defaultNetworkDeps(), +): Promise { + const ctxOrErr = lookupSession(manager, params, "network"); + if (isRpcError(ctxOrErr)) return ctxOrErr; + const bounds = parseNetworkBounds(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); + return deps.cdp.networkEntriesSince( + target.tabId, + bounds.since, + bounds.limit, + bounds.maxTextChars, + ); + } catch (err) { + return { + code: "cdp_failed", + message: err instanceof Error ? err.message : String(err), + }; + } +} + +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 d9eb661..f8dc093 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -9,7 +9,12 @@ 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, RpcError } from "@/transport/types"; +import type { + ConsoleResult, + JavaScriptDialogInfo, + NetworkResult, + RpcError, +} from "@/transport/types"; import { rpcError } from "./errors"; /** @@ -53,6 +58,13 @@ export interface CdpRunner { maxTextChars: number, includeStack: boolean, ): ConsoleResult; + ensureNetworkCapture?(tabId: number): Promise; + networkEntriesSince?( + tabId: number, + since: number | undefined, + limit: number, + maxTextChars: number, + ): NetworkResult; } /** diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index a3f12db..efdf36c 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -172,6 +172,37 @@ export interface ConsoleResult { truncated: boolean; } +export type NetworkEntryKind = "response" | "failure"; + +export interface NetworkEntry { + sequence: number; + kind: NetworkEntryKind; + method?: string; + url: string; + status?: number; + status_text?: string; + mime_type?: string; + resource_type?: string; + error_text?: string; + timestamp?: number; + truncated: boolean; +} + +export interface NetworkParams { + session_id: string; + tab_id?: number; + since?: number; + limit?: number; + max_text_chars?: number; +} + +export interface NetworkResult { + tab_id: number; + entries: NetworkEntry[]; + next_since: number; + truncated: boolean; +} + export type TabScopeFilter = "user" | "agent" | "all"; export interface TabInfo { diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 85b1ffb..1f23c23 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -18,6 +18,7 @@ pub mod install_skill; pub mod interaction; pub mod logs; pub mod navigate; +pub mod network; pub mod render_error; pub mod screenshot; pub mod session; @@ -36,6 +37,7 @@ use crate::cli::human_loop::RequestHelpArgs; use crate::cli::install_skill::InstallSkillArgs; use crate::cli::interaction::{ClickArgs, FillArgs, PressArgs, SelectArgs}; use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs}; +use crate::cli::network::NetworkArgs; use crate::cli::screenshot::ScreenshotArgs; use crate::cli::session::SessionCmd; use crate::cli::snapshot::SnapshotArgs; @@ -115,6 +117,9 @@ pub enum Command { /// Read buffered console/log/exception messages. Console(ConsoleArgs), + /// Read buffered network responses / failures. + Network(NetworkArgs), + /// Dump raw HTML for a tab or a snapshot ref. #[command(name = "get-html")] GetHtml(GetHtmlArgs), diff --git a/crates/bsk-cli/src/cli/network.rs b/crates/bsk-cli/src/cli/network.rs new file mode 100644 index 0000000..c0c84ac --- /dev/null +++ b/crates/bsk-cli/src/cli/network.rs @@ -0,0 +1,105 @@ +//! `bsk network` — read buffered page network responses / failures. + +use std::path::PathBuf; + +use anyhow::Context; +use bsk_protocol::Method; +use bsk_protocol::tools::{NetworkEntry, NetworkParams, NetworkResult}; +use clap::Args; + +use crate::cli::TOOL_IPC_TIMEOUT; +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +#[derive(Debug, Clone, Args)] +pub struct NetworkArgs { + /// Session id (must be active). + #[arg(long)] + pub session: String, + + /// Target tab. Defaults to the Agent Window's active tab. + #[arg(long = "tab-id")] + pub tab_id: Option, + + /// Return entries with sequence greater than this cursor. + #[arg(long)] + pub since: Option, + + /// Maximum number of entries to return. Defaults to 50; extension caps at 200. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: Option, + + /// Maximum characters per URL / error text. Defaults to 1000; extension caps at 4096. + #[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))] + pub max_text_chars: Option, +} + +pub fn dispatch(args: NetworkArgs, format: Format) -> Result<(), CliError> { + let info = ensure_daemon().context("ensure daemon is running")?; + run(info.sock_path, args, format) +} + +fn run(sock: PathBuf, args: NetworkArgs, format: Format) -> Result<(), CliError> { + let params = NetworkParams { + session_id: args.session, + tab_id: args.tab_id, + since: args.since, + limit: args.limit, + max_text_chars: args.max_text_chars, + }; + let reply: NetworkResult = call(sock, params)?; + render(&reply, format) +} + +fn call(sock: PathBuf, params: NetworkParams) -> Result { + crate::cli::business_rpc::call::( + sock, + "network", + Method::ToolNetwork, + Some(params), + TOOL_IPC_TIMEOUT, + ) +} + +fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> { + match format { + Format::Json => { + let json = serde_json::to_string_pretty(reply) + .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; + println!("{json}"); + } + Format::Human => { + if reply.entries.is_empty() { + println!("(no network activity captured)"); + } else { + for entry in &reply.entries { + println!("{}", render_entry(entry)); + } + } + if reply.truncated { + eprintln!( + "warning: network output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.", + reply.next_since + ); + } + } + } + Ok(()) +} + +fn render_entry(entry: &NetworkEntry) -> String { + let method = entry.method.as_deref().unwrap_or("?"); + 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) + } + 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) + } + } +} diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index 9c66e56..654d96c 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -229,6 +229,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolTabReturn | Method::ToolScreenshot | Method::ToolConsole + | Method::ToolNetwork | Method::ToolSnapshot | Method::ToolGetHtml | Method::ToolNavigate diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index c5f461a..a7dce3d 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -78,6 +78,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Screenshot(args) => cli::screenshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format), Command::Console(args) => cli::console::dispatch(args, format), + Command::Network(args) => cli::network::dispatch(args, format), Command::GetHtml(args) => cli::get_html::dispatch(args, format), Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format), Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format), diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 1111607..09780eb 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -104,6 +104,41 @@ fn rejects_zero_console_bounds() { ); } +#[test] +fn parses_network_command_with_context_safety_flags() { + let cli = parse(&[ + "bsk", + "network", + "--session", + "s1", + "--tab-id", + "9", + "--since", + "12", + "--limit", + "75", + "--max-text-chars", + "2048", + ]); + let Command::Network(args) = cli.command else { + panic!("expected network command"); + }; + assert_eq!(args.session, "s1"); + assert_eq!(args.tab_id, Some(9)); + assert_eq!(args.since, Some(12)); + assert_eq!(args.limit, Some(75)); + assert_eq!(args.max_text_chars, Some(2048)); +} + +#[test] +fn rejects_zero_network_bounds() { + assert!(Cli::try_parse_from(["bsk", "network", "--session", "s1", "--limit", "0"]).is_err()); + assert!( + Cli::try_parse_from(["bsk", "network", "--session", "s1", "--max-text-chars", "0"]) + .is_err() + ); +} + #[test] fn parses_install_skill_subcommand() { let cli = parse(&["bsk", "install-skill", "--list"]); diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index 32b7732..3eb71a2 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -11,8 +11,9 @@ use bsk::ipc_client::IpcClient; use bsk_protocol::system::{HandshakeParams, HandshakeResult}; use bsk_protocol::tools::{ ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult, - ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, - SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope, + NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ScreenshotParams, + ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, SnapshotResult, + TabInfo, TabListParams, TabListResult, TabScope, }; use bsk_protocol::{ BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError, @@ -368,6 +369,62 @@ async fn console_returns_buffered_entries() { handle.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn network_returns_buffered_entries() { + let (handle, sock) = spawn_daemon().await; + let mut ws = connect_ext(handle.ws_addr()).await; + let _ = do_handshake(&mut ws).await; + + run_extension(ws, |req| { + assert_eq!(req.method, Method::ToolNetwork); + let params: NetworkParams = serde_json::from_value(req.params.clone().unwrap()).unwrap(); + assert_eq!(params.tab_id, Some(7)); + assert_eq!(params.since, Some(3)); + assert_eq!(params.limit, Some(50)); + assert_eq!(params.max_text_chars, Some(1000)); + ResponseBody::Ok( + serde_json::to_value(NetworkResult { + tab_id: 7, + entries: vec![NetworkEntry { + sequence: 4, + kind: NetworkEntryKind::Response, + method: Some("GET".into()), + url: "https://example.test/api".into(), + status: Some(404), + status_text: Some("Not Found".into()), + mime_type: Some("application/json".into()), + resource_type: Some("Fetch".into()), + error_text: None, + timestamp: None, + truncated: false, + }], + next_since: 4, + truncated: false, + }) + .unwrap(), + ) + }); + + let session_id = ipc_session_start(&sock).await; + let result: NetworkResult = ipc_tool_call( + &sock, + Method::ToolNetwork, + NetworkParams { + session_id, + tab_id: Some(7), + since: Some(3), + limit: Some(50), + max_text_chars: Some(1000), + }, + ) + .await + .expect("network ok"); + assert_eq!(result.tab_id, 7); + assert_eq!(result.next_since, 4); + assert_eq!(result.entries[0].status, Some(404)); + handle.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_returns_text_and_ref_count() { let (handle, sock) = spawn_daemon().await; diff --git a/crates/bsk-protocol/schema/tool_network_entry.json b/crates/bsk-protocol/schema/tool_network_entry.json new file mode 100644 index 0000000..08ce4d1 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_network_entry.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NetworkEntry", + "type": "object", + "required": [ + "kind", + "sequence", + "url" + ], + "properties": { + "error_text": { + "description": "CDP failure reason (`failure` entries only).", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/NetworkEntryKind" + }, + "method": { + "type": [ + "string", + "null" + ] + }, + "mime_type": { + "type": [ + "string", + "null" + ] + }, + "resource_type": { + "type": [ + "string", + "null" + ] + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "HTTP status code (`response` entries only).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "status_text": { + "type": [ + "string", + "null" + ] + }, + "timestamp": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "truncated": { + "default": false, + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "definitions": { + "NetworkEntryKind": { + "oneOf": [ + { + "description": "A response was received (`Network.responseReceived`).", + "type": "string", + "enum": [ + "response" + ] + }, + { + "description": "The request failed before completing (`Network.loadingFailed`).", + "type": "string", + "enum": [ + "failure" + ] + } + ] + } + } +} diff --git a/crates/bsk-protocol/schema/tool_network_params.json b/crates/bsk-protocol/schema/tool_network_params.json new file mode 100644 index 0000000..2b9fa71 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_network_params.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NetworkParams", + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "limit": { + "description": "Maximum number of entries to return. Extension applies safe defaults and caps.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 1.0 + }, + "max_text_chars": { + "description": "Maximum characters returned per URL / error text. Extension applies safe defaults and caps.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 1.0 + }, + "session_id": { + "type": "string" + }, + "since": { + "description": "Return entries with sequence strictly greater than this cursor.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tab_id": { + "description": "Optional target tab. Defaults to the Agent Window's currently active tab.", + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } +} diff --git a/crates/bsk-protocol/schema/tool_network_result.json b/crates/bsk-protocol/schema/tool_network_result.json new file mode 100644 index 0000000..747ec8e --- /dev/null +++ b/crates/bsk-protocol/schema/tool_network_result.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NetworkResult", + "type": "object", + "required": [ + "next_since", + "tab_id" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/NetworkEntry" + } + }, + "next_since": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "tab_id": { + "type": "integer", + "format": "int64" + }, + "truncated": { + "default": false, + "type": "boolean" + } + }, + "definitions": { + "NetworkEntry": { + "type": "object", + "required": [ + "kind", + "sequence", + "url" + ], + "properties": { + "error_text": { + "description": "CDP failure reason (`failure` entries only).", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/NetworkEntryKind" + }, + "method": { + "type": [ + "string", + "null" + ] + }, + "mime_type": { + "type": [ + "string", + "null" + ] + }, + "resource_type": { + "type": [ + "string", + "null" + ] + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "HTTP status code (`response` entries only).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "status_text": { + "type": [ + "string", + "null" + ] + }, + "timestamp": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "truncated": { + "default": false, + "type": "boolean" + }, + "url": { + "type": "string" + } + } + }, + "NetworkEntryKind": { + "oneOf": [ + { + "description": "A response was received (`Network.responseReceived`).", + "type": "string", + "enum": [ + "response" + ] + }, + { + "description": "The request failed before completing (`Network.loadingFailed`).", + "type": "string", + "enum": [ + "failure" + ] + } + ] + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 29defeb..d20efa3 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -86,6 +86,9 @@ fn main() { dump!(ConsoleResult, "tool_console_result"); dump!(ConsoleEntry, "tool_console_entry"); dump!(ConsoleStackFrame, "tool_console_stack_frame"); + dump!(NetworkParams, "tool_network_params"); + dump!(NetworkResult, "tool_network_result"); + dump!(NetworkEntry, "tool_network_entry"); dump!(EvaluateParams, "tool_evaluate_params"); dump!(EvaluateResult, "tool_evaluate_result"); diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 746149b..e54a66a 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -64,6 +64,8 @@ pub enum Method { ToolScreenshot, #[serde(rename = "tool.console")] ToolConsole, + #[serde(rename = "tool.network")] + ToolNetwork, #[serde(rename = "tool.evaluate")] ToolEvaluate, #[serde(rename = "tool.wait_for_navigation")] @@ -127,6 +129,7 @@ impl Method { | Method::ToolGetHtml | Method::ToolScreenshot | Method::ToolConsole + | Method::ToolNetwork | Method::ToolWaitForNavigation | Method::ToolWaitMs | Method::ToolRequestHelp => false, @@ -169,6 +172,13 @@ mod tests { assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.console")); } + #[test] + fn network_method_round_trips() { + let method: Method = serde_json::from_value(json!("tool.network")).unwrap(); + assert_eq!(method, Method::ToolNetwork); + assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.network")); + } + #[test] fn cancel_params_and_result_round_trip() { let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap(); @@ -187,6 +197,7 @@ mod tests { assert!(!Method::ToolGetHtml.is_mutating()); assert!(!Method::ToolScreenshot.is_mutating()); assert!(!Method::ToolConsole.is_mutating()); + assert!(!Method::ToolNetwork.is_mutating()); assert!(!Method::ToolWaitForNavigation.is_mutating()); assert!(!Method::ToolWaitMs.is_mutating()); } diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 5ec8385..1103cd0 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -5,6 +5,7 @@ pub mod dialog; pub mod human_loop; pub mod interaction; pub mod navigation; +pub mod network; pub mod observation; pub mod script; pub mod session; @@ -16,6 +17,7 @@ pub use dialog::*; pub use human_loop::*; pub use interaction::*; pub use navigation::*; +pub use network::*; pub use observation::*; pub use script::*; pub use session::*; diff --git a/crates/bsk-protocol/src/tools/network.rs b/crates/bsk-protocol/src/tools/network.rs new file mode 100644 index 0000000..9cf556f --- /dev/null +++ b/crates/bsk-protocol/src/tools/network.rs @@ -0,0 +1,148 @@ +//! `tool.network` — read buffered network responses / failures. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct NetworkParams { + pub session_id: String, + /// Optional target tab. Defaults to the Agent Window's currently active tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + /// Return entries with sequence strictly greater than this cursor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since: Option, + /// Maximum number of entries to return. Extension applies safe defaults and caps. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub limit: Option, + /// Maximum characters returned per URL / error text. Extension applies safe defaults and caps. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub max_text_chars: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NetworkEntryKind { + /// A response was received (`Network.responseReceived`). + Response, + /// The request failed before completing (`Network.loadingFailed`). + 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, + /// HTTP status code (`response` entries only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_type: Option, + /// CDP failure reason (`failure` entries only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct NetworkResult { + pub tab_id: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, + pub next_since: u64, + #[serde(default)] + pub truncated: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn network_params_omit_optional_fields() { + let params = NetworkParams { + session_id: "aa11".into(), + tab_id: None, + since: None, + limit: None, + max_text_chars: None, + }; + let value = serde_json::to_value(¶ms).unwrap(); + assert_eq!(value["session_id"], "aa11"); + assert!(value.get("tab_id").is_none()); + assert!(value.get("since").is_none()); + assert!(value.get("limit").is_none()); + assert!(value.get("max_text_chars").is_none()); + let round: NetworkParams = serde_json::from_value(value).unwrap(); + assert_eq!(round, params); + } + + #[test] + fn network_result_round_trips_response_and_failure() { + let result = NetworkResult { + tab_id: 7, + entries: vec![ + NetworkEntry { + sequence: 3, + kind: NetworkEntryKind::Response, + method: Some("GET".into()), + url: "https://example.test/api".into(), + status: Some(404), + status_text: Some("Not Found".into()), + mime_type: Some("application/json".into()), + resource_type: Some("Fetch".into()), + error_text: None, + timestamp: Some(1234.5), + truncated: false, + }, + NetworkEntry { + sequence: 4, + kind: NetworkEntryKind::Failure, + method: Some("GET".into()), + url: "https://example.test/blocked".into(), + status: None, + status_text: None, + mime_type: None, + resource_type: Some("Script".into()), + error_text: Some("net::ERR_BLOCKED_BY_CLIENT".into()), + timestamp: Some(1240.0), + truncated: false, + }, + ], + next_since: 4, + truncated: false, + }; + let value = serde_json::to_value(&result).unwrap(); + 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_eq!( + value["entries"][1]["error_text"], + json!("net::ERR_BLOCKED_BY_CLIENT") + ); + let round: NetworkResult = serde_json::from_value(value).unwrap(); + assert_eq!(round, result); + } +}