Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/browser-runtime/src/automation/cdp-commander.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CdpCommander, rejectPendingCommands } from "./cdp-commander";

describe("CdpCommander", () => {
let commandCallback: ((result?: unknown) => void) | undefined;

beforeEach(() => {
vi.useFakeTimers();
commandCallback = undefined;
global.chrome = {
debugger: {
sendCommand: vi.fn((_target, _command, _params, callback) => {
commandCallback = callback as (result?: unknown) => void;
}),
},
runtime: { lastError: undefined },
} as unknown as typeof chrome;
});

it("rejects a pending CDP command when its signal is aborted", async () => {
const controller = new AbortController();
const pending = new CdpCommander(1, controller.signal).sendCommand(
"Accessibility.getFullAXTree",
{},
);
controller.abort(new Error("cancel CDP"));

await expect(pending).rejects.toThrow("cancel CDP");
expect(() => commandCallback?.({ nodes: [] })).not.toThrow();
});

it("rejects commands when debugger cleanup starts", async () => {
const pending = new CdpCommander(2).sendCommand("DOM.enable", {});
rejectPendingCommands(2, "Debugger detaching");

await expect(pending).rejects.toThrow(
"CDP command 'DOM.enable' aborted: Debugger detaching",
);
});

it("cleans up a command after its own timeout", async () => {
const pending = new CdpCommander(3).sendCommand("DOM.getDocument", {}, 50);
const assertion = expect(pending).rejects.toThrow("timed out after 50ms");
await vi.advanceTimersByTimeAsync(50);
await assertion;

expect(() => rejectPendingCommands(3, "late cleanup")).not.toThrow();
});
});
65 changes: 44 additions & 21 deletions packages/browser-runtime/src/automation/cdp-commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,58 @@ const DEFAULT_CDP_TIMEOUT = 10000;

const pendingCommands = new Map<
number,
Set<{ reject: (error: Error) => void; command: string }>
Set<{ abort: (error: Error) => void; command: string }>
>();

export function rejectPendingCommands(tabId: number, reason: string): void {
const pending = pendingCommands.get(tabId);
if (pending) {
for (const { reject, command } of pending) {
reject(new Error(`CDP command '${command}' aborted: ${reason}`));
for (const { abort, command } of [...pending]) {
abort(new Error(`CDP command '${command}' aborted: ${reason}`));
}
pending.clear();
pendingCommands.delete(tabId);
}
}

export class CdpCommander {
constructor(readonly tabId: number) {}
constructor(
readonly tabId: number,
private readonly signal?: AbortSignal,
) {}

async sendCommand<T = unknown>(
command: string,
params: Record<string, unknown>,
timeout: number = DEFAULT_CDP_TIMEOUT,
signal: AbortSignal | undefined = this.signal,
): Promise<T> {
return new Promise((resolve, reject) => {
const pendingEntry = { reject, command };

const timeoutId = setTimeout(() => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout>;
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener("abort", handleAbort);
const pending = pendingCommands.get(this.tabId);
if (pending) {
pending.delete(pendingEntry);
}
reject(
pending?.delete(pendingEntry);
if (pending?.size === 0) pendingCommands.delete(this.tabId);
};
const rejectCommand = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const handleAbort = () => {
rejectCommand(
signal?.reason instanceof Error
? signal.reason
: new Error(`CDP command '${command}' aborted`),
);
};
const pendingEntry = { abort: rejectCommand, command };

timeoutId = setTimeout(() => {
rejectCommand(
new Error(`CDP command '${command}' timed out after ${timeout}ms`),
);
}, timeout);
Expand All @@ -47,23 +68,25 @@ export class CdpCommander {
pendingCommands.set(this.tabId, new Set());
}
pendingCommands.get(this.tabId)!.add(pendingEntry);
signal?.addEventListener("abort", handleAbort, { once: true });
if (signal?.aborted) {
handleAbort();
return;
}

chrome.debugger.sendCommand(
{ tabId: this.tabId },
command,
params,
(result) => {
clearTimeout(timeoutId);

const pending = pendingCommands.get(this.tabId);
if (pending) {
pending.delete(pendingEntry);
}

if (chrome.runtime.lastError) {
const lastError = chrome.runtime.lastError;
if (settled) return;
settled = true;
cleanup();
if (lastError) {
reject(
new Error(
`Failed to send CDP command '${command}': ${chrome.runtime.lastError.message}`,
`Failed to send CDP command '${command}': ${lastError.message}`,
),
);
} else {
Expand Down
Loading