Skip to content
Merged
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
141 changes: 140 additions & 1 deletion packages/agent-cdp/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs, usage } from "../cli.js";
import { MULTIPLE_TARGETS_AVAILABLE_MESSAGE, ensureTargetSelected, parseArgs, usage } from "../cli.js";
import type { IpcCommand, IpcResponse, StatusInfo, TargetDescriptor } from "../types.js";

describe("cli", () => {
it("parses command arguments", () => {
Expand Down Expand Up @@ -26,4 +27,142 @@ describe("cli", () => {
expect(usage()).toContain("js-allocation start");
expect(usage()).toContain("js-allocation-timeline start");
});

it("auto-selects the only discovered target", async () => {
const target: TargetDescriptor = {
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1",
rawId: "page-1",
title: "Example",
kind: "chrome",
description: "Test page",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1",
sourceUrl: "http://127.0.0.1:9222",
};
const commands: IpcCommand[] = [];
const ensureDaemonMock = vi.fn().mockResolvedValue(undefined);
const sendCommandMock = vi.fn(async (command: IpcCommand): Promise<IpcResponse> => {
commands.push(command);
if (command.type === "status") {
return {
ok: true,
data: {
daemonRunning: true,
uptime: 123,
selectedTarget: null,
providerCount: 2,
sessionState: "disconnected",
tracingActive: false,
} satisfies StatusInfo,
};
}

if (command.type === "list-targets") {
return { ok: true, data: [target] };
}

if (command.type === "select-target") {
return { ok: true, data: target };
}

throw new Error(`Unexpected command: ${command.type}`);
});

await ensureTargetSelected({ ensureDaemon: ensureDaemonMock, sendCommand: sendCommandMock });

expect(ensureDaemonMock).toHaveBeenCalledTimes(1);
expect(commands).toEqual([
{ type: "status" },
{ type: "list-targets", options: {} },
{ type: "select-target", targetId: target.id, options: {} },
]);
});

it("skips auto-selection when a target is already selected", async () => {
const ensureDaemonMock = vi.fn().mockResolvedValue(undefined);
const sendCommandMock = vi.fn(async (command: IpcCommand): Promise<IpcResponse> => {
if (command.type !== "status") {
throw new Error(`Unexpected command: ${command.type}`);
}

return {
ok: true,
data: {
daemonRunning: true,
uptime: 123,
selectedTarget: {
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1",
rawId: "page-1",
title: "Example",
kind: "chrome",
description: "Test page",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1",
sourceUrl: "http://127.0.0.1:9222",
},
providerCount: 2,
sessionState: "connected",
tracingActive: false,
} satisfies StatusInfo,
};
});

await ensureTargetSelected({ ensureDaemon: ensureDaemonMock, sendCommand: sendCommandMock });

expect(ensureDaemonMock).toHaveBeenCalledTimes(1);
expect(sendCommandMock).toHaveBeenCalledTimes(1);
expect(sendCommandMock).toHaveBeenCalledWith({ type: "status" });
});

it("fails with the manual-selection message when multiple targets are available", async () => {
const ensureDaemonMock = vi.fn().mockResolvedValue(undefined);
const sendCommandMock = vi.fn(async (command: IpcCommand): Promise<IpcResponse> => {
if (command.type === "status") {
return {
ok: true,
data: {
daemonRunning: true,
uptime: 123,
selectedTarget: null,
providerCount: 2,
sessionState: "disconnected",
tracingActive: false,
} satisfies StatusInfo,
};
}

if (command.type === "list-targets") {
return {
ok: true,
data: [
{
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1",
rawId: "page-1",
title: "Example 1",
kind: "chrome",
description: "Test page",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1",
sourceUrl: "http://127.0.0.1:9222",
},
{
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-2",
rawId: "page-2",
title: "Example 2",
kind: "chrome",
description: "Test page",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/2",
sourceUrl: "http://127.0.0.1:9222",
},
] satisfies TargetDescriptor[],
};
}

throw new Error(`Unexpected command: ${command.type}`);
});

await expect(ensureTargetSelected({ ensureDaemon: ensureDaemonMock, sendCommand: sendCommandMock })).rejects.toThrow(
MULTIPLE_TARGETS_AVAILABLE_MESSAGE,
);
expect(sendCommandMock).toHaveBeenCalledTimes(2);
expect(sendCommandMock).toHaveBeenNthCalledWith(1, { type: "status" });
expect(sendCommandMock).toHaveBeenNthCalledWith(2, { type: "list-targets", options: {} });
});
});
4 changes: 3 additions & 1 deletion packages/agent-cdp/src/__tests__/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ describe("shouldReattachConsoleCollector", () => {

describe("getConnectionErrorMessage", () => {
it("explains when no target has been selected", () => {
expect(getConnectionErrorMessage(null)).toBe("No target selected. Use `target select` first.");
expect(getConnectionErrorMessage(null)).toBe(
"No target available. Use `target list` to find one, then `target select <id>`.",
);
});

it("explains when a target exists but is disconnected", () => {
Expand Down
83 changes: 65 additions & 18 deletions packages/agent-cdp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,53 @@ function discoveryOptionsFromFlags(flags: Record<string, string | boolean>): Dis
};
}

export const MULTIPLE_TARGETS_AVAILABLE_MESSAGE =
"Multiple targets available. Run 'agent-cdp target list' and 'agent-cdp target select <id>'.";

interface TargetSelectionDeps {
ensureDaemon: typeof ensureDaemon;
sendCommand: typeof sendCommand;
}

export async function ensureTargetSelected(
deps: TargetSelectionDeps = { ensureDaemon, sendCommand },
): Promise<void> {
await deps.ensureDaemon();

const statusResponse = await deps.sendCommand({ type: "status" });
if (!statusResponse.ok) {
throw new Error(statusResponse.error || "Failed to load daemon status");
}

if (readStatusInfo(statusResponse.data).selectedTarget) {
return;
}

const targetsResponse = await deps.sendCommand({ type: "list-targets", options: {} });
if (!targetsResponse.ok) {
throw new Error(targetsResponse.error || "Failed to list targets");
}

const targets = readTargets(targetsResponse.data);
if (targets.length === 0) {
return;
}

if (targets.length > 1) {
throw new Error(MULTIPLE_TARGETS_AVAILABLE_MESSAGE);
}

const target = targets[0];
const selectResponse = await deps.sendCommand({
type: "select-target",
targetId: target.id,
options: {},
});
if (!selectResponse.ok) {
throw new Error(selectResponse.error || "Failed to auto-select target");
}
}

export async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
const cmd = command[0];
Expand Down Expand Up @@ -351,7 +398,7 @@ export async function main(): Promise<void> {
}

if (cmd === "console" && command[1] === "list") {
await ensureDaemon();
await ensureTargetSelected();
const limit = typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined;
const response = await sendCommand({ type: "list-console-messages", limit });
if (!response.ok) {
Expand All @@ -367,7 +414,7 @@ export async function main(): Promise<void> {
if (Number.isNaN(id)) {
throw new Error("Usage: agent-cdp console get <id>");
}
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "get-console-message", id });
if (!response.ok) {
throw new Error(response.error || "Failed to get console message");
Expand All @@ -387,7 +434,7 @@ export async function main(): Promise<void> {
if (cmd === "network" && command[1] === "start") {
const name = typeof flags.name === "string" ? flags.name : undefined;
const preserveAcrossNavigation = flags["preserve-across-navigation"] === true;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-start", name, preserveAcrossNavigation });
if (!response.ok) throw new Error(response.error || "Failed to start network session");
console.log(`Network session started. Session ID: ${response.data as string}`);
Expand All @@ -414,7 +461,7 @@ export async function main(): Promise<void> {

if (cmd === "network" && command[1] === "summary") {
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-summary", sessionId });
if (!response.ok) throw new Error(response.error || "Failed to summarize network requests");
console.log(formatNetworkSummary(response.data as Parameters<typeof formatNetworkSummary>[0], verbose));
Expand All @@ -433,7 +480,7 @@ export async function main(): Promise<void> {
const maxMs = typeof flags["max-ms"] === "string" ? Number.parseFloat(flags["max-ms"]) : undefined;
const minBytes = typeof flags["min-bytes"] === "string" ? Number.parseInt(flags["min-bytes"], 10) : undefined;
const maxBytes = typeof flags["max-bytes"] === "string" ? Number.parseInt(flags["max-bytes"], 10) : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({
type: "network-list",
sessionId,
Expand All @@ -457,7 +504,7 @@ export async function main(): Promise<void> {
const requestId = typeof flags.id === "string" ? flags.id : undefined;
if (!requestId) throw new Error("Usage: agent-cdp network request --id REQ_ID [--session ID]");
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-request", requestId, sessionId });
if (!response.ok) throw new Error(response.error || "Failed to get network request");
console.log(formatNetworkRequest(response.data as Parameters<typeof formatNetworkRequest>[0], verbose));
Expand All @@ -469,7 +516,7 @@ export async function main(): Promise<void> {
if (!requestId) throw new Error("Usage: agent-cdp network request-headers --id REQ_ID [--session ID] [--name TEXT]");
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
const name = typeof flags.name === "string" ? flags.name : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-request-headers", requestId, sessionId, name });
if (!response.ok) throw new Error(response.error || "Failed to get request headers");
console.log(formatNetworkHeaders(response.data as Parameters<typeof formatNetworkHeaders>[0]));
Expand All @@ -481,7 +528,7 @@ export async function main(): Promise<void> {
if (!requestId) throw new Error("Usage: agent-cdp network response-headers --id REQ_ID [--session ID] [--name TEXT]");
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
const name = typeof flags.name === "string" ? flags.name : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-response-headers", requestId, sessionId, name });
if (!response.ok) throw new Error(response.error || "Failed to get response headers");
console.log(formatNetworkHeaders(response.data as Parameters<typeof formatNetworkHeaders>[0]));
Expand All @@ -493,7 +540,7 @@ export async function main(): Promise<void> {
if (!requestId) throw new Error("Usage: agent-cdp network request-body --id REQ_ID [--session ID] [--file PATH]");
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
const filePath = typeof flags.file === "string" ? flags.file : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-request-body", requestId, sessionId, filePath });
if (!response.ok) throw new Error(response.error || "Failed to get request body");
console.log(formatNetworkBody(response.data as Parameters<typeof formatNetworkBody>[0]));
Expand All @@ -505,15 +552,15 @@ export async function main(): Promise<void> {
if (!requestId) throw new Error("Usage: agent-cdp network response-body --id REQ_ID [--session ID] [--file PATH]");
const sessionId = typeof flags.session === "string" ? flags.session : undefined;
const filePath = typeof flags.file === "string" ? flags.file : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "network-response-body", requestId, sessionId, filePath });
if (!response.ok) throw new Error(response.error || "Failed to get response body");
console.log(formatNetworkBody(response.data as Parameters<typeof formatNetworkBody>[0]));
return;
}

if (cmd === "trace" && command[1] === "start") {
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "start-trace" });
if (!response.ok) {
throw new Error(response.error || "Failed to start trace");
Expand All @@ -538,7 +585,7 @@ export async function main(): Promise<void> {
if (!filePath) {
throw new Error("Usage: agent-cdp memory capture --file PATH");
}
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "capture-memory", filePath });
if (!response.ok) {
throw new Error(response.error || "Failed to capture heap snapshot");
Expand All @@ -551,7 +598,7 @@ export async function main(): Promise<void> {
const name = typeof flags.name === "string" ? flags.name : undefined;
const collectGarbage = flags.gc === true;
const filePath = typeof flags.file === "string" ? flags.file : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "mem-snapshot-capture", name, collectGarbage, filePath });
if (!response.ok) throw new Error(response.error || "Failed to capture heap snapshot");
console.log(formatMemSnapshotMeta(response.data as MemSnapshotMeta, verbose));
Expand Down Expand Up @@ -697,7 +744,7 @@ export async function main(): Promise<void> {
if (cmd === "js-memory" && command[1] === "sample") {
const label = typeof flags.label === "string" ? flags.label : undefined;
const collectGarbage = flags.gc === true;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "js-memory-sample", label, collectGarbage });
if (!response.ok) throw new Error(response.error || "Failed to capture heap usage sample");
console.log(formatJsMemorySample(response.data as Parameters<typeof formatJsMemorySample>[0], verbose));
Expand Down Expand Up @@ -758,7 +805,7 @@ export async function main(): Promise<void> {
const stackDepth = typeof flags["stack-depth"] === "string" ? Number.parseInt(flags["stack-depth"], 10) : undefined;
const includeObjectsCollectedByMajorGC = flags["include-major-gc"] === true;
const includeObjectsCollectedByMinorGC = flags["include-minor-gc"] === true;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({
type: "js-allocation-start",
name,
Expand Down Expand Up @@ -860,7 +907,7 @@ export async function main(): Promise<void> {

if (cmd === "js-allocation-timeline" && command[1] === "start") {
const name = typeof flags.name === "string" ? flags.name : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "js-allocation-timeline-start", name });
if (!response.ok) throw new Error(response.error || "Failed to start JS allocation timeline session");
console.log("JS allocation timeline session started");
Expand Down Expand Up @@ -971,15 +1018,15 @@ export async function main(): Promise<void> {
const name = typeof flags.name === "string" ? flags.name : undefined;
const samplingIntervalUs =
typeof flags.interval === "string" ? Number.parseInt(flags.interval, 10) : undefined;
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "js-profile-start", name, samplingIntervalUs });
if (!response.ok) throw new Error(response.error || "Failed to start JS profile");
console.log("JS profile started");
return;
}

if (cmd === "js-profile" && command[1] === "stop") {
await ensureDaemon();
await ensureTargetSelected();
const response = await sendCommand({ type: "js-profile-stop" });
if (!response.ok) throw new Error(response.error || "Failed to stop JS profile");
console.log(`JS profile stopped. Session ID: ${response.data as string}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-cdp/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function shouldReattachConsoleCollector(

export function getConnectionErrorMessage(selectedTarget: { id: string } | null): string {
if (!selectedTarget) {
return "No target selected. Use `target select` first.";
return "No target available. Use `target list` to find one, then `target select <id>`.";
}

return `Target ${selectedTarget.id} is not connected. Reconnect the app and try again.`;
Expand Down
Loading