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