mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat: add console observation command
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -119,27 +119,41 @@ describe("ChromiumCdp", () => {
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {});
|
||||
});
|
||||
|
||||
it("enables console capture domains best-effort during attach", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Log.enable") throw new Error("restricted");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await expect(cdp.ensureAttached(12)).resolves.toBeUndefined();
|
||||
expect(cdp.isAttached(12)).toBe(true);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {});
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {});
|
||||
await cdp.ensureConsoleCapture(12);
|
||||
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([, method]) => method === "Runtime.enable" || method === "Log.enable",
|
||||
);
|
||||
expect(enableCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("records javascriptDialogOpening and auto-accepts", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(3);
|
||||
const cursor = cdp.dialogCursor(3);
|
||||
onEvent.fire(
|
||||
{ tabId: 3 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{
|
||||
type: "alert",
|
||||
message: "hello",
|
||||
url: "https://example.com/",
|
||||
hasBrowserHandler: false,
|
||||
},
|
||||
);
|
||||
onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "hello",
|
||||
url: "https://example.com/",
|
||||
hasBrowserHandler: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(api.sendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 3 },
|
||||
"Page.handleJavaScriptDialog",
|
||||
{ accept: true },
|
||||
);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", {
|
||||
accept: true,
|
||||
});
|
||||
const dialogs = cdp.dialogsSince(3, cursor);
|
||||
expect(dialogs).toHaveLength(1);
|
||||
expect(dialogs[0]).toMatchObject({
|
||||
@@ -170,11 +184,11 @@ describe("ChromiumCdp", () => {
|
||||
await cdp.ensureAttached(5);
|
||||
const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" });
|
||||
await Promise.resolve();
|
||||
onEvent.fire(
|
||||
{ tabId: 5 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{ type: "alert", message: "blocked", url: "https://example.com/" },
|
||||
);
|
||||
onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "blocked",
|
||||
url: "https://example.com/",
|
||||
});
|
||||
releaseEvaluate();
|
||||
await expect(pending).resolves.toEqual({ result: { value: 2 } });
|
||||
});
|
||||
@@ -183,17 +197,145 @@ describe("ChromiumCdp", () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(11);
|
||||
onEvent.fire(
|
||||
{ tabId: 11 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{ type: "alert", message: "x", url: "https://example.com/" },
|
||||
);
|
||||
onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "x",
|
||||
url: "https://example.com/",
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
|
||||
await cdp.detach(11);
|
||||
expect(cdp.dialogsSince(11, 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records Runtime console calls with bounded text and optional stack frames", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(21);
|
||||
onEvent.fire({ tabId: 21 }, "Runtime.consoleAPICalled", {
|
||||
type: "warn",
|
||||
args: [{ value: "abcdefghi" }, { description: "Object { ok: true }" }],
|
||||
timestamp: 1234.5,
|
||||
stackTrace: {
|
||||
callFrames: [
|
||||
{
|
||||
functionName: "render",
|
||||
url: "https://example.test/app.js",
|
||||
lineNumber: 4,
|
||||
columnNumber: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const withoutStack = cdp.consoleEntriesSince(21, 0, 50, 5, false);
|
||||
expect(withoutStack).toMatchObject({
|
||||
next_since: 1,
|
||||
truncated: true,
|
||||
entries: [
|
||||
{
|
||||
sequence: 1,
|
||||
kind: "console",
|
||||
level: "warn",
|
||||
text: "abcde",
|
||||
url: "https://example.test/app.js",
|
||||
line: 5,
|
||||
column: 9,
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(withoutStack.entries[0].stack_trace).toBeUndefined();
|
||||
|
||||
const withStack = cdp.consoleEntriesSince(21, 0, 50, 1000, true);
|
||||
expect(withStack.entries[0].stack_trace).toEqual([
|
||||
{
|
||||
function_name: "render",
|
||||
url: "https://example.test/app.js",
|
||||
line: 5,
|
||||
column: 9,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records exceptions and engine log entries", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(22);
|
||||
onEvent.fire({ tabId: 22 }, "Runtime.exceptionThrown", {
|
||||
timestamp: 2000,
|
||||
exceptionDetails: {
|
||||
text: "Uncaught",
|
||||
url: "https://example.test/app.js",
|
||||
lineNumber: 40,
|
||||
columnNumber: 6,
|
||||
exception: { description: "TypeError: boom\n at render (app.js:41:7)" },
|
||||
},
|
||||
});
|
||||
onEvent.fire({ tabId: 22 }, "Log.entryAdded", {
|
||||
entry: {
|
||||
level: "error",
|
||||
text: "Failed to load resource: 404",
|
||||
url: "https://example.test/missing.png",
|
||||
lineNumber: 0,
|
||||
timestamp: 2100,
|
||||
},
|
||||
});
|
||||
|
||||
const result = cdp.consoleEntriesSince(22, 0, 50, 1000, false);
|
||||
expect(result.next_since).toBe(2);
|
||||
expect(result.entries).toMatchObject([
|
||||
{
|
||||
sequence: 1,
|
||||
kind: "exception",
|
||||
level: "error",
|
||||
text: "TypeError: boom",
|
||||
url: "https://example.test/app.js",
|
||||
line: 41,
|
||||
column: 7,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
kind: "log",
|
||||
level: "error",
|
||||
text: "Failed to load resource: 404",
|
||||
url: "https://example.test/missing.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters console entries by cursor and caps result size", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(23);
|
||||
for (let i = 1; i <= 3; i += 1) {
|
||||
onEvent.fire({ tabId: 23 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: `message ${i}` }],
|
||||
});
|
||||
}
|
||||
|
||||
const result = cdp.consoleEntriesSince(23, 1, 1, 1000, false);
|
||||
expect(result).toMatchObject({
|
||||
next_since: 2,
|
||||
truncated: true,
|
||||
entries: [{ sequence: 2, text: "message 2" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears console state on detach", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(24);
|
||||
onEvent.fire({ tabId: 24 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: "x" }],
|
||||
});
|
||||
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(1);
|
||||
await cdp.detach(24);
|
||||
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detachSession only detaches tabs no other session owns", async () => {
|
||||
const { api } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
@@ -20,7 +20,14 @@
|
||||
// record the payload for tool results, and auto-accept so automation
|
||||
// can continue.
|
||||
|
||||
import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/types";
|
||||
import type {
|
||||
ConsoleEntry,
|
||||
ConsoleEntryKind,
|
||||
ConsoleResult,
|
||||
ConsoleStackFrame,
|
||||
JavaScriptDialogInfo,
|
||||
JavaScriptDialogType,
|
||||
} from "@/transport/types";
|
||||
|
||||
/**
|
||||
* Minimal slice of `chrome.debugger` the rest of the extension
|
||||
@@ -77,6 +84,9 @@ export type DialogCursor = number;
|
||||
|
||||
const MAX_DIALOG_BUFFER = 32;
|
||||
const MAX_DIALOG_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_BUFFER = 200;
|
||||
const MAX_CONSOLE_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_STACK_FRAMES = 20;
|
||||
|
||||
interface ParsedDialogOpening {
|
||||
type: JavaScriptDialogType;
|
||||
@@ -86,6 +96,8 @@ interface ParsedDialogOpening {
|
||||
hasBrowserHandler?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
|
||||
|
||||
/**
|
||||
* Wrapper around `chrome.debugger` that owns the "attach once per
|
||||
* tabId" cache and exposes typed `send<T>()`.
|
||||
@@ -97,13 +109,18 @@ export class ChromiumCdp {
|
||||
private readonly tabOwners = new Map<number, Set<string>>();
|
||||
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
||||
private readonly dialogSequences = new Map<number, number>();
|
||||
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
|
||||
private readonly consoleSequences = new Map<number, number>();
|
||||
private readonly consoleDomainsAttemptedTabs = new Set<number>();
|
||||
private detachSubscription: { dispose(): void } | null = null;
|
||||
private dialogSubscription: { dispose(): void } | null = null;
|
||||
private consoleSubscription: { dispose(): void } | null = null;
|
||||
|
||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
||||
this.api = api;
|
||||
this.bindAutoDetach();
|
||||
this.bindDialogHandler();
|
||||
this.bindConsoleHandler();
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
@@ -117,6 +134,7 @@ export class ChromiumCdp {
|
||||
const attach = (async () => {
|
||||
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
|
||||
await this.enablePageDomain(tabId);
|
||||
await this.enableConsoleDomains(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
})()
|
||||
.catch((err) => {
|
||||
@@ -159,12 +177,52 @@ export class ChromiumCdp {
|
||||
return buf.filter((entry) => entry.sequence > cursor);
|
||||
}
|
||||
|
||||
/** Ensure CDP domains for console capture are enabled for this tab. */
|
||||
async ensureConsoleCapture(tabId: number): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId)) {
|
||||
await this.ensureAttached(tabId);
|
||||
return;
|
||||
}
|
||||
await this.enableConsoleDomains(tabId);
|
||||
}
|
||||
|
||||
/** Console entries observed on `tabId`, bounded for agent context safety. */
|
||||
consoleEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
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);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
next_since: nextSince,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Detach if attached; never throws. */
|
||||
async detach(tabId: number): Promise<void> {
|
||||
this.attachInFlight.delete(tabId);
|
||||
if (!this.attachedTabs.has(tabId)) return;
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
@@ -204,6 +262,9 @@ export class ChromiumCdp {
|
||||
this.attachedTabs.clear();
|
||||
this.dialogBuffers.clear();
|
||||
this.dialogSequences.clear();
|
||||
this.consoleBuffers.clear();
|
||||
this.consoleSequences.clear();
|
||||
this.consoleDomainsAttemptedTabs.clear();
|
||||
await Promise.all(
|
||||
tabs.map(async (tabId) => {
|
||||
try {
|
||||
@@ -219,6 +280,18 @@ export class ChromiumCdp {
|
||||
await this.api.sendCommand({ tabId }, "Page.enable", {});
|
||||
}
|
||||
|
||||
private async enableConsoleDomains(tabId: number): Promise<void> {
|
||||
if (this.consoleDomainsAttemptedTabs.has(tabId)) return;
|
||||
this.consoleDomainsAttemptedTabs.add(tabId);
|
||||
for (const method of ["Runtime.enable", "Log.enable"]) {
|
||||
try {
|
||||
await this.api.sendCommand({ tabId }, method, {});
|
||||
} catch (err) {
|
||||
console.debug("[bsk cdp] console domain enable failed", { tabId, method, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bindDialogHandler(): void {
|
||||
if (this.dialogSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
@@ -272,6 +345,47 @@ export class ChromiumCdp {
|
||||
this.dialogSequences.delete(tabId);
|
||||
}
|
||||
|
||||
private bindConsoleHandler(): void {
|
||||
if (this.consoleSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
switch (method) {
|
||||
case "Runtime.consoleAPICalled":
|
||||
this.appendConsole(tabId, parseConsoleApiCalled(params));
|
||||
break;
|
||||
case "Runtime.exceptionThrown":
|
||||
this.appendConsole(tabId, parseExceptionThrown(params));
|
||||
break;
|
||||
case "Log.entryAdded":
|
||||
this.appendConsole(tabId, parseLogEntry(params));
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.consoleSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private appendConsole(tabId: number, entry: ParsedConsoleEntry | null): void {
|
||||
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);
|
||||
}
|
||||
|
||||
private clearConsoleState(tabId: number): void {
|
||||
this.consoleBuffers.delete(tabId);
|
||||
this.consoleSequences.delete(tabId);
|
||||
this.consoleDomainsAttemptedTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindAutoDetach(): void {
|
||||
if (this.detachSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
@@ -280,6 +394,7 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.delete(source.tabId);
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
this.clearConsoleState(source.tabId);
|
||||
}
|
||||
};
|
||||
this.api.onDetach.addListener(listener);
|
||||
@@ -294,6 +409,8 @@ export class ChromiumCdp {
|
||||
this.detachSubscription = null;
|
||||
this.dialogSubscription?.dispose();
|
||||
this.dialogSubscription = null;
|
||||
this.consoleSubscription?.dispose();
|
||||
this.consoleSubscription = null;
|
||||
}
|
||||
|
||||
/** Detach tabs only when no other live session has claimed them. */
|
||||
@@ -339,6 +456,154 @@ function truncateDialogField(value: string): string {
|
||||
return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`;
|
||||
}
|
||||
|
||||
function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const args = Array.isArray(raw.args) ? raw.args : [];
|
||||
const text = args.map(remoteObjectToText).filter(Boolean).join(" ");
|
||||
const stackTrace = parseStackTrace(raw.stackTrace);
|
||||
const top = stackTrace[0];
|
||||
return makeConsoleEntry({
|
||||
kind: "console",
|
||||
level: typeof raw.type === "string" ? raw.type : "log",
|
||||
text,
|
||||
url: top?.url,
|
||||
line: top?.line,
|
||||
column: top?.column,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
stack_trace: stackTrace,
|
||||
});
|
||||
}
|
||||
|
||||
function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const details = (raw.exceptionDetails ?? {}) as Record<string, unknown>;
|
||||
const exception = (details.exception ?? {}) as Record<string, unknown>;
|
||||
const text =
|
||||
firstLine(typeof exception.description === "string" ? exception.description : undefined) ||
|
||||
firstLine(typeof details.text === "string" ? details.text : undefined) ||
|
||||
"Uncaught (unknown error)";
|
||||
return makeConsoleEntry({
|
||||
kind: "exception",
|
||||
level: "error",
|
||||
text,
|
||||
url: typeof details.url === "string" ? details.url : undefined,
|
||||
line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined,
|
||||
column: typeof details.columnNumber === "number" ? details.columnNumber + 1 : undefined,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
stack_trace: parseStackTrace(details.stackTrace),
|
||||
});
|
||||
}
|
||||
|
||||
function parseLogEntry(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const entry = (raw.entry ?? {}) as Record<string, unknown>;
|
||||
return makeConsoleEntry({
|
||||
kind: "log",
|
||||
level: typeof entry.level === "string" ? entry.level : "info",
|
||||
text: typeof entry.text === "string" ? entry.text : "",
|
||||
url: typeof entry.url === "string" ? entry.url : undefined,
|
||||
line: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
|
||||
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined,
|
||||
stack_trace: [],
|
||||
});
|
||||
}
|
||||
|
||||
function makeConsoleEntry(input: {
|
||||
kind: ConsoleEntryKind;
|
||||
level: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
timestamp?: number;
|
||||
stack_trace: ConsoleStackFrame[];
|
||||
}): ParsedConsoleEntry {
|
||||
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
|
||||
return {
|
||||
kind: input.kind,
|
||||
level: input.level,
|
||||
text: projected.text,
|
||||
url: truncateOptionalConsoleField(input.url),
|
||||
line: input.line,
|
||||
column: input.column,
|
||||
timestamp: input.timestamp,
|
||||
stack_trace: input.stack_trace.slice(0, MAX_CONSOLE_STACK_FRAMES).map((frame) => ({
|
||||
function_name: truncateOptionalConsoleField(frame.function_name),
|
||||
url: truncateOptionalConsoleField(frame.url),
|
||||
line: frame.line,
|
||||
column: frame.column,
|
||||
})),
|
||||
truncated: projected.truncated || input.stack_trace.length > MAX_CONSOLE_STACK_FRAMES,
|
||||
};
|
||||
}
|
||||
|
||||
function projectConsoleEntry(
|
||||
entry: ConsoleEntry,
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleEntry {
|
||||
const projected = truncateConsoleText(entry.text, maxTextChars);
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
level: entry.level,
|
||||
text: projected.text,
|
||||
url: entry.url,
|
||||
line: entry.line,
|
||||
column: entry.column,
|
||||
timestamp: entry.timestamp,
|
||||
...(includeStack && entry.stack_trace && entry.stack_trace.length > 0
|
||||
? { stack_trace: entry.stack_trace }
|
||||
: {}),
|
||||
truncated: entry.truncated || projected.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteObjectToText(value: unknown): string {
|
||||
const raw = (value ?? {}) as Record<string, unknown>;
|
||||
if ("value" in raw && raw.value !== undefined) return String(raw.value);
|
||||
if (typeof raw.description === "string") return raw.description;
|
||||
if (typeof raw.unserializableValue === "string") return raw.unserializableValue;
|
||||
if (typeof raw.type === "string") return `[${raw.type}]`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseStackTrace(value: unknown): ConsoleStackFrame[] {
|
||||
const raw = (value ?? {}) as Record<string, unknown>;
|
||||
const frames = Array.isArray(raw.callFrames) ? raw.callFrames : [];
|
||||
return frames
|
||||
.filter(
|
||||
(frame): frame is Record<string, unknown> => frame !== null && typeof frame === "object",
|
||||
)
|
||||
.map((frame) => ({
|
||||
function_name:
|
||||
typeof frame.functionName === "string" && frame.functionName.length > 0
|
||||
? frame.functionName
|
||||
: undefined,
|
||||
url: typeof frame.url === "string" && frame.url.length > 0 ? frame.url : undefined,
|
||||
line: typeof frame.lineNumber === "number" ? frame.lineNumber + 1 : undefined,
|
||||
column: typeof frame.columnNumber === "number" ? frame.columnNumber + 1 : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function firstLine(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return value.split(/\r?\n/, 1)[0] || undefined;
|
||||
}
|
||||
|
||||
function truncateConsoleText(
|
||||
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 truncateOptionalConsoleField(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown): Error {
|
||||
if (err instanceof Error) return err;
|
||||
if (typeof err === "string") return new Error(err);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
import type { ConsoleResult } from "@/transport/types";
|
||||
import { handleConsole } from "../console";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
opts: {
|
||||
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
|
||||
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
|
||||
result?: ConsoleResult;
|
||||
} = {},
|
||||
) {
|
||||
const result =
|
||||
opts.result ??
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
} satisfies ConsoleResult);
|
||||
const ensureConsoleCapture = vi.fn(async () => {});
|
||||
const consoleEntriesSince = vi.fn(() => result);
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
ensureConsoleCapture,
|
||||
consoleEntriesSince,
|
||||
} as unknown as CdpRunner;
|
||||
const tabsApi = {
|
||||
get:
|
||||
opts.get ??
|
||||
vi.fn(
|
||||
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query:
|
||||
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||
};
|
||||
return { cdp, tabsApi, ensureConsoleCapture, consoleEntriesSince };
|
||||
}
|
||||
|
||||
describe("handleConsole", () => {
|
||||
it("reads the Agent Window active tab with safe defaults", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [{ sequence: 3, kind: "console", level: "log", text: "hello", truncated: false }],
|
||||
next_since: 3,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleConsole(sm, { session_id: "aa11" }, deps);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.entries[0].text).toBe("hello");
|
||||
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(7);
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
|
||||
});
|
||||
|
||||
it("reads an explicit tab and forwards bounded options", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
await handleConsole(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
tab_id: 9,
|
||||
since: 12,
|
||||
limit: 500,
|
||||
max_text_chars: 9999,
|
||||
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();
|
||||
});
|
||||
|
||||
it("hides other sessions' Agent Window tabs", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
|
||||
await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
const res = await handleConsole(sm, { session_id: "aa11", tab_id: 9 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "not_found" });
|
||||
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
|
||||
import type { ConnectionState, ProtocolFrame, RequestFrame } from "@/transport/types";
|
||||
import type {
|
||||
ConnectionState,
|
||||
ConsoleResult,
|
||||
ProtocolFrame,
|
||||
RequestFrame,
|
||||
} from "@/transport/types";
|
||||
import { ToolDispatcher } from "../dispatcher";
|
||||
|
||||
function fakeTransport() {
|
||||
@@ -36,6 +41,10 @@ function makeRequest(method: string, params: unknown): RequestFrame {
|
||||
}
|
||||
|
||||
describe("ToolDispatcher", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -78,6 +87,55 @@ describe("ToolDispatcher", () => {
|
||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||
});
|
||||
|
||||
it("routes tool.console through the CDP console buffer", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
|
||||
},
|
||||
});
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await sessions.start("aa11");
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureConsoleCapture: vi.fn(async () => {}),
|
||||
consoleEntriesSince: vi.fn(
|
||||
() =>
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [
|
||||
{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false },
|
||||
],
|
||||
next_since: 1,
|
||||
truncated: false,
|
||||
}) satisfies ConsoleResult,
|
||||
),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.console", { session_id: "aa11" }));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(cdp.ensureConsoleCapture).toHaveBeenCalledWith(7);
|
||||
expect(sent[0]).toEqual({
|
||||
id: "r-1",
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }],
|
||||
next_since: 1,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches CDP state before stopping a session", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { ConsoleParams, ConsoleResult, RpcError } from "@/transport/types";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
chromeTabsApi,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
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;
|
||||
}
|
||||
|
||||
function defaultConsoleDeps(): ConsoleDeps {
|
||||
return {
|
||||
cdp: new ChromiumCdp(),
|
||||
tabsApi: chromeTabsApi,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleConsole(
|
||||
manager: SessionManager,
|
||||
params: ConsoleParams,
|
||||
deps: ConsoleDeps = defaultConsoleDeps(),
|
||||
): Promise<ConsoleResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "console");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const bounds = parseConsoleBounds(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.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
|
||||
return { code: "cdp_failed", message: "console capture requires CDP console support" };
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.cdp.ensureConsoleCapture(target.tabId);
|
||||
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
||||
return deps.cdp.consoleEntriesSince(
|
||||
target.tabId,
|
||||
bounds.since,
|
||||
bounds.limit,
|
||||
bounds.maxTextChars,
|
||||
bounds.includeStack,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import type {
|
||||
ClickParams,
|
||||
ConsoleParams,
|
||||
EvaluateParams,
|
||||
FillParams,
|
||||
GetHtmlParams,
|
||||
@@ -11,17 +13,19 @@ import type {
|
||||
PressParams,
|
||||
ProtocolFrame,
|
||||
ReloadParams,
|
||||
RequestHelpParams,
|
||||
RequestFrame,
|
||||
RequestHelpParams,
|
||||
ResponseFrame,
|
||||
RpcError,
|
||||
SelectParams,
|
||||
ScreenshotParams,
|
||||
SelectParams,
|
||||
SnapshotParams,
|
||||
WaitForNavigationParams,
|
||||
} from "@/transport/types";
|
||||
import { isRequestFrame } from "@/transport/types";
|
||||
import { handleConsole } from "./console";
|
||||
import { handleEvaluate } from "./evaluate";
|
||||
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
|
||||
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
|
||||
import {
|
||||
handleNavigate,
|
||||
@@ -42,16 +46,15 @@ import {
|
||||
type SessionStartParams,
|
||||
type SessionStopParams,
|
||||
} from "./session";
|
||||
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
|
||||
import { chromeTabsApi } from "./shared";
|
||||
import {
|
||||
type BorrowConfirmationApprover,
|
||||
handleTabBorrow,
|
||||
handleTabClose,
|
||||
handleTabCreate,
|
||||
handleTabList,
|
||||
handleTabReturn,
|
||||
handleTabSelect,
|
||||
type BorrowConfirmationApprover,
|
||||
type TabBorrowParams,
|
||||
type TabCloseParams,
|
||||
type TabCreateParams,
|
||||
@@ -59,7 +62,6 @@ import {
|
||||
type TabReturnParams,
|
||||
type TabSelectParams,
|
||||
} from "./tabs";
|
||||
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
|
||||
import { handleWaitForNavigation } from "./waits";
|
||||
|
||||
export interface DispatcherDeps {
|
||||
@@ -277,6 +279,12 @@ export class ToolDispatcher {
|
||||
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
|
||||
: undefined,
|
||||
);
|
||||
case "tool.console":
|
||||
return handleConsole(
|
||||
this.sessions,
|
||||
req.params as ConsoleParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.snapshot":
|
||||
return handleSnapshot(
|
||||
this.sessions,
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
// exactly the same sandbox + visibility rules as the M6 observation
|
||||
// handlers (review parity).
|
||||
|
||||
import { normaliseRef } from "@/session-manager/ref-store";
|
||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import type { JavaScriptDialogInfo } from "@/transport/types";
|
||||
import type { DialogCursor } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||
import { normaliseRef } from "@/session-manager/ref-store";
|
||||
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
|
||||
/**
|
||||
@@ -46,6 +45,14 @@ export interface CdpRunner {
|
||||
};
|
||||
dialogCursor?(tabId: number): DialogCursor;
|
||||
dialogsSince?(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[];
|
||||
ensureConsoleCapture?(tabId: number): Promise<void>;
|
||||
consoleEntriesSince?(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleResult;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,6 +134,44 @@ export interface JavaScriptDialogInfo {
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export type ConsoleEntryKind = "console" | "exception" | "log";
|
||||
|
||||
export interface ConsoleStackFrame {
|
||||
function_name?: string;
|
||||
url?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface ConsoleEntry {
|
||||
sequence: number;
|
||||
kind: ConsoleEntryKind;
|
||||
level: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
timestamp?: number;
|
||||
stack_trace?: ConsoleStackFrame[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
since?: number;
|
||||
limit?: number;
|
||||
max_text_chars?: number;
|
||||
include_stack?: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleResult {
|
||||
tab_id: number;
|
||||
entries: ConsoleEntry[];
|
||||
next_since: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type TabScopeFilter = "user" | "agent" | "all";
|
||||
|
||||
export interface TabInfo {
|
||||
|
||||
Reference in New Issue
Block a user