From 8b744af929f026ef271a34479f7e91a2adefb5e3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 6 May 2026 10:28:40 +0200 Subject: [PATCH 1/2] Auto-select a sole discovered target --- packages/agent-cdp/src/__tests__/cli.test.ts | 141 ++++++++++++++++++- packages/agent-cdp/src/cli.ts | 83 ++++++++--- 2 files changed, 205 insertions(+), 19 deletions(-) diff --git a/packages/agent-cdp/src/__tests__/cli.test.ts b/packages/agent-cdp/src/__tests__/cli.test.ts index 02b0fc5..4fd1c13 100644 --- a/packages/agent-cdp/src/__tests__/cli.test.ts +++ b/packages/agent-cdp/src/__tests__/cli.test.ts @@ -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", () => { @@ -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 => { + 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 => { + 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 => { + 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: {} }); + }); }); diff --git a/packages/agent-cdp/src/cli.ts b/packages/agent-cdp/src/cli.ts index e46d52b..12a8466 100644 --- a/packages/agent-cdp/src/cli.ts +++ b/packages/agent-cdp/src/cli.ts @@ -253,6 +253,53 @@ function discoveryOptionsFromFlags(flags: Record): Dis }; } +export const MULTIPLE_TARGETS_AVAILABLE_MESSAGE = + "Multiple targets available. Run 'agent-cdp target list' and 'agent-cdp target select '."; + +interface TargetSelectionDeps { + ensureDaemon: typeof ensureDaemon; + sendCommand: typeof sendCommand; +} + +export async function ensureTargetSelected( + deps: TargetSelectionDeps = { ensureDaemon, sendCommand }, +): Promise { + 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 { const { command, flags } = parseArgs(process.argv.slice(2)); const cmd = command[0]; @@ -351,7 +398,7 @@ export async function main(): Promise { } 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) { @@ -367,7 +414,7 @@ export async function main(): Promise { if (Number.isNaN(id)) { throw new Error("Usage: agent-cdp console get "); } - 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"); @@ -387,7 +434,7 @@ export async function main(): Promise { 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}`); @@ -414,7 +461,7 @@ export async function main(): Promise { 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[0], verbose)); @@ -433,7 +480,7 @@ export async function main(): Promise { 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, @@ -457,7 +504,7 @@ export async function main(): Promise { 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[0], verbose)); @@ -469,7 +516,7 @@ export async function main(): Promise { 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[0])); @@ -481,7 +528,7 @@ export async function main(): Promise { 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[0])); @@ -493,7 +540,7 @@ export async function main(): Promise { 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[0])); @@ -505,7 +552,7 @@ export async function main(): Promise { 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[0])); @@ -513,7 +560,7 @@ export async function main(): Promise { } 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"); @@ -538,7 +585,7 @@ export async function main(): Promise { 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"); @@ -551,7 +598,7 @@ export async function main(): Promise { 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)); @@ -697,7 +744,7 @@ export async function main(): Promise { 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[0], verbose)); @@ -758,7 +805,7 @@ export async function main(): Promise { 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, @@ -860,7 +907,7 @@ export async function main(): Promise { 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"); @@ -971,7 +1018,7 @@ export async function main(): Promise { 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"); @@ -979,7 +1026,7 @@ export async function main(): Promise { } 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}`); From 83626286016c121d10ad5a1890ff6da9642fe25d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 6 May 2026 12:35:07 +0200 Subject: [PATCH 2/2] fix: clarify no-target message --- packages/agent-cdp/src/__tests__/daemon.test.ts | 4 +++- packages/agent-cdp/src/daemon.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-cdp/src/__tests__/daemon.test.ts b/packages/agent-cdp/src/__tests__/daemon.test.ts index 4ed1791..8fe8256 100644 --- a/packages/agent-cdp/src/__tests__/daemon.test.ts +++ b/packages/agent-cdp/src/__tests__/daemon.test.ts @@ -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 `.", + ); }); it("explains when a target exists but is disconnected", () => { diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index bd8e1f4..db8ee82 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -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 `."; } return `Target ${selectedTarget.id} is not connected. Reconnect the app and try again.`;