mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix: address network command review feedback
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<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 () => {
|
||||
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);
|
||||
|
||||
@@ -106,11 +106,12 @@ interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
|
||||
|
||||
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
|
||||
|
||||
/** 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<number>();
|
||||
private readonly networkBuffers = new Map<number, NetworkEntry[]>();
|
||||
private readonly networkSequences = new Map<number, number>();
|
||||
private readonly networkDomainsAttemptedTabs = new Set<number>();
|
||||
private readonly networkRequestMeta = new Map<string, NetworkRequestMeta>();
|
||||
private readonly networkDomainsEnabledTabs = new Set<number>();
|
||||
private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.networkDomainsAttemptedTabs.has(tabId)) return;
|
||||
this.networkDomainsAttemptedTabs.add(tabId);
|
||||
private async enableNetworkDomain(tabId: number): Promise<void> {
|
||||
if (this.networkDomainsEnabledTabs.has(tabId)) return;
|
||||
await this.api.sendCommand({ tabId }, "Network.enable", {});
|
||||
this.networkDomainsEnabledTabs.add(tabId);
|
||||
}
|
||||
|
||||
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
|
||||
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<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const request = (raw.request ?? {}) as Record<string, unknown>;
|
||||
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<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) {
|
||||
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<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 {
|
||||
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<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 {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
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<string, NetworkRequestMeta>,
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
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 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<string, NetworkRequestMeta>,
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
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({
|
||||
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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<chrome.tabs.Tab>;
|
||||
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ConsoleResult | RpcError> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
export interface DispatcherDeps {
|
||||
transport: Transport;
|
||||
sessions: SessionManager;
|
||||
cdp?: CdpRunner & {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
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<void>;
|
||||
};
|
||||
private readonly cdp?: DispatcherCdpRunner;
|
||||
private readonly onSessionsChanged?: () => void;
|
||||
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
||||
|
||||
@@ -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<void>;
|
||||
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<NetworkResult | RpcError> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// HTTP status code (`response` entries only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<u32>,
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user