From 34282bf5e964c75df781ddd6f79ea6b9a2f19637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 18:23:04 +0200 Subject: [PATCH] feat: support agent-cdp remote bridge sessions --- src/__tests__/cli-agent-cdp-session.test.ts | 98 +++++++++++++ src/__tests__/cli-agent-cdp.test.ts | 94 ++++++++++++ src/__tests__/remote-connection.test.ts | 155 ++++++++++++++++++++ src/cli.ts | 19 ++- src/cli/commands/agent-cdp.ts | 68 ++++++++- src/cli/commands/connection-runtime.ts | 14 +- src/cli/commands/react-devtools.ts | 5 +- src/cli/commands/remote-bridge.ts | 5 + src/utils/cli-help.ts | 1 + website/docs/docs/debugging-profiling.md | 9 +- 10 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/cli-agent-cdp-session.test.ts create mode 100644 src/cli/commands/remote-bridge.ts diff --git a/src/__tests__/cli-agent-cdp-session.test.ts b/src/__tests__/cli-agent-cdp-session.test.ts new file mode 100644 index 000000000..114afb5c7 --- /dev/null +++ b/src/__tests__/cli-agent-cdp-session.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; + +vi.mock('../cli/commands/agent-cdp.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAgentCdpCommand: vi.fn(async () => 0), + }; +}); + +import { runCli } from '../cli.ts'; +import { runAgentCdpCommand } from '../cli/commands/agent-cdp.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; +import { hashRemoteConfigFile, writeRemoteConnectionState } from '../remote-connection-state.ts'; +import type { DaemonResponse } from '../daemon-client.ts'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +test('cdp receives active remote connection session and runtime after defaults are merged', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-session-')); + const stateDir = path.join(tempRoot, 'state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test', + platform: 'android', + metroProxyBaseUrl: 'https://bridge.example.test', + metroBearerToken: 'token', + }), + ); + const runtime = { + platform: 'android' as const, + bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle', + }; + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example.test', transport: 'http' }, + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + platform: 'android', + runtime, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const originalExit = process.exit; + let exitCode: number | undefined; + const restoreEnv = installIsolatedCliTestEnv(); + (process as any).exit = ((code?: number) => { + exitCode = code ?? 0; + }) as typeof process.exit; + + const sendToDaemon = async (req: { command: string }): Promise => { + if (req.command === 'lease_heartbeat') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-1', + tenantId: 'tenant-1', + runId: 'run-1', + backend: 'android-instance', + }, + }, + }; + } + return { ok: true, data: {} }; + }; + + try { + await runCli(['--state-dir', stateDir, 'cdp', 'target', 'list'], { sendToDaemon }); + } finally { + restoreEnv(); + process.exit = originalExit; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + assert.equal(exitCode, 0); + assert.equal(vi.mocked(runAgentCdpCommand).mock.calls.length, 1); + assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[0], ['target', 'list']); + assert.equal(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.flags?.session, 'adc-android'); + assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.runtime, runtime); +}); diff --git a/src/__tests__/cli-agent-cdp.test.ts b/src/__tests__/cli-agent-cdp.test.ts index dd2d5f336..c9ff40504 100644 --- a/src/__tests__/cli-agent-cdp.test.ts +++ b/src/__tests__/cli-agent-cdp.test.ts @@ -10,6 +10,7 @@ import { runCmdStreaming } from '../utils/exec.ts'; import { AGENT_CDP_PACKAGE, buildAgentCdpNpmExecArgs, + buildAgentCdpPassthroughArgs, runAgentCdpCommand, } from '../cli/commands/agent-cdp.ts'; @@ -90,3 +91,96 @@ test('cdp wrapper streams through npm exec and returns downstream exit code', as assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env); assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true); }); + +test('cdp injects remote Metro public url for target discovery', async () => { + const args = buildAgentCdpPassthroughArgs(['target', 'list'], { + flags: { + leaseBackend: 'android-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + metroPublicBaseUrl: 'http://127.0.0.1:8081/', + }, + runtime: { + platform: 'android', + bundleUrl: + 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true', + }, + }); + + assert.deepEqual(args, ['target', 'list', '--url', 'http://127.0.0.1:8081']); +}); + +test('cdp preserves explicit target url for remote sessions', () => { + const args = buildAgentCdpPassthroughArgs( + ['target', 'select', 'react-native:a:b', '--url', 'https://custom.example.test'], + { + flags: { + leaseBackend: 'ios-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + }, + runtime: { + platform: 'ios', + bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle', + }, + }, + ); + + assert.deepEqual(args, [ + 'target', + 'select', + 'react-native:a:b', + '--url', + 'https://custom.example.test', + ]); +}); + +test('cdp rejects remote bridge target discovery without Metro public url', () => { + assert.throws( + () => + buildAgentCdpPassthroughArgs(['target', 'list'], { + flags: { + leaseBackend: 'android-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + }, + runtime: { + platform: 'android', + bundleUrl: + 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true', + }, + }), + /cdp remote bridge target discovery requires a Metro public base URL/, + ); +}); + +test('cdp passes injected remote target url to npm exec', async () => { + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const exitCode = await runAgentCdpCommand(['target', 'list'], { + flags: { + leaseBackend: 'ios-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + metroPublicBaseUrl: 'http://127.0.0.1:8081', + }, + runtime: { + platform: 'ios', + bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-2/index.bundle', + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [ + 'exec', + '--yes', + '--package', + 'agent-cdp@1.6.0', + '--', + 'agent-cdp', + 'target', + 'list', + '--url', + 'http://127.0.0.1:8081', + ]); +}); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 91816e663..e0b5c4f19 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -631,6 +631,161 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('cdp remote materialization prepares Metro runtime for bridge target discovery', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-runtime-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + let prepareRequest: Parameters[0] | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'cdp', + positionals: ['target', 'list'], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + leaseBackend: 'android-instance', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + metroPublicBaseUrl: 'https://sandbox.example.test', + }, + client: createTestClient({ + prepare: async (options) => { + prepareRequest = options; + return { + projectRoot: '/tmp/project', + kind: 'react-native', + dependenciesInstalled: false, + packageManager: null, + started: false, + reused: true, + pid: 0, + logPath: '/tmp/project/.agent-device/metro.log', + statusUrl: 'http://127.0.0.1:8081/status', + runtimeFilePath: null, + iosRuntime: { platform: 'ios' }, + androidRuntime: { + platform: 'android', + bundleUrl: + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', + }, + bridge: null, + }; + }, + }), + }); + + assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test'); + assert.deepEqual(prepareRequest?.bridgeScope, { + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-1', + }); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', + }); + assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.metro, { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('cdp remote materialization skips Metro runtime for non-target commands', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-memory-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(path.join(tempRoot, 'remote.json'), JSON.stringify({})); + let prepared = false; + + try { + const materialized = await materializeRemoteConnectionForCommand({ + command: 'cdp', + positionals: ['memory', 'usage', 'sample'], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + leaseBackend: 'android-instance', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + metroPublicBaseUrl: 'https://sandbox.example.test', + }, + client: createTestClient({ + prepare: async () => { + prepared = true; + throw new Error('prepare should not be called'); + }, + }), + }); + + assert.equal(prepared, false); + assert.equal(materialized.runtime, undefined); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('cdp remote materialization skips Metro runtime without public CDP url', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-no-public-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({})); + let prepared = false; + + try { + const materialized = await materializeRemoteConnectionForCommand({ + command: 'cdp', + positionals: ['target', 'list'], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + leaseBackend: 'android-instance', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ + prepare: async () => { + prepared = true; + throw new Error('prepare should not be called'); + }, + }), + }); + + assert.equal(prepared, false); + assert.equal(materialized.runtime, undefined); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('deferred materialization heartbeats an existing lease before dispatch', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli.ts b/src/cli.ts index 13307a124..ffe71a550 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -197,14 +197,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } let logTailStopper: (() => void) | null = null; try { - if (command === 'cdp') { - const exitCode = await runAgentCdpCommand(positionals, { - cwd: process.cwd(), - env: process.env, - }); - process.exit(exitCode); - return; - } if (command === 'react-devtools') { const exitCode = await runReactDevtoolsCommand(positionals, { flags: effectiveFlags, @@ -283,6 +275,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): flags: effectiveFlags, client: materializationClient, runtime: resolvedRuntime, + positionals, batchSteps: parsedBatchSteps, forceRuntimePrepare: hasExplicitMetroRuntimeOverrides(explicitFlagKeys), }); @@ -302,6 +295,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): 'Warning: open is using explicit remote daemon or tenant flags without saved Metro runtime hints. React Native apps may launch without bundle/runtime hints; prefer connect --remote-config first or pass --remote-config on this command.\n', ); } + if (command === 'cdp') { + const exitCode = await runAgentCdpCommand(positionals, { + flags: effectiveFlags, + runtime: resolvedRuntime, + cwd: process.cwd(), + env: process.env, + }); + process.exit(exitCode); + return; + } const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl; logTailStopper = debugOutputEnabled && !effectiveFlags.json && !remoteDaemonBaseUrl diff --git a/src/cli/commands/agent-cdp.ts b/src/cli/commands/agent-cdp.ts index 3894c2226..f278ae79b 100644 --- a/src/cli/commands/agent-cdp.ts +++ b/src/cli/commands/agent-cdp.ts @@ -1,10 +1,25 @@ import { runCmdStreaming } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import { isRemoteBridgeBackend } from './remote-bridge.ts'; +import type { SessionRuntimeHints } from '../../contracts.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; const AGENT_CDP_VERSION = '1.6.0'; export const AGENT_CDP_PACKAGE = `agent-cdp@${AGENT_CDP_VERSION}`; const AGENT_CDP_BIN = 'agent-cdp'; type AgentCdpCommandOptions = { + flags?: Pick< + CliFlags, + | 'leaseBackend' + | 'leaseId' + | 'metroProxyBaseUrl' + | 'metroPublicBaseUrl' + | 'runId' + | 'session' + | 'tenant' + >; + runtime?: SessionRuntimeHints; cwd?: string; env?: NodeJS.ProcessEnv; }; @@ -13,11 +28,62 @@ export function buildAgentCdpNpmExecArgs(args: string[]): string[] { return ['exec', '--yes', '--package', AGENT_CDP_PACKAGE, '--', AGENT_CDP_BIN, ...args]; } +function hasExplicitUrl(args: string[]): boolean { + return args.some((arg) => arg === '--url' || arg.startsWith('--url=')); +} + +export function shouldAgentCdpUseRemoteBridgeUrl(args: string[]): boolean { + return ( + args[0] === 'target' && (args[1] === 'list' || args[1] === 'select') && !hasExplicitUrl(args) + ); +} + +function normalizeCdpBaseUrl(value: string): string { + const url = new URL(value); + url.search = ''; + url.hash = ''; + const pathname = url.pathname.replace(/\/+$/, ''); + url.pathname = pathname.endsWith('/index.bundle') + ? pathname.slice(0, -'/index.bundle'.length) || '/' + : pathname || '/'; + return url.toString().replace(/\/+$/, ''); +} + +function resolveRemoteBridgeCdpUrl(flags: AgentCdpCommandOptions['flags']): string | null { + const publicBaseUrl = flags?.metroPublicBaseUrl?.trim(); + if (publicBaseUrl) { + return normalizeCdpBaseUrl(publicBaseUrl); + } + return null; +} + +export function buildAgentCdpPassthroughArgs( + args: string[], + options: Pick = {}, +): string[] { + if (!shouldAgentCdpUseRemoteBridgeUrl(args)) return args; + if (!options.flags?.metroProxyBaseUrl || !isRemoteBridgeBackend(options.flags.leaseBackend)) { + return args; + } + const cdpUrl = resolveRemoteBridgeCdpUrl(options.flags); + if (!cdpUrl) { + throw new AppError( + 'INVALID_ARGS', + 'cdp remote bridge target discovery requires a Metro public base URL.', + { + hint: 'Include metroPublicBaseUrl in the remote config so cdp can reach the local or tunneled Metro CDP endpoint without bridge proxy authentication.', + }, + ); + } + return [...args, '--url', cdpUrl]; +} + export async function runAgentCdpCommand( args: string[], options: AgentCdpCommandOptions = {}, ): Promise { - const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(args), { + const passthroughArgs = buildAgentCdpPassthroughArgs(args, options); + const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(passthroughArgs), { cwd: options.cwd ?? process.cwd(), env: options.env ?? process.env, allowFailure: true, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 285a382dc..cc52e429b 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -2,6 +2,7 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopReactDevtoolsCompanion } from '../../client-react-devtools-companion.ts'; import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; +import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import type { MetroBridgeScope } from '../../client-companion-tunnel-contract.ts'; import { buildRemoteConnectionDaemonState, @@ -33,6 +34,7 @@ export async function materializeRemoteConnectionForCommand(options: { flags: CliFlags; client: AgentDeviceClient; runtime?: SessionRuntimeHints; + positionals?: string[]; batchSteps?: BatchStep[]; forceRuntimePrepare?: boolean; }): Promise<{ flags: CliFlags; runtime?: SessionRuntimeHints }> { @@ -100,7 +102,7 @@ export async function materializeRemoteConnectionForCommand(options: { } if ( - shouldPrepareRuntimeForCommand(command, options.batchSteps) && + shouldPrepareRuntimeForCommand(command, nextFlags, options.batchSteps, options.positionals) && hasDeferredMetroConfig(nextFlags) ) { if (!nextState.leaseId && nextFlags.leaseId) { @@ -291,7 +293,15 @@ function shouldAllocateLeaseForCommand(command: string): boolean { return !leaseDeferredCommands.has(command); } -function shouldPrepareRuntimeForCommand(command: string, batchSteps?: BatchStep[]): boolean { +function shouldPrepareRuntimeForCommand( + command: string, + flags: CliFlags, + batchSteps?: BatchStep[], + positionals: string[] = [], +): boolean { + if (command === 'cdp') { + return shouldAgentCdpUseRemoteBridgeUrl(positionals) && Boolean(flags.metroPublicBaseUrl); + } if (runtimeDeferredCommands.has(command)) { return true; } diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 257efd3e0..3090ea6fd 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -4,6 +4,7 @@ import { stopReactDevtoolsCompanion, } from '../../client-react-devtools-companion.ts'; import { AppError } from '../../utils/errors.ts'; +import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; @@ -48,10 +49,6 @@ export function buildReactDevtoolsNpmExecArgs(args: string[]): string[] { ]; } -function isRemoteBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { - return leaseBackend === 'android-instance' || leaseBackend === 'ios-instance'; -} - function isRemoteIosBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { return leaseBackend === 'ios-instance'; } diff --git a/src/cli/commands/remote-bridge.ts b/src/cli/commands/remote-bridge.ts new file mode 100644 index 000000000..161ba8561 --- /dev/null +++ b/src/cli/commands/remote-bridge.ts @@ -0,0 +1,5 @@ +import type { CliFlags } from '../../utils/cli-flags.ts'; + +export function isRemoteBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { + return leaseBackend === 'android-instance' || leaseBackend === 'ios-instance'; +} diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..c66fa0d48 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -475,6 +475,7 @@ state. Do not use this as the default React Native profiler. Setup: Start Metro and open the app first. For Android devices/emulators, make sure Metro is reachable from the app, typically with adb reverse tcp:8081 tcp:8081. + In remote bridge sessions, omit --url for target list/select after connect; agent-device derives the Metro CDP URL from the prepared remote runtime. agent-device cdp target list --url http://127.0.0.1:8081 agent-device cdp target select diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index bfd4ee128..b8fd8fc15 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -55,6 +55,7 @@ agent-device cdp memory snapshot retainers --snapshot ms_3 --id --dept - `cdp` dynamically runs a pinned CDP helper through npm; the first run may download the pinned package, and later runs can reuse the npm cache. - Every argument after `cdp` is passed to the CDP helper. Put `agent-device` global flags before `cdp` when you need the outer CLI to consume them. +- In remote bridge sessions, run `connect` first and omit `--url` for `target list` or `target select`; `agent-device` derives the Metro CDP URL from the prepared remote runtime. - Start with `memory usage sample --gc` for a quick JS heap growth signal. Use snapshot diff and `leak-triplet` for proof that objects stayed retained after cleanup. - Until `cdp` has a compact leak report command, synthesize one from `memory usage diff`, `memory snapshot diff`, `memory snapshot leak-triplet`, and `memory snapshot retainers`. - Keep raw heap snapshots and allocation exports as artifacts. Default answers should summarize heap deltas, top retained classes/shapes, leak-triplet rows that stayed high after cleanup, and shortest useful retaining paths. @@ -90,11 +91,11 @@ agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/a Crash routing: -| Need | Use | -| --- | --- | -| Lead-up timeline before a failure | `logs` | +| Need | Use | +| ----------------------------------------------------------------------------- | --------------- | +| Lead-up timeline before a failure | `logs` | | Failing frame from `crash.ips`/`crash.log` plus matching dSYM/build directory | `debug symbols` | -| Live state, breakpoints, variables, memory, or stepping | Xcode/LLDB | +| Live state, breakpoints, variables, memory, or stepping | Xcode/LLDB | Use `debug symbols` when you already have an Apple crash artifact and local dSYMs and need the failing code path, not a full log dump: