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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ import type { ScreenshotRequestFlags } from './contracts/screenshot.ts';
import type { PerfAction, PerfArea, PerfKind, PerfSubject } 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';
import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/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 { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts';

export type AgentDeviceDaemonTransport = (
req: Omit<DaemonRequest, 'token'>,
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +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 { symbolicateCrashArtifact } from './platforms/ios/debug-symbols.ts';
import { INTERNAL_COMMANDS } from './command-catalog.ts';
import {
prepareDaemonCommandRequest,
Expand Down
4 changes: 4 additions & 0 deletions src/commands/cli-grammar/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CliFlags } from '../../utils/cli-flags.ts';
import { batchCliReaders } from '../batch/index.ts';
import { captureCliReaders } from '../capture/index.ts';
import { debuggingCliReaders } from '../debugging/index.ts';
import type { CliReader } from './types.ts';
import type { CommandName } from '../command-metadata.ts';
import {
Expand All @@ -11,6 +12,7 @@ import {
import { appCliReaders } from '../management/index.ts';
import { metroCliReaders } from '../metro/index.ts';
import { observabilityCliReaders } from '../observability/index.ts';
import { perfCliReaders } from '../perf/index.ts';
import { reactNativeCliReaders } from '../react-native/index.ts';
import { recordingCliReaders } from '../recording/index.ts';
import { replayCliReaders } from '../replay/index.ts';
Expand All @@ -23,6 +25,8 @@ const cliReaders = {
...gestureCliReaders,
...interactionSelectorCliReaders,
...observabilityCliReaders,
...perfCliReaders,
...debuggingCliReaders,
...reactNativeCliReaders,
...recordingCliReaders,
...replayCliReaders,
Expand Down
4 changes: 4 additions & 0 deletions src/commands/cli-output.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { batchCliOutputFormatters } from './batch/output.ts';
import { captureCliOutputFormatters } from './capture/output.ts';
import type { CliOutput } from './command-contract.ts';
import { debuggingCliOutputFormatters } from './debugging/output.ts';
import { interactionCliOutputFormatters } from './interaction/output.ts';
import { managementCliOutputFormatters } from './management/output.ts';
import { metroCliOutputFormatters } from './metro/output.ts';
import { observabilityCliOutputFormatters } from './observability/output.ts';
import { perfCliOutputFormatters } from './perf/output.ts';
import type { CliOutputFormatter } from './output-common.ts';
import { recordingCliOutputFormatters } from './recording/output.ts';
import { systemCliOutputFormatters } from './system/output.ts';
Expand All @@ -16,6 +18,8 @@ const cliOutputFormatters: Partial<Record<CommandName, CliOutputFormatter>> = {
...systemCliOutputFormatters,
...interactionCliOutputFormatters,
...observabilityCliOutputFormatters,
...perfCliOutputFormatters,
...debuggingCliOutputFormatters,
...batchCliOutputFormatters,
...recordingCliOutputFormatters,
...metroCliOutputFormatters,
Expand Down
10 changes: 10 additions & 0 deletions src/commands/client-command-facets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { debuggingCommandDefinitions, debuggingCommandMetadata } from './debugging/index.ts';
import { captureCommandDefinitions, captureCommandMetadata } from './capture/index.ts';
import { managementCommandDefinitions, managementCommandMetadata } from './management/index.ts';
import { metroCommandDefinition, metroCommandMetadata } from './metro/index.ts';
import {
observabilityCommandDefinitions,
observabilityCommandMetadata,
} from './observability/index.ts';
import { perfCommandDefinitions, perfCommandMetadataList } from './perf/index.ts';
import { reactNativeCommandDefinition, reactNativeCommandMetadata } from './react-native/index.ts';
import { recordingCommandDefinitions, recordingCommandMetadata } from './recording/index.ts';
import { replayCommandDefinitions, replayCommandMetadataList } from './replay/index.ts';
Expand Down Expand Up @@ -35,6 +37,14 @@ const clientCommandFamilyFacets = [
metadata: observabilityCommandMetadata,
definitions: observabilityCommandDefinitions,
},
{
metadata: perfCommandMetadataList,
definitions: perfCommandDefinitions,
},
{
metadata: debuggingCommandMetadata,
definitions: debuggingCommandDefinitions,
},
{
metadata: recordingCommandMetadata,
definitions: recordingCommandDefinitions,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/command-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from './interaction/index.ts';
import { appDaemonWriters } from './management/index.ts';
import { observabilityDaemonWriters } from './observability/index.ts';
import { perfDaemonWriters } from './perf/index.ts';
import { reactNativeDaemonWriters } from './react-native/index.ts';
import { recordingDaemonWriters } from './recording/index.ts';
import { replayDaemonWriters } from './replay/index.ts';
Expand All @@ -20,6 +21,7 @@ const daemonWriters = {
...gestureDaemonWriters,
...selectorDaemonWriters,
...observabilityDaemonWriters,
...perfDaemonWriters,
...reactNativeDaemonWriters,
...recordingDaemonWriters,
...replayDaemonWriters,
Expand Down
35 changes: 35 additions & 0 deletions src/commands/debugging/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest';
import type { CliFlags } from '../../utils/cli-flags.ts';
import { debugCliReader, debugCommandDefinition, debugCommandMetadata } from './index.ts';

describe('debugging command interface', () => {
test('owns debug public metadata', () => {
expect(debugCommandMetadata.name).toBe('debug');
expect(debugCommandDefinition.name).toBe('debug');
});

test('reads debug symbols crash artifact inputs', () => {
expect(
debugCliReader(['symbols'], {
artifact: 'crash.ips',
dsym: 'Demo.app.dSYM',
out: 'crash-symbolicated.ips',
} as CliFlags),
).toEqual({
action: 'symbols',
artifact: 'crash.ips',
dsym: 'Demo.app.dSYM',
searchPath: undefined,
out: 'crash-symbolicated.ips',
});
});

test('rejects unsupported debug actions', () => {
expect(() => debugCliReader(['live'], {} as CliFlags)).toThrow(
expect.objectContaining({
code: 'INVALID_ARGS',
message: expect.stringContaining('debug supports only symbols'),
}),
);
});
});
69 changes: 69 additions & 0 deletions src/commands/debugging/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AppError } from '../../utils/errors.ts';
import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts';
import { enumField, requiredField, stringField } from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { commonInputFromFlags } from '../cli-grammar/common.ts';
import type { CliReader } from '../cli-grammar/types.ts';

const DEBUG_COMMAND_NAME = 'debug';
const DEBUG_ACTION_VALUES = ['symbols'] as const;

const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.';

export const debugCommandMetadata = defineFieldCommandMetadata(
DEBUG_COMMAND_NAME,
debugCommandDescription,
{
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.'),
},
);

export const debuggingCommandMetadata = [debugCommandMetadata] as const;

export const debugCommandDefinition = defineExecutableCommand(
debugCommandMetadata,
(client, input) => client.debug.symbols(input),
);

export const debuggingCommandDefinitions = [debugCommandDefinition] as const;

const debugCliSchema = {
usageOverride:
'debug symbols --artifact <crash.ips|crash.log> (--dsym <App.dSYM> | --search-path <dir>) [--out <symbolicated>]',
listUsageOverride: 'debug symbols --artifact <path> --dsym <App.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'],
} as const satisfies CommandSchemaOverride;

export const debuggingCliSchemas = {
[DEBUG_COMMAND_NAME]: debugCliSchema,
} as const satisfies Record<string, CommandSchemaOverride>;

export const debugCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readDebugAction(positionals[0]),
artifact: flags.artifact,
dsym: flags.dsym,
searchPath: flags.searchPath,
out: flags.out,
});

export const debuggingCliReaders = {
debug: debugCliReader,
} satisfies Record<string, CliReader>;

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',
);
}
36 changes: 36 additions & 0 deletions src/commands/debugging/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { DebugSymbolsResult } from '../../client-types.ts';
import type { CliOutput } from '../command-contract.ts';
import { resultOutput, type CliOutputFormatter } from '../output-common.ts';

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') };
}

export const debuggingCliOutputFormatters = {
debug: resultOutput(debugSymbolsCliOutput),
} as const satisfies Record<string, CliOutputFormatter>;

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;
}
35 changes: 1 addition & 34 deletions src/commands/observability/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import {
networkCommandDefinition,
networkCommandMetadata,
networkDaemonWriter,
perfCliReader,
perfCommandDefinition,
perfCommandMetadata,
perfDaemonWriter,
} from './index.ts';

