diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index a2dafb6d2..7a4c7aff5 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1167,6 +1167,7 @@ function createStubClient(params: { replay: createThrowingMethodGroup(), batch: createThrowingMethodGroup(), observability: createThrowingMethodGroup(), + debug: createThrowingMethodGroup(), recording: createThrowingMethodGroup(), settings: { update: params.updateSettings ?? unexpectedCommandCall, diff --git a/src/__tests__/cli-debug-symbols.test.ts b/src/__tests__/cli-debug-symbols.test.ts new file mode 100644 index 000000000..0a9a8620d --- /dev/null +++ b/src/__tests__/cli-debug-symbols.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { withCommandExecutorOverride } from '../utils/exec.ts'; +import { runCliCapture } from './cli-capture.ts'; + +const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB'; + +test('debug symbols prints only compact human output and does not contact daemon', async () => { + const fixture = await makeCrashFixture('human'); + const result = await withFakeAppleTools( + fixture, + async () => + await runCliCapture( + [ + 'debug', + 'symbols', + '--artifact', + 'crash.log', + '--dsym', + 'Demo.app.dSYM', + '--out', + 'crash-symbolicated.log', + ], + { cwd: fixture.dir }, + ), + ); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 0); + assert.match(result.stdout, /crash-symbolicated\.log/); + assert.match(result.stdout, /Symbolicated 1 frame/); + assert.match(result.stdout, /Crash: Demo thread 0/); + assert.match(result.stdout, /Exception: EXC_CRASH/); + assert.match(result.stdout, /Finding: Start with main \+ 12 in Demo/); + assert.doesNotMatch(result.stdout, /Thread 0 Crashed/); + assert.match(await fs.readFile(fixture.out, 'utf8'), /main \+ 12/); +}); + +test('debug symbols JSON output returns artifact paths and summary', async () => { + const fixture = await makeCrashFixture('json'); + const result = await withFakeAppleTools( + fixture, + async () => + await runCliCapture( + [ + 'debug', + 'symbols', + '--artifact', + 'crash.log', + '--dsym', + 'Demo.app.dSYM', + '--out', + 'crash-symbolicated.log', + '--json', + ], + { cwd: fixture.dir }, + ), + ); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.success, true); + assert.equal(payload.data.outPath, await fs.realpath(fixture.out)); + assert.equal(payload.data.symbolicatedFrames, 1); + assert.equal(payload.data.matchedImages[0].name, 'Demo'); + assert.equal(payload.data.crash.appName, 'Demo'); + assert.equal(payload.data.crash.exceptionType, 'EXC_CRASH (SIGABRT)'); + assert.equal(payload.data.crash.topFrames[0].symbol, 'main + 12'); + assert.doesNotMatch(result.stdout, /Thread 0 Crashed/); +}); + +test('debug rejects catch-all diagnostics subcommands', async () => { + const result = await runCliCapture(['debug', 'perf']); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /debug supports only symbols/); + assert.match(result.stderr, /use logs, network, perf, record, trace, or react-devtools/); +}); + +async function makeCrashFixture(label: string): Promise<{ + dir: string; + dsym: string; + out: string; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `agent-device-cli-debug-${label}-`)); + const dsym = path.join(dir, 'Demo.app.dSYM'); + const out = path.join(dir, 'crash-symbolicated.log'); + await fs.mkdir(dsym); + await fs.writeFile( + path.join(dir, 'crash.log'), + [ + 'Process: Demo [123]', + 'Identifier: com.example.Demo', + 'Exception Type: EXC_CRASH (SIGABRT)', + 'Triggered by Thread: 0', + '', + 'Thread 0 Crashed:', + '0 Demo 0x0000000100001000 0x100000000 + 4096', + '', + 'Binary Images:', + `0x100000000 - 0x10000ffff +Demo arm64 <${UUID}> /tmp/Demo.app/Demo`, + '', + ].join('\n'), + ); + return { dir, dsym, out }; +} + +async function withFakeAppleTools(fixture: { dsym: string }, fn: () => Promise): Promise { + return await withCommandExecutorOverride((cmd, args) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: `/tools/${args.at(-1)}\n`, stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64) ${fixture.dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + return Promise.resolve({ stdout: 'main + 12\n', stderr: '', exitCode: 0 }); + } + return undefined; + }, fn); +} diff --git a/src/__tests__/debug-symbols.test.ts b/src/__tests__/debug-symbols.test.ts new file mode 100644 index 000000000..9b1c8e634 --- /dev/null +++ b/src/__tests__/debug-symbols.test.ts @@ -0,0 +1,464 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { symbolicateCrashArtifact } from '../debug-symbols.ts'; +import { AppError } from '../utils/errors.ts'; +import { withCommandExecutorOverride } from '../utils/exec.ts'; + +const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB'; +const NORMALIZED_UUID = 'ABCDEFABCDEFABCDEFABCDEFABCDEFAB'; +const OTHER_UUID = '11111111-2222-3333-4444-555555555555'; + +test('symbolicates Apple text crash frames with a matching dSYM UUID', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-symbols-')); + const artifact = path.join(dir, 'crash.log'); + const out = path.join(dir, 'crash-symbolicated.log'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + [ + 'Process: Demo [123]', + 'Identifier: com.example.Demo', + 'Exception Type: EXC_CRASH (SIGABRT)', + 'Triggered by Thread: 0', + '', + 'Thread 0 Crashed:', + '0 Demo 0x0000000100001000 0x100000000 + 4096', + '', + 'Binary Images:', + `0x100000000 - 0x10000ffff +Demo arm64 <${UUID}> /tmp/Demo.app/Demo`, + `0x200000000 - 0x20000ffff +Other arm64 <${OTHER_UUID}> /tmp/Other.framework/Other`, + '', + ].join('\n'), + ); + const calls: string[] = []; + + const result = await withCommandExecutorOverride( + (cmd, args) => { + calls.push(`${path.basename(cmd)} ${args.join(' ')}`); + if (cmd === 'xcrun' && args.join(' ') === '--find dwarfdump') { + return Promise.resolve({ stdout: '/tools/dwarfdump\n', stderr: '', exitCode: 0 }); + } + if (cmd === 'xcrun' && args.join(' ') === '--find atos') { + return Promise.resolve({ stdout: '/tools/atos\n', stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64) ${dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + assert.deepEqual(args, [ + '-arch', + 'arm64', + '-o', + `${dsym}/Contents/Resources/DWARF/Demo`, + '-l', + '0x100000000', + '0x100001000', + ]); + return Promise.resolve({ stdout: 'main + 12\n', stderr: '', exitCode: 0 }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + ); + + const output = await fs.readFile(out, 'utf8'); + assert.match(output, /0\s+Demo\s+0x0000000100001000.*\/\/ main \+ 12/); + assert.equal(result.outPath, out); + assert.equal(result.symbolicatedFrames, 1); + assert.equal(result.skippedImages, 1); + assert.equal(result.crash.appName, 'Demo'); + assert.equal(result.crash.bundleId, 'com.example.Demo'); + assert.equal(result.crash.crashedThread, 0); + assert.equal(result.crash.topFrames[0]?.symbol, 'main + 12'); + assert.match(result.crash.findings[0] ?? '', /Start with main \+ 12/); + assert.deepEqual(result.matchedImages[0], { + name: 'Demo', + uuid: NORMALIZED_UUID, + arch: 'arm64', + dsymPath: dsym, + binaryPath: `${dsym}/Contents/Resources/DWARF/Demo`, + }); + assert.ok(calls.some((call) => call.includes('dwarfdump --uuid'))); +}); + +test('matches dSYMs discovered under search path and symbolicates IPS frames', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-ips-')); + const artifact = path.join(dir, 'crash.ips'); + const buildDir = path.join(dir, 'build'); + const dsym = path.join(buildDir, 'Products', 'Demo.app.dSYM'); + const out = path.join(dir, 'crash-symbolicated.ips'); + await fs.mkdir(dsym, { recursive: true }); + await fs.writeFile( + artifact, + JSON.stringify({ + usedImages: [{ name: 'Demo', uuid: UUID, arch: 'arm64', base: 4_294_967_296 }], + threads: [{ frames: [{ imageIndex: 0, imageOffset: 8192 }] }], + }), + ); + + const result = await withCommandExecutorOverride( + (cmd, args) => { + if (cmd === 'xcrun' && args[1] === 'dwarfdump') { + return Promise.resolve({ stdout: '/tools/dwarfdump\n', stderr: '', exitCode: 0 }); + } + if (cmd === 'xcrun' && args[1] === 'atos') { + return Promise.resolve({ stdout: '/tools/atos\n', stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64) ${dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + assert.equal(args.at(-1), '0x100002000'); + return Promise.resolve({ + stdout: 'ViewController.crash() + 44\n', + stderr: '', + exitCode: 0, + }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, searchPath: buildDir, out }), + ); + + const output = JSON.parse(await fs.readFile(out, 'utf8')) as any; + assert.equal(output.threads[0].frames[0].symbol, 'ViewController.crash()'); + assert.equal(output.threads[0].frames[0].symbolLocation, 44); + assert.equal(output.agentDeviceSymbolication.symbolicatedFrames, 1); + assert.equal(result.matchedImages[0]?.dsymPath, dsym); +}); + +test('preserves modern two-document IPS headers while symbolicating payload frames', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-ips-header-')); + const artifact = path.join(dir, 'crash.ips'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + const out = path.join(dir, 'crash-symbolicated.ips'); + const header = '{"app_name":"Demo","timestamp":"2026-06-11 12:00:00.00 +0200"}'; + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + `${header}\n${JSON.stringify({ + procName: 'Demo', + bundleInfo: { CFBundleIdentifier: 'com.example.Demo' }, + exception: { type: 'EXC_CRASH', codes: '0x0000000000000000, 0x0000000000000000' }, + faultingThread: 0, + usedImages: [{ name: 'Demo', uuid: UUID, arch: 'arm64', base: '0x100000000' }], + threads: [{ frames: [{ imageIndex: 0, imageOffset: '0x2000' }] }], + })}`, + ); + + const result = await withCommandExecutorOverride( + (cmd, args) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: `/tools/${args.at(-1)}\n`, stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64) ${dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + assert.equal(args.at(-1), '0x100002000'); + return Promise.resolve({ + stdout: 'ViewController.crash() + 44\n', + stderr: '', + exitCode: 0, + }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + ); + + const output = await fs.readFile(out, 'utf8'); + const newlineIndex = output.indexOf('\n'); + assert.equal(output.slice(0, newlineIndex), header); + const payload = JSON.parse(output.slice(newlineIndex + 1)); + assert.equal(payload.threads[0].frames[0].symbol, 'ViewController.crash()'); + assert.equal(payload.agentDeviceSymbolication.symbolicatedFrames, 1); + assert.equal(result.crash.appName, 'Demo'); + assert.equal(result.crash.bundleId, 'com.example.Demo'); + assert.equal(result.crash.exceptionType, 'EXC_CRASH'); + assert.equal(result.crash.topFrames[0]?.symbol, 'ViewController.crash() + 44'); +}); + +test('reports a UUID mismatch with actionable details', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-mismatch-')); + const artifact = path.join(dir, 'crash.log'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + [ + 'Binary Images:', + ...Array.from( + { length: 8 }, + (_value, index) => + `0x${(0x100000000 + index * 0x100000).toString(16)} - 0x${( + 0x10000ffff + + index * 0x100000 + ).toString( + 16, + )} +Demo${index} arm64 <${uuidFromIndex(index)}> /tmp/Demo${index}.app/Demo${index}`, + ), + ].join('\n'), + ); + + const error = await readRejectedError( + withCommandExecutorOverride( + (cmd, args) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: `/tools/${args.at(-1)}\n`, stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: Array.from( + { length: 7 }, + (_value, index) => + `UUID: ${uuidFromIndex(index + 20)} (arm64) ${dsym}/Contents/Resources/DWARF/Demo${index}\n`, + ).join(''), + stderr: '', + exitCode: 0, + }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym }), + ), + ); + + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /dSYM UUID does not match/); + assert.equal(error.details?.artifactUuidCount, 8); + assert.equal(error.details?.dsymUuidCount, 7); + assert.ok(Array.isArray(error.details?.artifactUuidSample)); + assert.equal(error.details.artifactUuidSample.length, 5); + assert.ok(Array.isArray(error.details?.dsymUuidSample)); + assert.equal(error.details.dsymUuidSample.length, 5); + assert.equal(Object.hasOwn(error.details, 'artifactUuids'), false); + assert.equal(Object.hasOwn(error.details, 'dsymUuids'), false); + assert.equal(typeof error.details?.hint, 'string'); +}); + +test('normalizes malformed IPS numeric fields into AppErrors', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-invalid-ips-')); + const artifact = path.join(dir, 'crash.ips'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + JSON.stringify({ + usedImages: [{ name: 'Demo', uuid: UUID, arch: 'arm64', base: 4_294_967_296 }], + threads: [{ frames: [{ imageIndex: 0, imageOffset: 1.5 }] }], + }), + ); + + const error = await readRejectedError(symbolicateCrashArtifact({ artifact, dsym })); + + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /Invalid IPS frame numeric field: imageOffset/); + assert.equal(typeof error.details?.hint, 'string'); +}); + +test('rejects nonexistent search paths with an actionable invalid-args hint', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-missing-search-')); + const artifact = path.join(dir, 'crash.log'); + const searchPath = path.join(dir, 'missing-build'); + await fs.writeFile( + artifact, + ['Binary Images:', `0x100000000 - 0x10000ffff +Demo arm64 <${UUID}> /tmp/Demo.app/Demo`].join( + '\n', + ), + ); + + const error = await readRejectedError(symbolicateCrashArtifact({ artifact, searchPath })); + + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /search path does not exist/); + assert.equal(typeof error.details?.hint, 'string'); +}); + +test('uses address ranges for duplicate text crash image names and supports arm64_32', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-duplicate-images-')); + const artifact = path.join(dir, 'crash.log'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + [ + 'Thread 0 Crashed:', + '0 Demo 0x0000000200001000 0x200000000 + 4096', + '', + 'Binary Images:', + `0x100000000 - 0x10000ffff +Demo arm64 <${OTHER_UUID}> /tmp/First/Demo.app/Demo`, + `0x200000000 - 0x20000ffff +Demo arm64_32 <${UUID}> /tmp/Second/Demo.app/Demo`, + `0x300000000 - 0x30000ffff Demo (1.0 - 1) <${uuidFromIndex( + 30, + )}> /Applications/Demo.app/Contents/MacOS/Demo`, + '', + ].join('\n'), + ); + + const result = await withCommandExecutorOverride( + (cmd, args) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: `/tools/${args.at(-1)}\n`, stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64_32) ${dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + assert.deepEqual(args, [ + '-arch', + 'arm64_32', + '-o', + `${dsym}/Contents/Resources/DWARF/Demo`, + '-l', + '0x200000000', + '0x200001000', + ]); + return Promise.resolve({ stdout: 'selectedDuplicateImage + 4\n', stderr: '', exitCode: 0 }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym }), + ); + + assert.equal(result.symbolicatedFrames, 1); + assert.equal(result.matchedImages[0]?.uuid, NORMALIZED_UUID); + assert.equal(result.matchedImages[0]?.arch, 'arm64_32'); +}); + +test('keeps atos output aligned and ignores unsymbolicated address echoes', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-atos-align-')); + const artifact = path.join(dir, 'crash.log'); + const out = path.join(dir, 'crash-symbolicated.log'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + [ + 'Thread 0 Crashed:', + '0 Demo 0x0000000100001000 0x100000000 + 4096', + '1 Demo 0x0000000100002000 0x100000000 + 8192', + '', + 'Binary Images:', + `0x100000000 - 0x10000ffff +Demo arm64 <${UUID}> /tmp/Demo.app/Demo`, + '', + ].join('\n'), + ); + + const result = await withCommandExecutorOverride( + (cmd, args) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: `/tools/${args.at(-1)}\n`, stderr: '', exitCode: 0 }); + } + if (cmd === '/tools/dwarfdump') { + return Promise.resolve({ + stdout: `UUID: ${UUID} (arm64) ${dsym}/Contents/Resources/DWARF/Demo\n`, + stderr: '', + exitCode: 0, + }); + } + if (cmd === '/tools/atos') { + return Promise.resolve({ + stdout: '0x100001000\nsecondSymbol + 8\n', + stderr: '', + exitCode: 0, + }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + ); + + const output = await fs.readFile(out, 'utf8'); + assert.equal(result.symbolicatedFrames, 1); + assert.doesNotMatch(output, /0\s+Demo.*\/\//); + assert.match(output, /1\s+Demo\s+0x0000000100002000.*\/\/ secondSymbol \+ 8/); +}); + +test('reports missing Apple tools before attempting symbolication', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-tool-')); + const artifact = path.join(dir, 'crash.log'); + const dsym = path.join(dir, 'Demo.app.dSYM'); + await fs.mkdir(dsym); + await fs.writeFile( + artifact, + ['Binary Images:', `0x100000000 - 0x10000ffff +Demo arm64 <${UUID}> /tmp/Demo.app/Demo`].join( + '\n', + ), + ); + + const error = await readRejectedError( + withCommandExecutorOverride( + (cmd) => { + if (cmd === 'xcrun') { + return Promise.resolve({ stdout: '', stderr: 'missing', exitCode: 1 }); + } + return undefined; + }, + async () => await symbolicateCrashArtifact({ artifact, dsym }), + ), + ); + + assert.ok(error instanceof AppError); + assert.equal(error.code, 'TOOL_MISSING'); + assert.match(error.message, /dwarfdump/); + assert.equal(typeof error.details?.hint, 'string'); +}); + +test('defers Android crash symbolication with a clear hint', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-debug-android-')); + const artifact = path.join(dir, 'android-crash.log'); + await fs.writeFile( + artifact, + [ + 'java.lang.RuntimeException: boom', + ' at com.example.MainActivity.onCreate(MainActivity.kt:10)', + ].join('\n'), + ); + + const error = await readRejectedError( + symbolicateCrashArtifact({ artifact, dsym: path.join(dir, 'mapping.txt') }), + ); + + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.match(String(error.details?.hint), /Android Java\/R8/); +}); + +async function readRejectedError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('Expected promise to reject.'); +} + +function uuidFromIndex(index: number): string { + return `${index.toString(16).padStart(8, '0')}-aaaa-bbbb-cccc-${index + .toString(16) + .padStart(12, '0')}`; +} diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 225b24afd..8aa8a16e4 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -111,6 +111,7 @@ function createTestClient( replay: createThrowingMethodGroup(), batch: createThrowingMethodGroup(), observability: createThrowingMethodGroup(), + debug: createThrowingMethodGroup(), recording: createThrowingMethodGroup(), settings: createThrowingMethodGroup(), }; diff --git a/src/client-types.ts b/src/client-types.ts index 1d4a8c25f..bfc79b957 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -43,11 +43,13 @@ import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; import type { PerfAction, PerfArea } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; +import type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; export type { AppsFilter } from './contracts/app-inventory.ts'; export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert-contract.ts'; +export type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, @@ -831,6 +833,9 @@ export type SettingsUpdateOptions = type CommandExecutionOptions = Partial & { positionals?: string[]; out?: string; + artifact?: string; + dsym?: string; + searchPath?: string; interactiveOnly?: boolean; compact?: boolean; depth?: number; @@ -979,6 +984,9 @@ export type AgentDeviceClient = { logs: (options?: LogsOptions) => Promise; network: (options?: NetworkOptions) => Promise; }; + debug: { + symbols: (options: DebugSymbolsOptions) => Promise; + }; recording: { record: (options: RecordOptions) => Promise; trace: (options: TraceOptions) => Promise; diff --git a/src/client.ts b/src/client.ts index 9127cd5ec..24ebea3d4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; +import { symbolicateCrashArtifact } from './debug-symbols.ts'; import { INTERNAL_COMMANDS } from './command-catalog.ts'; import { prepareDaemonCommandRequest, @@ -303,6 +304,10 @@ export function createAgentDeviceClient( logs: async (options = {}) => await executeCommand('logs', options), network: async (options = {}) => await executeCommand('network', options), }, + debug: { + symbols: async (options) => + await symbolicateCrashArtifact({ cwd: options.cwd ?? config.cwd, ...options }), + }, recording: { record: async (options) => await executeCommand('record', options), trace: async (options) => await executeCommand('trace', options), diff --git a/src/command-catalog.ts b/src/command-catalog.ts index bb11fc882..a4d56cd2e 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -61,6 +61,7 @@ const LOCAL_CLI_COMMANDS = { auth: 'auth', connect: 'connect', connection: 'connection', + debug: 'debug', disconnect: 'disconnect', mcp: 'mcp', metro: 'metro', @@ -77,6 +78,7 @@ export type LocalCliCommandName = (typeof LOCAL_CLI_COMMANDS)[keyof typeof LOCAL export type CliCommandName = PublicCommandName | LocalCliCommandName; export type ClientBackedCliCommandName = | PublicCommandName + | typeof LOCAL_CLI_COMMANDS.debug | typeof LOCAL_CLI_COMMANDS.metro | typeof LOCAL_CLI_COMMANDS.session; @@ -140,6 +142,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.auth, LOCAL_CLI_COMMANDS.connect, LOCAL_CLI_COMMANDS.connection, + LOCAL_CLI_COMMANDS.debug, LOCAL_CLI_COMMANDS.disconnect, LOCAL_CLI_COMMANDS.mcp, LOCAL_CLI_COMMANDS.metro, @@ -168,6 +171,7 @@ export function isClientBackedCliCommandName( ): command is ClientBackedCliCommandName { return ( Object.values(PUBLIC_COMMANDS).includes(command as PublicCommandName) || + command === LOCAL_CLI_COMMANDS.debug || command === LOCAL_CLI_COMMANDS.metro || command === LOCAL_CLI_COMMANDS.session ); diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts index ae274608c..a637cae80 100644 --- a/src/commands/cli-grammar/observability.ts +++ b/src/commands/cli-grammar/observability.ts @@ -28,6 +28,14 @@ import { import type { CliReader, DaemonWriter } from './types.ts'; export const observabilityCliReaders = { + debug: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readDebugAction(positionals[0]), + artifact: flags.artifact, + dsym: flags.dsym, + searchPath: flags.searchPath, + out: flags.out, + }), perf: (positionals, flags) => ({ ...commonInputFromFlags(flags), ...readPerfPositionals(positionals), @@ -104,6 +112,14 @@ function readStartStop(value: string | undefined, command: string): 'start' | 's throw new AppError('INVALID_ARGS', `${command} requires start|stop`); } +function readDebugAction(value: string | undefined): 'symbols' { + if (value === 'symbols') return value; + throw new AppError( + 'INVALID_ARGS', + 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics.', + ); +} + function readPerfArea(value: string | undefined): PerfArea | undefined { if (value === undefined) return undefined; const normalized = value.toLowerCase(); diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index 72dd7ab66..aecab3324 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -7,6 +7,7 @@ import { bootCliOutput, clipboardCliOutput, closeCliOutput, + debugSymbolsCliOutput, deployCliOutput, devicesCliOutput, findCliOutput, @@ -54,6 +55,7 @@ const cliOutputFormatters: Partial> = { appsFilter: input.appsFilter as Parameters[0]['appsFilter'], }), session: resultOutput(sessionCliOutput), + debug: resultOutput(debugSymbolsCliOutput), open: resultOutput(openCliOutput), close: resultOutput(closeCliOutput), install: resultOutput(deployCliOutput), diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts index 6ec411476..6adefe929 100644 --- a/src/commands/client-command-contracts.ts +++ b/src/commands/client-command-contracts.ts @@ -65,6 +65,7 @@ export const clientCommandDefinitions = [ client.command.reactNative(input), ), defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), + defineExecutableCommand(metadata('debug'), (client, input) => client.debug.symbols(input)), defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)), defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)), defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)), diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index d5ac175d0..87fd70620 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -29,6 +29,7 @@ import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; const START_STOP_VALUES = ['start', 'stop'] as const; +const DEBUG_ACTION_VALUES = ['symbols'] as const; const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; const PREPARE_ACTION_VALUES = ['ios-runner'] as const; @@ -43,6 +44,13 @@ export const clientCommandMetadata = [ action: requiredField(enumField(PREPARE_ACTION_VALUES)), timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), }), + defineClientCommandMetadata('debug', { + action: requiredField(enumField(DEBUG_ACTION_VALUES)), + artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), + dsym: stringField('Path to a matching .dSYM bundle.'), + searchPath: stringField('Directory to scan for matching .dSYM bundles.'), + out: stringField('Output path for the symbolicated artifact.'), + }), defineClientCommandMetadata('apps', { appsFilter: enumField(['user-installed', 'all']), }), diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts index 41781b3c7..f5f83c454 100644 --- a/src/commands/client-output.ts +++ b/src/commands/client-output.ts @@ -18,6 +18,7 @@ import type { CaptureSnapshotResult, ClipboardCommandResult, CommandRequestResult, + DebugSymbolsResult, KeyboardCommandResult, SessionCloseResult, } from '../client-types.ts'; @@ -109,6 +110,35 @@ export function deployCliOutput(result: AppDeployResult): CliOutput { return messageOutput(serializeDeployResult(result)); } +export function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { + const lines = [result.outPath, result.message]; + lines.push(...formatDebugCrashSummary(result)); + for (const image of result.matchedImages) { + lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); + } + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning}`); + } + return { data: result, text: lines.join('\n') }; +} + +function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { + const crash = result.crash; + const lines = [ + `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, + ]; + if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); + if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); + if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); + for (const frame of crash.topFrames) { + lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); + } + for (const finding of crash.findings) { + lines.push(`Finding: ${finding}`); + } + return lines; +} + export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { return messageOutput(serializeInstallFromSourceResult(result)); } diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index 1313824ff..e60d14ba4 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -1,5 +1,6 @@ const COMMAND_DESCRIPTIONS = { devices: 'List available devices.', + debug: 'Symbolicate crash artifacts with matching debug symbols.', boot: 'Boot or prepare a selected device without using CLI positional arguments.', shutdown: 'Shutdown a selected simulator or emulator.', apps: 'List installed apps.', diff --git a/src/debug-symbols.ts b/src/debug-symbols.ts new file mode 100644 index 000000000..5a7438389 --- /dev/null +++ b/src/debug-symbols.ts @@ -0,0 +1,1005 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runCmd } from './utils/exec.ts'; +import { AppError } from './utils/errors.ts'; + +export type DebugSymbolsOptions = { + action?: 'symbols'; + artifact: string; + dsym?: string; + searchPath?: string; + out?: string; + cwd?: string; +}; + +export type DebugSymbolsImage = { + name: string; + uuid: string; + arch?: string; + dsymPath: string; + binaryPath: string; +}; + +export type DebugSymbolsCrashFrame = { + index: number; + image: string; + address: string; + symbol?: string; +}; + +export type DebugSymbolsCrashSummary = { + format: 'ips' | 'text'; + appName?: string; + bundleId?: string; + version?: string; + incident?: string; + timestamp?: string; + exceptionType?: string; + exceptionCodes?: string; + terminationReason?: string; + crashedThread?: number; + topFrames: DebugSymbolsCrashFrame[]; + findings: string[]; +}; + +export type DebugSymbolsResult = { + kind: 'debugSymbols'; + platform: 'apple'; + artifactPath: string; + outPath: string; + crash: DebugSymbolsCrashSummary; + matchedImages: DebugSymbolsImage[]; + symbolicatedFrames: number; + skippedImages: number; + warnings?: string[]; + message: string; +}; + +type AppleImage = { + index?: number; + name: string; + uuid: string; + arch?: string; + base: bigint; + end?: bigint; + path?: string; +}; + +type DsymSlice = { + dsymPath: string; + uuid: string; + arch?: string; + binaryPath: string; +}; + +type SymbolicatedAddress = { + image: AppleImage; + address: bigint; + text?: string; +}; + +type CrashArtifact = { + images: AppleImage[]; + addresses: SymbolicatedAddress[]; + summary: (addressMap: Map) => DebugSymbolsCrashSummary; + write: (addressMap: Map) => string; +}; + +type IpsFrameMatch = SymbolicatedAddress & { + frame: Record; + frameIndex: number; + threadIndex: number; +}; + +type IpsDocument = { + header?: string; + payload: Record; +}; + +type SymbolicationGroup = { + image: AppleImage; + dsym: DsymSlice; + addresses: bigint[]; +}; + +const MAX_SEARCH_ENTRIES = 10_000; +const MAX_DSYM_CANDIDATES = 200; +const MAX_CRASH_SUMMARY_FRAMES = 5; +const MAX_CRASH_FINDINGS = 3; +const UUID_DETAIL_SAMPLE_LIMIT = 5; +const UUID_RE = /^[0-9a-fA-F-]{32,36}$/; +const TEXT_IMAGE_ARCH_RE = /^(?:arm64e?|arm64_32|x86_64|armv7[sk]?|i386)$/; + +export async function symbolicateCrashArtifact( + options: DebugSymbolsOptions, +): Promise { + if (options.action !== undefined && options.action !== 'symbols') { + throw new AppError('INVALID_ARGS', 'debug supports only the symbols workflow.', { + hint: 'Use debug symbols --artifact --dsym or --search-path --out .', + }); + } + const cwd = options.cwd ?? process.cwd(); + const artifactPath = resolvePath(cwd, options.artifact); + const outPath = resolvePath(cwd, options.out ?? defaultOutPath(artifactPath)); + const artifactText = await readTextFile(artifactPath, 'crash artifact'); + const crash = readAppleCrashArtifact(artifactText); + if (!crash) throwUnsupportedArtifact(); + + const dsymPaths = await readDsymPaths({ + cwd, + dsym: options.dsym, + searchPath: options.searchPath, + }); + if (dsymPaths.length === 0) { + throw new AppError('INVALID_ARGS', 'debug symbols requires --dsym or --search-path.', { + hint: 'Pass a matching .dSYM bundle directly, or pass --search-path so agent-device can match crash image UUIDs to local dSYMs.', + }); + } + + const tools = await resolveAppleTools(); + const dsymSlices = await readDsymSlices(dsymPaths, tools.dwarfdump); + const matched = matchImagesToDsyms(crash.images, dsymSlices, Boolean(options.dsym)); + const addressMap = await symbolicateAddresses(crash.addresses, matched, tools.atos); + const output = crash.write(addressMap); + + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, output, 'utf8'); + + const matchedImages = [...matched.values()].map(({ image, dsym }) => ({ + name: image.name, + uuid: image.uuid, + arch: image.arch ?? dsym.arch, + dsymPath: dsym.dsymPath, + binaryPath: dsym.binaryPath, + })); + const symbolicatedFrames = [...addressMap.values()].filter((entry) => entry.text).length; + const skippedImages = crash.images.length - matchedImages.length; + const warnings = + skippedImages > 0 + ? [ + `${skippedImages} Apple image${skippedImages === 1 ? '' : 's'} had no matching dSYM and were left unchanged.`, + ] + : undefined; + + return { + kind: 'debugSymbols', + platform: 'apple', + artifactPath, + outPath, + crash: crash.summary(addressMap), + matchedImages, + symbolicatedFrames, + skippedImages, + warnings, + message: `Symbolicated ${symbolicatedFrames} frame${symbolicatedFrames === 1 ? '' : 's'} -> ${outPath}`, + }; +} + +function readAppleCrashArtifact(text: string): CrashArtifact | null { + return readIpsArtifact(text) ?? readTextCrashArtifact(text); +} + +function readIpsArtifact(text: string): CrashArtifact | null { + const document = readIpsDocument(text); + if (!document) return null; + const rawImages = Array.isArray(document.payload.usedImages) ? document.payload.usedImages : []; + const images = rawImages.flatMap((entry, index) => readIpsImage(entry, index)); + if (images.length === 0) return null; + + const rawThreads = Array.isArray(document.payload.threads) ? document.payload.threads : []; + const frameMatches = readIpsFrameMatches(rawThreads, images); + + return { + images, + addresses: frameMatches.map(({ frame: _frame, ...address }) => address), + summary: (addressMap) => summarizeIpsCrash(document, frameMatches, addressMap), + write: (addressMap) => writeIpsArtifact(document, frameMatches, addressMap), + }; +} + +function readIpsDocument(text: string): IpsDocument | null { + const wholeDocument = readJsonRecord(text); + if (wholeDocument) return { payload: wholeDocument }; + const newlineIndex = text.indexOf('\n'); + if (newlineIndex === -1) return null; + const header = text.slice(0, newlineIndex); + const payload = readJsonRecord(text.slice(newlineIndex + 1)); + return payload ? { header, payload } : null; +} + +function readJsonRecord(text: string): Record | null { + try { + const value = JSON.parse(text); + return value && typeof value === 'object' ? (value as Record) : null; + } catch { + return null; + } +} + +function readIpsFrameMatches(rawThreads: unknown[], images: AppleImage[]): IpsFrameMatch[] { + const imageByIndex = new Map(images.map((image) => [image.index, image])); + return rawThreads.flatMap((thread, threadIndex) => + readIpsFrameRecords(thread).flatMap((frame, frameIndex) => + readIpsFrameMatch(frame, imageByIndex, threadIndex, frameIndex), + ), + ); +} + +function readIpsFrameRecords(thread: unknown): Record[] { + if (!thread || typeof thread !== 'object') return []; + const frames = (thread as Record).frames; + return Array.isArray(frames) + ? frames.filter((frame): frame is Record => isRecord(frame)) + : []; +} + +function readIpsFrameMatch( + frame: Record, + imageByIndex: Map, + threadIndex: number, + frameIndex: number, +): IpsFrameMatch[] { + const imageIndex = readIntegerNumberField(frame, 'imageIndex', 'IPS frame'); + const imageOffset = readBigIntField(frame, 'imageOffset', 'IPS frame'); + if (imageIndex === undefined || imageOffset === undefined) return []; + const image = imageByIndex.get(imageIndex); + return image + ? [{ frame, frameIndex, threadIndex, image, address: image.base + imageOffset }] + : []; +} + +function writeIpsArtifact( + document: IpsDocument, + frameMatches: IpsFrameMatch[], + addressMap: Map, +): string { + for (const match of frameMatches) { + const symbol = addressMap.get(addressKey(match.image, match.address))?.text; + if (symbol) writeIpsFrameSymbol(match.frame, symbol); + } + document.payload.agentDeviceSymbolication = { + tool: 'agent-device debug symbols', + symbolicatedFrames: [...addressMap.values()].filter((entry) => entry.text).length, + }; + const payload = `${JSON.stringify(document.payload, null, 2)}\n`; + return document.header ? `${document.header}\n${payload}` : payload; +} + +function writeIpsFrameSymbol(frame: Record, symbol: string): void { + const parsed = parseAtosSymbol(symbol); + frame.symbol = parsed.symbol; + if (parsed.location !== undefined) frame.symbolLocation = parsed.location; +} + +function summarizeIpsCrash( + document: IpsDocument, + frameMatches: IpsFrameMatch[], + addressMap: Map, +): DebugSymbolsCrashSummary { + const crashedThread = readIpsCrashedThread(document.payload); + const summary: DebugSymbolsCrashSummary = { + format: 'ips', + ...readIpsCrashMetadata(document), + crashedThread, + topFrames: summarizeIpsFrames(frameMatches, crashedThread, addressMap), + findings: [], + }; + return { ...summary, findings: crashFindings(summary) }; +} + +function readIpsCrashMetadata( + document: IpsDocument, +): Omit { + const payload = document.payload; + const header = readIpsHeader(document.header); + return { + appName: readIpsAppName(payload, header), + bundleId: readIpsBundleId(payload, header), + version: readIpsVersion(payload, header), + incident: readIpsIncident(payload, header), + timestamp: readIpsTimestamp(payload, header), + exceptionType: readIpsExceptionType(payload.exception), + exceptionCodes: readIpsExceptionCodes(payload.exception), + terminationReason: readIpsTerminationReason(payload.termination), + }; +} + +function readIpsAppName( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.procName, header?.app_name, header?.name); +} + +function readIpsBundleId( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(readRecord(payload.bundleInfo)?.CFBundleIdentifier, header?.bundleID); +} + +function readIpsVersion( + payload: Record, + header: Record | null, +): string | undefined { + return firstString( + readRecord(payload.bundleInfo)?.CFBundleShortVersionString, + header?.app_version, + ); +} + +function readIpsIncident( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.incident, header?.incident_id); +} + +function readIpsTimestamp( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.captureTime, header?.timestamp); +} + +function readIpsExceptionType(exception: unknown): string | undefined { + return readString(readRecord(exception)?.type); +} + +function readIpsHeader(header: string | undefined): Record | null { + return header ? readJsonRecord(header) : null; +} + +function readIpsCrashedThread(payload: Record): number | undefined { + const faultingThread = readNumber(payload.faultingThread); + if (faultingThread !== undefined) return faultingThread; + const threads = Array.isArray(payload.threads) ? payload.threads : []; + const triggeredIndex = threads.findIndex( + (thread) => isRecord(thread) && thread.triggered === true, + ); + return triggeredIndex === -1 ? undefined : triggeredIndex; +} + +function readIpsExceptionCodes(exception: unknown): string | undefined { + const record = readRecord(exception); + if (!record) return undefined; + return firstString(record.codes, record.rawCodes); +} + +function readIpsTerminationReason(termination: unknown): string | undefined { + const record = readRecord(termination); + if (!record) return undefined; + return compactJoin([ + readString(record.namespace), + readString(record.code), + readString(record.reason), + ]); +} + +function summarizeIpsFrames( + frameMatches: IpsFrameMatch[], + crashedThread: number | undefined, + addressMap: Map, +): DebugSymbolsCrashFrame[] { + return frameMatches + .filter((match) => crashedThread === undefined || match.threadIndex === crashedThread) + .slice(0, MAX_CRASH_SUMMARY_FRAMES) + .map((match) => crashFrameSummary(match.frameIndex, match.image, match.address, addressMap)); +} + +function readIpsImage(value: unknown, index: number): AppleImage[] { + if (!value || typeof value !== 'object') return []; + const record = value as Record; + const uuid = normalizeUuid(readString(record.uuid)); + const base = readBigIntField(record, 'base', 'IPS usedImages'); + if (!uuid || base === undefined) return []; + const pathValue = readString(record.path); + return [ + { + index, + name: readString(record.name) ?? (pathValue ? path.basename(pathValue) : `image-${index}`), + uuid, + arch: readString(record.arch), + base, + path: pathValue, + }, + ]; +} + +function readTextCrashArtifact(text: string): CrashArtifact | null { + const lines = text.split('\n'); + const images = readTextImages(lines); + if (images.length === 0) return null; + const addresses = readTextFrameAddresses(lines, images); + return { + images, + addresses, + summary: (addressMap) => summarizeTextCrash(lines, images, addressMap), + write(addressMap) { + return lines + .map((line) => { + const frame = readTextFrameLine(line, images); + if (!frame) return line; + const symbol = addressMap.get(addressKey(frame.image, frame.address))?.text; + if (!symbol || line.includes(symbol)) return line; + return `${line} // ${symbol}`; + }) + .join('\n'); + }, + }; +} + +function summarizeTextCrash( + lines: string[], + images: AppleImage[], + addressMap: Map, +): DebugSymbolsCrashSummary { + const crashedThread = readTextCrashedThread(lines); + const summary: DebugSymbolsCrashSummary = { + format: 'text', + appName: readTextProcessName(lines), + bundleId: readTextField(lines, 'Identifier'), + version: readTextField(lines, 'Version'), + incident: readTextField(lines, 'Incident Identifier'), + timestamp: readTextField(lines, 'Date/Time'), + exceptionType: readTextField(lines, 'Exception Type'), + exceptionCodes: readTextField(lines, 'Exception Codes'), + terminationReason: readTextField(lines, 'Termination Reason'), + crashedThread, + topFrames: summarizeTextFrames(lines, images, crashedThread, addressMap), + findings: [], + }; + return { ...summary, findings: crashFindings(summary) }; +} + +function readTextProcessName(lines: string[]): string | undefined { + const process = readTextField(lines, 'Process'); + return process?.replace(/\s+\[\d+\]$/, ''); +} + +function readTextField(lines: string[], label: string): string | undefined { + const prefix = `${label}:`; + const line = lines.find((candidate) => candidate.trimStart().startsWith(prefix)); + return line ? line.slice(line.indexOf(':') + 1).trim() || undefined : undefined; +} + +function readTextCrashedThread(lines: string[]): number | undefined { + const triggered = readTextField(lines, 'Triggered by Thread'); + const triggeredThread = triggered ? Number.parseInt(triggered, 10) : Number.NaN; + if (Number.isSafeInteger(triggeredThread)) return triggeredThread; + for (const line of lines) { + const match = line.match(/^Thread\s+(\d+)\s+Crashed:/); + if (match) return Number(match[1]); + } + return undefined; +} + +function summarizeTextFrames( + lines: string[], + images: AppleImage[], + crashedThread: number | undefined, + addressMap: Map, +): DebugSymbolsCrashFrame[] { + return textCrashedThreadFrameLines(lines, crashedThread) + .flatMap((line) => readTextCrashFrameSummary(line, images, addressMap)) + .slice(0, MAX_CRASH_SUMMARY_FRAMES); +} + +function textCrashedThreadFrameLines(lines: string[], crashedThread: number | undefined): string[] { + const headingIndex = lines.findIndex((line) => + crashedThread === undefined + ? /^Thread\s+\d+\s+Crashed:/.test(line) + : new RegExp(`^Thread\\s+${crashedThread}\\s+Crashed:`).test(line), + ); + if (headingIndex === -1) return []; + const frames: string[] = []; + for (const line of lines.slice(headingIndex + 1)) { + if (/^Thread\s+\d+/.test(line) || line.trim().length === 0) break; + if (/^\s*\d+\s+/.test(line)) frames.push(line); + } + return frames; +} + +function readTextCrashFrameSummary( + line: string, + images: AppleImage[], + addressMap: Map, +): DebugSymbolsCrashFrame[] { + const frame = readTextFrameLine(line, images); + const indexMatch = line.match(/^\s*(\d+)/); + return frame && indexMatch + ? [crashFrameSummary(Number(indexMatch[1]), frame.image, frame.address, addressMap)] + : []; +} + +function crashFrameSummary( + index: number, + image: AppleImage, + address: bigint, + addressMap: Map, +): DebugSymbolsCrashFrame { + return { + index, + image: image.name, + address: hex(address), + symbol: addressMap.get(addressKey(image, address))?.text, + }; +} + +function crashFindings(summary: DebugSymbolsCrashSummary): string[] { + return [ + firstSymbolicatedFrameFinding(summary), + summary.exceptionType ? `Exception: ${summary.exceptionType}` : undefined, + summary.terminationReason ? `Termination: ${summary.terminationReason}` : undefined, + ] + .filter((finding): finding is string => Boolean(finding)) + .slice(0, MAX_CRASH_FINDINGS); +} + +function firstSymbolicatedFrameFinding(summary: DebugSymbolsCrashSummary): string | undefined { + const frame = summary.topFrames.find((candidate) => candidate.symbol); + if (frame) { + return `Start with ${frame.symbol} in ${frame.image}; it is the first symbolicated frame captured on the crashed thread.`; + } + return summary.topFrames.length > 0 + ? 'No symbolicated frame was found on the crashed thread; verify matching dSYMs for the top images.' + : undefined; +} + +function readTextImages(lines: string[]): AppleImage[] { + const images: AppleImage[] = []; + const binaryImagesIndex = lines.findIndex((line) => /^Binary Images:/i.test(line.trim())); + if (binaryImagesIndex === -1) return images; + for (const line of lines.slice(binaryImagesIndex + 1)) { + const image = readTextImageLine(line); + if (image) images.push(image); + } + return images; +} + +function readTextImageLine(line: string): AppleImage | null { + const match = line.match( + /^\s*(0x[0-9a-fA-F]+)\s*-\s*(0x[0-9a-fA-F]+)\s+\+?(.+?)\s+<([0-9a-fA-F-]{32,36})>\s+(.+)$/, + ); + if (!match) return null; + const uuid = normalizeUuid(match[4]); + if (!uuid) return null; + const parsedName = readTextImageNameAndArch(match[3]!.trim(), match[5]!.trim()); + return { + name: parsedName.name, + arch: parsedName.arch, + uuid, + base: BigInt(match[1]!), + end: BigInt(match[2]!), + path: match[5]!.trim(), + }; +} + +function readTextImageNameAndArch( + rawName: string, + imagePath: string, +): { name: string; arch?: string } { + const tokens = rawName.split(/\s+/); + const maybeArch = tokens.at(-1); + if (maybeArch && TEXT_IMAGE_ARCH_RE.test(maybeArch)) { + return { name: tokens.slice(0, -1).join(' ').trim(), arch: maybeArch }; + } + const executableName = path.basename(imagePath); + return { name: rawName.startsWith(executableName) ? executableName : rawName }; +} + +function readTextFrameAddresses(lines: string[], images: AppleImage[]): SymbolicatedAddress[] { + return lines.flatMap((line) => { + const frame = readTextFrameLine(line, images); + return frame ? [frame] : []; + }); +} + +function readTextFrameLine(line: string, images: AppleImage[]): SymbolicatedAddress | null { + const match = line.match(/^\s*\d+\s+(.+?)\s+(0x[0-9a-fA-F]+)\b/); + if (!match) return null; + const imageName = match[1]!.trim(); + const address = BigInt(match[2]!); + const image = findTextFrameImage(images, imageName, address); + if (!image) return null; + return { image, address }; +} + +function findTextFrameImage( + images: AppleImage[], + imageName: string, + address: bigint, +): AppleImage | undefined { + const matches = images.filter((candidate) => candidate.name === imageName); + return matches.find((candidate) => imageContainsAddress(candidate, address)) ?? single(matches); +} + +function imageContainsAddress(image: AppleImage, address: bigint): boolean { + return image.end !== undefined && image.base <= address && address <= image.end; +} + +async function readDsymPaths(options: { + cwd: string; + dsym?: string; + searchPath?: string; +}): Promise { + if (options.dsym && options.searchPath) { + return [ + resolvePath(options.cwd, options.dsym), + ...(await findDsymBundles(resolvePath(options.cwd, options.searchPath))), + ]; + } + if (options.dsym) return [resolvePath(options.cwd, options.dsym)]; + if (options.searchPath) + return await findDsymBundles(resolvePath(options.cwd, options.searchPath)); + return []; +} + +async function findDsymBundles(root: string): Promise { + const found: string[] = []; + let visited = 0; + async function walk(current: string): Promise { + if (found.length >= MAX_DSYM_CANDIDATES) return; + visited += 1; + if (visited > MAX_SEARCH_ENTRIES) { + throw new AppError('COMMAND_FAILED', 'debug symbols search-path scan exceeded bounds.', { + searchPath: root, + maxEntries: MAX_SEARCH_ENTRIES, + hint: 'Pass --dsym directly or narrow --search-path to the build products directory.', + }); + } + const stat = await readSearchPathStat(current, root); + if (!stat.isDirectory()) { + if (current === root) throwInvalidSearchPathDirectory(root); + return; + } + if (current.endsWith('.dSYM')) { + found.push(current); + return; + } + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + await walk(path.join(current, entry.name)); + } + } + await walk(root); + return found; +} + +async function readSearchPathStat( + current: string, + root: string, +): Promise>> { + try { + return await fs.stat(current); + } catch { + throw new AppError('INVALID_ARGS', `debug symbols search path does not exist: ${root}`, { + hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', + }); + } +} + +function throwInvalidSearchPathDirectory(root: string): never { + throw new AppError('INVALID_ARGS', `debug symbols search path is not a directory: ${root}`, { + hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', + }); +} + +async function readDsymSlices(dsymPaths: string[], dwarfdump: string): Promise { + const sliceGroups = await Promise.all( + unique(dsymPaths).map((dsymPath) => readDsymBundleSlices(dsymPath, dwarfdump)), + ); + const slices = sliceGroups.flat(); + if (slices.length === 0) { + throw new AppError('COMMAND_FAILED', 'No UUIDs found in dSYM bundle.', { + hint: 'Verify the path points to a built .dSYM bundle with DWARF contents.', + }); + } + return slices; +} + +async function readDsymBundleSlices(dsymPath: string, dwarfdump: string): Promise { + await assertDsymBundlePath(dsymPath); + const result = await runCmd(dwarfdump, ['--uuid', dsymPath], { + timeoutMs: 15_000, + allowFailure: true, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to inspect dSYM UUIDs: ${dsymPath}`, { + stderr: result.stderr, + hint: 'Verify the dSYM bundle is valid and readable.', + }); + } + return parseDwarfdumpUuidOutput(dsymPath, result.stdout); +} + +async function assertDsymBundlePath(dsymPath: string): Promise { + const stat = await fs.stat(dsymPath).catch(() => null); + if (stat?.isDirectory() && dsymPath.endsWith('.dSYM')) return; + throw new AppError('INVALID_ARGS', `Not a .dSYM bundle: ${dsymPath}`, { + hint: 'Pass the .dSYM bundle path, not the DWARF executable inside it.', + }); +} + +function parseDwarfdumpUuidOutput(dsymPath: string, output: string): DsymSlice[] { + return output.split('\n').flatMap((line) => { + const match = line.match(/^UUID:\s+([0-9a-fA-F-]{32,36})\s+\(([^)]+)\)\s+(.+)$/); + const uuid = normalizeUuid(match?.[1]); + return match && uuid ? [{ dsymPath, uuid, arch: match[2], binaryPath: match[3]!.trim() }] : []; + }); +} + +function matchImagesToDsyms( + images: AppleImage[], + dsymSlices: DsymSlice[], + explicitDsym: boolean, +): Map { + const matched = new Map(); + for (const image of images) { + const dsym = dsymSlices.find( + (candidate) => + candidate.uuid === image.uuid && + (image.arch === undefined || candidate.arch === undefined || candidate.arch === image.arch), + ); + if (dsym) matched.set(image.uuid, { image, dsym }); + } + if (matched.size > 0) return matched; + + const artifactUuids = unique(images.map((image) => image.uuid)); + const dsymUuids = unique(dsymSlices.map((slice) => slice.uuid)); + throw new AppError( + 'COMMAND_FAILED', + explicitDsym + ? 'dSYM UUID does not match any Apple image in the crash artifact.' + : 'No matching dSYM UUID found under search path.', + { + artifactUuidCount: artifactUuids.length, + artifactUuidSample: artifactUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), + dsymUuidCount: dsymUuids.length, + dsymUuidSample: dsymUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), + hint: 'Use dwarfdump --uuid and compare it with the crash Binary Images or usedImages UUID, then pass the matching dSYM/search path.', + }, + ); +} + +async function symbolicateAddresses( + addresses: SymbolicatedAddress[], + matched: Map, + atos: string, +): Promise> { + const addressMap = new Map(); + for (const group of groupSymbolicationAddresses(addresses, matched).values()) { + for (const entry of await runAtosForGroup(atos, group)) { + addressMap.set(addressKey(entry.image, entry.address), entry); + } + } + return addressMap; +} + +function groupSymbolicationAddresses( + addresses: SymbolicatedAddress[], + matched: Map, +): Map { + const groups = new Map(); + for (const frame of addresses) { + const match = matched.get(frame.image.uuid); + if (!match) continue; + const key = `${frame.image.uuid}:${match.dsym.binaryPath}`; + const group = groups.get(key) ?? { ...match, addresses: [] }; + group.addresses.push(frame.address); + groups.set(key, group); + } + return groups; +} + +async function runAtosForGroup( + atos: string, + group: SymbolicationGroup, +): Promise { + const addresses = unique(group.addresses.map(hex)); + const result = await runCmd(atos, atosArgs(group, addresses), { + timeoutMs: 30_000, + allowFailure: true, + }); + if (result.exitCode !== 0) throwAtosFailure(result.stderr); + return mapAtosOutputToAddresses(group.image, addresses, result.stdout); +} + +function atosArgs(group: SymbolicationGroup, addresses: string[]): string[] { + return [ + '-arch', + group.image.arch ?? group.dsym.arch ?? 'arm64', + '-o', + group.dsym.binaryPath, + '-l', + hex(group.image.base), + ...addresses, + ]; +} + +function mapAtosOutputToAddresses( + image: AppleImage, + addresses: string[], + output: string, +): SymbolicatedAddress[] { + const symbols = splitAtosOutput(output); + return addresses.map((rawAddress, index) => { + const text = symbols[index]?.trim(); + return { + image, + address: BigInt(rawAddress), + text: isSymbolicatedAtosOutput(text, rawAddress) ? text : undefined, + }; + }); +} + +function splitAtosOutput(output: string): string[] { + const lines = output.split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + return lines; +} + +function isSymbolicatedAtosOutput(text: string | undefined, rawAddress: string): text is string { + if (!text) return false; + const normalized = text.trim().toLowerCase(); + if (!normalized || normalized === '??') return false; + if (normalized === rawAddress.toLowerCase()) return false; + return !normalized.startsWith('0x'); +} + +function throwAtosFailure(stderr: string): never { + throw new AppError('COMMAND_FAILED', 'atos failed while symbolicating crash frames.', { + stderr, + hint: 'Verify the crash artifact and dSYM were produced from the same build and architecture.', + }); +} + +async function resolveAppleTools(): Promise<{ dwarfdump: string; atos: string }> { + return { + dwarfdump: await resolveAppleTool('dwarfdump'), + atos: await resolveAppleTool('atos'), + }; +} + +async function resolveAppleTool(name: 'dwarfdump' | 'atos'): Promise { + try { + const result = await runCmd('xcrun', ['--find', name], { + timeoutMs: 5_000, + allowFailure: true, + }); + const toolPath = result.stdout.trim(); + if (result.exitCode === 0 && toolPath.length > 0) return toolPath; + } catch { + // Fall through to the normalized TOOL_MISSING error below. + } + throw new AppError('TOOL_MISSING', `Apple symbolication tool not found: ${name}`, { + hint: 'Install Xcode Command Line Tools and verify xcrun --find dwarfdump and xcrun --find atos succeed.', + }); +} + +function parseAtosSymbol(value: string): { symbol: string; location?: number } { + const match = value.match(/^(.*) \+ (\d+)$/); + if (!match) return { symbol: value }; + return { symbol: match[1]!, location: Number(match[2]) }; +} + +function throwUnsupportedArtifact(): never { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'debug symbols currently supports Apple crash artifacts with Binary Images or IPS usedImages.', + { + hint: 'For Android Java/R8 crashes, use retrace with mapping.txt. For Android native crashes, use ndk-stack or addr2line with unstripped .so symbols. Capture the crash with logs, then symbolicate externally until Android support is added.', + }, + ); +} + +async function readTextFile(filePath: string, label: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new AppError('INVALID_ARGS', `Failed to read ${label}: ${filePath}`, { message }); + } +} + +function resolvePath(cwd: string, value: string): string { + return path.resolve(cwd, value); +} + +function defaultOutPath(artifactPath: string): string { + const extension = path.extname(artifactPath); + const base = extension ? artifactPath.slice(0, -extension.length) : artifactPath; + return `${base}-symbolicated${extension || '.log'}`; +} + +function normalizeUuid(value: string | undefined): string | undefined { + if (!value || !UUID_RE.test(value)) return undefined; + return value.replaceAll('-', '').toUpperCase(); +} + +function addressKey(image: AppleImage, address: bigint): string { + return `${image.uuid}:${hex(address)}`; +} + +function hex(value: bigint): string { + return `0x${value.toString(16)}`; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + const stringValue = readString(value); + if (stringValue) return stringValue; + } + return undefined; +} + +function compactJoin(values: (string | undefined)[]): string | undefined { + const compact = values.filter((value): value is string => Boolean(value)); + return compact.length > 0 ? compact.join(' ') : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string') { + const parsed = value.startsWith('0x') ? Number.parseInt(value, 16) : Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; + } + return undefined; +} + +function readIntegerNumberField( + record: Record, + key: string, + context: string, +): number | undefined { + if (!Object.hasOwn(record, key)) return undefined; + const value = readNumber(record[key]); + if (value !== undefined) return value; + throwInvalidNumericField(context, key); +} + +function readBigIntField( + record: Record, + key: string, + context: string, +): bigint | undefined { + if (!Object.hasOwn(record, key)) return undefined; + const value = readBigInt(record[key]); + if (value !== undefined) return value; + throwInvalidNumericField(context, key); +} + +function readBigInt(value: unknown): bigint | undefined { + if (typeof value === 'bigint') return value; + if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value); + if (typeof value !== 'string') return undefined; + if (!/^(?:0x[0-9a-fA-F]+|\d+)$/.test(value)) return undefined; + return BigInt(value); +} + +function throwInvalidNumericField(context: string, key: string): never { + throw new AppError('INVALID_ARGS', `Invalid ${context} numeric field: ${key}`, { + hint: 'Crash artifact numeric fields must be integer numbers or integer numeric strings.', + }); +} + +function single(values: T[]): T | undefined { + return values.length === 1 ? values[0] : undefined; +} + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index ed1413dd8..f4a790b89 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1180,6 +1180,11 @@ test('usageForCommand resolves debugging help topic', () => { const help = usageForCommand('debugging'); if (help === null) throw new Error('Expected debugging help text'); assert.match(help, /agent-device help debugging/); + assert.match(help, /Use logs when you need the lead-up timeline/); + assert.match(help, /Use debug symbols when you have crash\.ips\/crash\.log/); + assert.match(help, /Use Xcode\/LLDB when you need live state/); + assert.match(help, /debug symbols --artifact crash\.ips --search-path \.\/build/); + assert.match(help, /Android Java\/R8 mapping\.txt and native ndk-stack\/addr2line/); assert.match(help, /agent-device alert wait 3000/); assert.match(help, /iOS support is runner-derived/); assert.match(help, /resolved app executable/); @@ -1190,6 +1195,45 @@ test('usageForCommand resolves debugging help topic', () => { assert.match(help, /Do not use settings permission to answer a dialog already on screen/); }); +test('parseArgs recognizes debug symbols command shape', () => { + const parsed = parseArgs([ + 'debug', + 'symbols', + '--artifact', + 'crash.ips', + '--search-path', + './build', + '--out', + 'crash-symbolicated.ips', + ]); + + assert.equal(parsed.command, 'debug'); + assert.deepEqual(parsed.positionals, ['symbols']); + assert.equal(parsed.flags.artifact, 'crash.ips'); + assert.equal(parsed.flags.searchPath, './build'); + assert.equal(parsed.flags.out, 'crash-symbolicated.ips'); +}); + +test('debug command help stays scoped to symbolication', () => { + const help = usageForCommand('debug'); + if (help === null) throw new Error('Expected debug help text'); + assert.match(help, /debug symbols --artifact/); + assert.match(help, /intentionally narrow/); + assert.match(help, /use logs for app logs, network for HTTP evidence, perf for performance/); + assert.doesNotMatch(help, /agent-device debug perf/); + assert.doesNotMatch(help, /agent-device debug logs/); +}); + +test('debug rejects unrelated diagnostics flags', () => { + assert.throws( + () => parseArgs(['debug', 'symbols', '--include', 'headers']), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('not supported for command debug'), + ); +}); + test('usageForCommand resolves remote help topic', () => { const help = usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 5f474f25f..bfc36446e 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -77,6 +77,16 @@ const CLI_COMMAND_OVERRIDES = { positionalArgs: ['ios-runner'], allowedFlags: ['timeoutMs'], }, + debug: { + usageOverride: + 'debug symbols --artifact (--dsym | --search-path ) [--out ]', + listUsageOverride: 'debug symbols --artifact --dsym ', + helpDescription: + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', + summary: 'Symbolicate Apple crash artifacts', + positionalArgs: ['symbols'], + allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], + }, open: { helpDescription: 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 049d8e825..81fae4a6e 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -62,6 +62,9 @@ export type CliFlags = RemoteConfigMetroOptions & snapshotScope?: string; snapshotRaw?: boolean; snapshotForceFull?: boolean; + artifact?: string; + dsym?: string; + searchPath?: string; networkInclude?: NetworkIncludeMode; baseline?: string; threshold?: string; @@ -119,6 +122,7 @@ export type CliFlags = RemoteConfigMetroOptions & input: Record; runtime?: unknown; }>; + out?: string; help: boolean; version: boolean; }; @@ -988,6 +992,27 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--out ', usageDescription: 'Output path', }, + { + key: 'artifact', + names: ['--artifact'], + type: 'string', + usageLabel: '--artifact ', + usageDescription: 'Debug symbols: Apple crash artifact path (.ips, .crash, or .log)', + }, + { + key: 'dsym', + names: ['--dsym'], + type: 'string', + usageLabel: '--dsym ', + usageDescription: 'Debug symbols: matching .dSYM bundle path', + }, + { + key: 'searchPath', + names: ['--search-path'], + type: 'string', + usageLabel: '--search-path ', + usageDescription: 'Debug symbols: directory to scan for matching .dSYM bundles', + }, { key: 'overlayRefs', names: ['--overlay-refs'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 614aad5ab..142157faa 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -275,6 +275,18 @@ Network: Use this instead of logs path when the question is request/response metadata. network log is a supported alias, but network dump --include headers is the clearest plan form. Do not write network log headers. +Crash symbolication: + Crash routing: + Use logs when you need the lead-up timeline before a failure. + Use debug symbols when you have crash.ips/crash.log plus a matching dSYM/build directory and need the failing frame. + Use Xcode/LLDB when you need live state, breakpoints, variables, memory, or stepping. + 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: + agent-device debug symbols --artifact crash.log --dsym MyApp.dSYM --out crash-symbolicated.log + agent-device debug symbols --artifact crash.ips --search-path ./build --out crash-symbolicated.ips + debug is intentionally narrow. Do not use it for logs, network evidence, performance samples, recordings, traces, or React Native internals. + Apple support matches crash Binary Images / IPS usedImages UUIDs against dwarfdump --uuid output from .dSYM bundles, then writes a symbolicated artifact path and compact crash report: app/thread, exception or termination, top symbolicated frames, and first-frame finding. This is better than pasting crash logs because it keeps agent context small while preserving the artifact on disk for inspection. + Android Java/R8 mapping.txt and native ndk-stack/addr2line symbolication are not in this first debug symbols workflow; capture crash evidence with logs and use the Android toolchain externally for now. + Alerts: Native and platform dialogs: agent-device alert wait 3000 diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6979830b2..a3172ba1b 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1235,6 +1235,29 @@ const SKILL_GUIDANCE_CASES: Case[] = [ ], forbiddenOutputs: [/logs path/i, /cat .*log/i], }), + makeCase({ + id: 'debug-symbols-apple-crash', + contract: [ + 'Artifact: artifacts/crash.ips', + 'Build products directory: ./build', + 'Need a symbolicated crash artifact at artifacts/crash-symbolicated.ips', + 'Do not inspect or paste the full crash into context', + ], + task: 'Plan the command to symbolicate this Apple crash report with local debug symbols.', + outputs: [ + plannedCommand('debug'), + /symbols/i, + /--artifact\s+artifacts\/crash\.ips/i, + /--search-path\s+\.\/build/i, + /--out\s+artifacts\/crash-symbolicated\.ips/i, + ], + forbiddenOutputs: [ + plannedCommand('perf'), + plannedCommand('logs'), + /cat\s+artifacts\/crash\.ips/i, + /react-devtools/i, + ], + }), makeCase({ id: 'android-open-verify-ui', contract: [ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bec930e24..0ef1598a5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -720,6 +720,19 @@ agent-device network dump 25 --include all # Include parsed headers/body when av - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. +**Crash symbols (bounded local symbolication):** Use `debug symbols` when you already have an Apple crash artifact and local dSYMs and need the failing code path. The command matches crash Binary Images / IPS `usedImages` UUIDs to `dwarfdump --uuid` output, runs `atos`, writes a symbolicated artifact, and prints only the output path plus a compact crash report with app/thread, exception or termination, top symbolicated frames, and the first actionable frame finding. This is better than pasting raw crash logs because the agent sees the diagnosis and artifact path without ingesting the full crash body. + +Crash routing: use `logs` for the lead-up timeline, `debug symbols` for a failing frame from `crash.ips`/`crash.log` plus matching dSYMs, and Xcode/LLDB for live state, breakpoints, variables, memory, or stepping. + +```bash +agent-device debug symbols --artifact crash.log --dsym MyApp.dSYM --out crash-symbolicated.log +agent-device debug symbols --artifact crash.ips --search-path ./build --out crash-symbolicated.ips +``` + +- `debug` is intentionally narrow: do not use it for app logs, network evidence, performance samples, recordings, traces, or React Native internals. +- Android Java/R8 `mapping.txt` and native `ndk-stack`/`addr2line` symbolication are deferred; capture Android crash evidence with `logs` and symbolicate externally for now. +- The crash artifact body is written to `--out`; it is not dumped into agent context or default JSON. + **Grepping app logs:** Use `logs path` to get the file path, then run `grep` (or `grep -E`) on that path so only matching lines enter context—keeping token use low. ```bash diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index f9acceb76..b8979077b 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -11,6 +11,7 @@ Use `agent-device` when the task moves past UI automation and you need runtime e - Session app logs for targeted debugging windows - Network inspection from recent HTTP(s) entries in app logs via `network dump` - Performance snapshots with `perf metrics` / `perf frames` +- Apple crash symbolication with `debug symbols` - Screenshots, recordings, and replayable repro flows ## React Native component internals @@ -62,6 +63,27 @@ agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/a `--launch-console` is only for direct iOS simulator app launches, not URL opens. +## Crash symbolication + +Crash routing: + +| 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 | + +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: + +```bash +agent-device debug symbols --artifact crash.log --dsym MyApp.dSYM --out crash-symbolicated.log +agent-device debug symbols --artifact crash.ips --search-path ./build --out crash-symbolicated.ips +``` + +The command supports Apple `.ips`, `.crash`, and log-style crash artifacts that contain Binary Images or IPS `usedImages`. It matches UUIDs from the crash artifact against `dwarfdump --uuid` output from `.dSYM` bundles, runs `atos`, writes a symbolicated artifact, and prints only the output path plus a compact crash report: app/thread, exception or termination, top symbolicated frames, and the first actionable frame finding. This is better than pasting raw crash logs because it keeps agent context small while preserving the full symbolicated artifact on disk. + +`debug` is intentionally narrow. Use `logs` for app logs, `network` for HTTP evidence, `perf` for performance samples, `record`/`trace` for media and traces, and `react-devtools` for React Native internals. Android Java/R8 `mapping.txt` and native `ndk-stack`/`addr2line` symbolication are deferred; capture Android crash evidence with `logs` and symbolicate externally for now. + ## Core commands ### Logs