Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp";
function fakeChromeEvent<TArgs extends unknown[]>() {
const listeners = new Set<(...args: TArgs) => void>();
return {
listeners,
addListener: vi.fn((cb: (...args: TArgs) => void) => listeners.add(cb)),
removeListener: vi.fn((cb: (...args: TArgs) => void) => listeners.delete(cb)),
fire: (...args: TArgs) => {
for (const cb of listeners) cb(...args);
},
};
}
function fakeApi() {
const onEvent = fakeChromeEvent<[chrome.debugger.Debuggee, string, unknown]>();
const onDetach = fakeChromeEvent<[chrome.debugger.Debuggee, string]>();
const api: CdpDebuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async () => ({ ok: true })),
// biome-ignore lint/suspicious/noExplicitAny: minimal chrome.events.Event shim
onEvent: onEvent as any,
// biome-ignore lint/suspicious/noExplicitAny: minimal chrome.events.Event shim
onDetach: onDetach as any,
};
return { api, onEvent, onDetach };
}
describe("ChromiumCdp", () => {
it("coalesces concurrent attach calls for the same tab", async () => {
const { api } = fakeApi();
let releaseAttach!: () => void;
const attachGate = new Promise<void>((resolve) => {
releaseAttach = resolve;
});
(api.attach as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => attachGate);
const cdp = new ChromiumCdp(api);
const first = cdp.ensureAttached(7);
const second = cdp.ensureAttached(7);
await Promise.resolve();
expect(api.attach).toHaveBeenCalledTimes(1);
releaseAttach();
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
expect(cdp.isAttached(7)).toBe(true);
});
it("attaches lazily on first send if not attached yet", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
const out = await cdp.send<{ ok: boolean }>(9, "DOM.getDocument");
expect(out).toEqual({ ok: true });
expect(api.attach).toHaveBeenCalledTimes(1);
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "DOM.getDocument", {});
});
it("propagates attach failures as thrown Error", async () => {
const { api } = fakeApi();
(api.attach as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Another debugger is already attached"),
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(5)).rejects.toThrow(/already attached/);
expect(cdp.isAttached(5)).toBe(false);
});
it("send() rejects on chrome.runtime.lastError-style failures", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "DOM.getDocument") {
throw "frame got detached";
}
return { ok: true };
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.send(1, "DOM.getDocument")).rejects.toThrow("frame got detached");
});
it("auto-clears the attached cache on detach event", async () => {
const { api, onDetach } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(11);
expect(cdp.isAttached(11)).toBe(true);
onDetach.fire({ tabId: 11 }, "target_closed");
expect(cdp.isAttached(11)).toBe(false);
});
it("detach() is idempotent and never throws", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.detach(99); // not attached
expect(api.detach).not.toHaveBeenCalled();
await cdp.ensureAttached(7);
(api.detach as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("tab closed"));
await expect(cdp.detach(7)).resolves.toBeUndefined();
expect(cdp.isAttached(7)).toBe(false);
});
it("detachAll() iterates every cached tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
await cdp.detachAll();
expect(api.detach).toHaveBeenCalledTimes(2);
expect(cdp.isAttached(1)).toBe(false);
expect(cdp.isAttached(2)).toBe(false);
});
it("attach enables Page domain on first send", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.send(9, "DOM.getDocument");
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {});
});
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,
},
);
await Promise.resolve();
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 3 },
"Page.handleJavaScriptDialog",
{ accept: true },
);
const dialogs = cdp.dialogsSince(3, cursor);
expect(dialogs).toHaveLength(1);
expect(dialogs[0]).toMatchObject({
tab_id: 3,
type: "alert",
message: "hello",
handled: "accepted",
sequence: 1,
});
});
it("unblocks a pending send after dialog is handled", async () => {
const { api, onEvent } = fakeApi();
let releaseEvaluate!: () => void;
const evaluateGate = new Promise<void>((resolve) => {
releaseEvaluate = resolve;
});
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Runtime.evaluate") {
await evaluateGate;
return { result: { value: 2 } };
}
return {};
},
);
const cdp = new ChromiumCdp(api);
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/" },
);
releaseEvaluate();
await expect(pending).resolves.toEqual({ result: { value: 2 } });
});
it("clears dialog state on detach", async () => {
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/" },
);
await Promise.resolve();
expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
await cdp.detach(11);
expect(cdp.dialogsSince(11, 0)).toHaveLength(0);
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
cdp.trackSessionTab("aa11", 2);
await cdp.detachSession("aa11");
expect(api.detach).toHaveBeenCalledTimes(1);
expect(api.detach).toHaveBeenCalledWith({ tabId: 2 });
expect(cdp.isAttached(1)).toBe(true);
expect(cdp.isAttached(2)).toBe(false);
await cdp.detachSession("bb22");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 1 });
expect(cdp.isAttached(1)).toBe(false);
});
});
@@ -0,0 +1,349 @@
// Chromium `BrowserDriver` implementation. Wraps `chrome.debugger.*`
// so each tool implementation can think in terms of typed `send<T>`
// calls and a single attach-once-per-tab cache.
//
// Lifecycle notes:
// * `chrome.debugger.attach()` will fail with "Another debugger is
// already attached" if invoked twice for the same tab — we track
// attachments in `attachedTabs` to coalesce. The CDP protocol
// version pinned here ("1.3") matches what intern uses and what
// Playwright targets for stable Chrome / Edge / Brave.
// * Closing a tab implicitly detaches; we do not race-clean here.
// Higher-level code calls `detach()` explicitly when a session
// stops to keep the "Agent controlling — DevTools" infobar visible
// only while we actually need it.
// * MV3 service workers can be evicted mid-call. The wrapper
// propagates `chrome.runtime.lastError` as a thrown Error so
// callers can decide whether to retry vs. surface an `cdp_failed`.
// * Native JS dialogs (`alert` / `confirm` / `prompt` / `beforeunload`)
// block CDP until dismissed. We listen for `Page.javascriptDialogOpening`,
// record the payload for tool results, and auto-accept so automation
// can continue.
import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/types";
/**
* Minimal slice of `chrome.debugger` the rest of the extension
* depends on. Stays as an explicit interface so vitest can inject a
* fake without monkey-patching the real `chrome` global.
*/
export interface CdpDebuggerApi {
attach(target: chrome.debugger.Debuggee, requiredVersion: string): Promise<void>;
detach(target: chrome.debugger.Debuggee): Promise<void>;
sendCommand(
target: chrome.debugger.Debuggee,
method: string,
commandParams?: object,
): Promise<unknown>;
/**
* Fires for every CDP event (`Page.lifecycleEvent`, `DOM.documentUpdated`,
* …). The first callback argument is the source debuggee; the second
* is the CDP method name; the third is the payload.
*/
onEvent: chrome.events.Event<
(source: chrome.debugger.Debuggee, method: string, params: unknown) => void
>;
/**
* Fires when Chrome unilaterally detaches us — most commonly because
* the tab navigated to a chrome:// URL or the user clicked
* "Cancel debugging" on the system infobar.
*/
onDetach: chrome.events.Event<(source: chrome.debugger.Debuggee, reason: string) => void>;
}
/**
* Production-backed [`CdpDebuggerApi`]. `onEvent` / `onDetach` are
* exposed as getters so the *module* loads in vitest (where `chrome`
* is undefined) — the actual property access only fires when a real
* caller wires the driver in `background.ts`.
*/
export const chromeDebuggerApi: CdpDebuggerApi = {
attach: (target, version) => chrome.debugger.attach(target, version),
detach: (target) => chrome.debugger.detach(target),
sendCommand: (target, method, commandParams) =>
chrome.debugger.sendCommand(target, method, commandParams),
get onEvent() {
return chrome.debugger.onEvent;
},
get onDetach() {
return chrome.debugger.onDetach;
},
};
export const CDP_PROTOCOL_VERSION = "1.3";
/** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */
export type DialogCursor = number;
const MAX_DIALOG_BUFFER = 32;
const MAX_DIALOG_FIELD_LENGTH = 4096;
interface ParsedDialogOpening {
type: JavaScriptDialogType;
message: string;
url?: string;
defaultPrompt?: string;
hasBrowserHandler?: boolean;
}
/**
* Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`.
*/
export class ChromiumCdp {
private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>();
private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
}
/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
if (this.attachedTabs.has(tabId)) return;
const existing = this.attachInFlight.get(tabId);
if (existing) {
await existing;
return;
}
const attach = (async () => {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId);
this.attachedTabs.add(tabId);
})()
.catch((err) => {
// Chrome surfaces "Another debugger is already attached" when
// (rare) the user opened DevTools on the same tab. Don't swallow
// — let the caller decide how to surface it.
throw normalizeError(err);
})
.finally(() => {
this.attachInFlight.delete(tabId);
});
this.attachInFlight.set(tabId, attach);
await attach;
}
/**
* Send a CDP command and decode the result as `T`. Throws on any
* `chrome.runtime.lastError`.
*/
async send<T = unknown>(tabId: number, method: string, params?: object): Promise<T> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
}
try {
const result = await this.api.sendCommand({ tabId }, method, params ?? {});
return result as T;
} catch (err) {
throw normalizeError(err);
}
}
/** Return a cursor marking the current dialog sequence for `tabId`. */
dialogCursor(tabId: number): DialogCursor {
return this.dialogSequences.get(tabId) ?? 0;
}
/** Dialogs observed on `tabId` with sequence strictly greater than `cursor`. */
dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] {
const buf = this.dialogBuffers.get(tabId) ?? [];
return buf.filter((entry) => entry.sequence > cursor);
}
/** 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);
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
}
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
isAttached(tabId: number): boolean {
return this.attachedTabs.has(tabId);
}
/** Remember that `sessionId` used CDP on `tabId` so stop can detach it. */
trackSessionTab(sessionId: string, tabId: number): void {
const owners = this.tabOwners.get(tabId) ?? new Set<string>();
owners.add(sessionId);
this.tabOwners.set(tabId, owners);
}
/** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): {
dispose(): void;
} {
this.api.onEvent.addListener(handler);
return {
dispose: () => this.api.onEvent.removeListener(handler),
};
}
/** Best-effort detach of every cached tab. Used on session.stop. */
async detachAll(): Promise<void> {
const tabs = Array.from(this.attachedTabs);
this.attachInFlight.clear();
this.tabOwners.clear();
this.attachedTabs.clear();
this.dialogBuffers.clear();
this.dialogSequences.clear();
await Promise.all(
tabs.map(async (tabId) => {
try {
await this.api.detach({ tabId });
} catch (err) {
console.debug("[bsk cdp] detachAll: tab already gone", { tabId, err });
}
}),
);
}
private async enablePageDomain(tabId: number): Promise<void> {
await this.api.sendCommand({ tabId }, "Page.enable", {});
}
private bindDialogHandler(): void {
if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
if (method !== "Page.javascriptDialogOpening") return;
const tabId = source.tabId;
if (typeof tabId !== "number") return;
void this.onJavaScriptDialogOpening(tabId, params);
};
this.api.onEvent.addListener(listener);
this.dialogSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
const parsed = parseDialogOpeningParams(params);
try {
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
if (parsed.type === "prompt") {
handleParams.promptText = parsed.defaultPrompt ?? "";
}
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
this.dialogSequences.set(tabId, sequence);
this.appendDialog(tabId, {
tab_id: tabId,
type: parsed.type,
message: parsed.message,
url: parsed.url,
default_prompt: parsed.defaultPrompt,
has_browser_handler: parsed.hasBrowserHandler,
handled: "accepted",
sequence,
});
} catch (err) {
console.debug("[bsk cdp] Page.handleJavaScriptDialog failed", { tabId, err });
}
}
private appendDialog(tabId: number, entry: JavaScriptDialogInfo): void {
const buf = this.dialogBuffers.get(tabId) ?? [];
buf.push(entry);
while (buf.length > MAX_DIALOG_BUFFER) {
buf.shift();
}
this.dialogBuffers.set(tabId, buf);
}
private clearDialogState(tabId: number): void {
this.dialogBuffers.delete(tabId);
this.dialogSequences.delete(tabId);
}
private bindAutoDetach(): void {
if (this.detachSubscription) return;
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
if (typeof source.tabId === "number") {
this.attachedTabs.delete(source.tabId);
this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId);
}
};
this.api.onDetach.addListener(listener);
this.detachSubscription = {
dispose: () => this.api.onDetach.removeListener(listener),
};
}
/** Remove internal Chrome event listeners; tests and SW teardown call this. */
dispose(): void {
this.detachSubscription?.dispose();
this.detachSubscription = null;
this.dialogSubscription?.dispose();
this.dialogSubscription = null;
}
/** Detach tabs only when no other live session has claimed them. */
async detachSession(sessionId: string): Promise<void> {
const tabsToDetach: number[] = [];
for (const [tabId, owners] of this.tabOwners) {
owners.delete(sessionId);
if (owners.size === 0) {
this.tabOwners.delete(tabId);
tabsToDetach.push(tabId);
}
}
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
}
}
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
const raw = (params ?? {}) as Record<string, unknown>;
const type = normalizeDialogType(raw.type);
const message = truncateDialogField(typeof raw.message === "string" ? raw.message : "");
const url = typeof raw.url === "string" ? truncateDialogField(raw.url) : undefined;
const defaultPrompt =
typeof raw.defaultPrompt === "string" ? truncateDialogField(raw.defaultPrompt) : undefined;
const hasBrowserHandler =
typeof raw.hasBrowserHandler === "boolean" ? raw.hasBrowserHandler : undefined;
return { type, message, url, defaultPrompt, hasBrowserHandler };
}
function normalizeDialogType(value: unknown): JavaScriptDialogType {
switch (value) {
case "alert":
case "confirm":
case "prompt":
case "beforeunload":
return value;
default:
return "alert";
}
}
function truncateDialogField(value: string): string {
if (value.length <= MAX_DIALOG_FIELD_LENGTH) return value;
return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`;
}
function normalizeError(err: unknown): Error {
if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err);
if (err && typeof err === "object" && "message" in err) {
return new Error(String((err as { message: unknown }).message));
}
return new Error("unknown chrome.debugger error");
}