const NO_FLAGS = {} as CliFlags;
Expand All @@ -27,41 +23,13 @@ function expectInvalidArgs(fn: () => unknown, messageFragment: string) {
}

describe('observability command interface', () => {
test('owns perf, logs, and network public metadata', () => {
expect(perfCommandMetadata.name).toBe('perf');
expect(perfCommandDefinition.name).toBe('perf');
test('owns logs and network public metadata', () => {
expect(logsCommandMetadata.name).toBe('logs');
expect(logsCommandDefinition.name).toBe('logs');
expect(networkCommandMetadata.name).toBe('network');
expect(networkCommandDefinition.name).toBe('network');
});

test('reads perf area, action, kind, and out flags', () => {
expect(
perfCliReader(['memory', 'snapshot'], {
kind: 'android-hprof',
out: './heap.hprof',
} as CliFlags),
).toEqual({
area: 'memory',
action: 'snapshot',
kind: 'android-hprof',
out: './heap.hprof',
});
});

test('treats a single perf action as metrics action', () => {
expect(perfCliReader(['sample'], NO_FLAGS)).toEqual({
action: 'sample',
kind: undefined,
out: undefined,
});
expect(perfDaemonWriter({ action: 'sample' })).toMatchObject({
command: 'perf',
positionals: ['metrics', 'sample'],
});
});

test('reads logs action and message', () => {
expect(logsCliReader(['mark', 'checkout', 'started'], NO_FLAGS)).toEqual({
action: 'mark',
Expand Down Expand Up @@ -96,7 +64,6 @@ describe('observability command interface', () => {
});

test('rejects invalid observability positionals', () => {
expectInvalidArgs(() => perfCliReader(['memory', 'explode'], NO_FLAGS), 'perf action');
expectInvalidArgs(() => logsCliReader(['explode'], NO_FLAGS), 'logs requires');
expectInvalidArgs(() => networkCliReader(['explode'], NO_FLAGS), 'network requires');
expectInvalidArgs(
Expand Down
Loading
Loading