diff --git a/AGENTS.md b/AGENTS.md index 1ff2c59fd..ed915e5cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,7 @@ Command-only flags (like `find --first`) that do not flow to the platform layer ## React Native Verification - After changing runtime code exercised through `bin/agent-device.mjs` or the daemon, run `pnpm build` and `pnpm clean:daemon` before manual device verification so snapshots use current `dist` output. +- For repo-owned `Agent Device Tester` verification, use `examples/test-app/README.md` as the source of truth for simulator, physical-device, Metro/dev-client, and app-surface verification steps. Do not treat an already installed `com.callstack.agentdevicelab` as sufficient unless the README's Metro/dev-build and `snapshot -i` checks prove the expected app surface is running. - For Android RN/Expo/dev-client apps connected to any local Metro port, `adb reverse tcp: tcp:` is harmless and should be run before opening the app or URL on the emulator/device. - In sandboxed agent environments, run manual `agent-device` CLI verification that starts the daemon outside the sandbox with escalation. The daemon binds localhost, and sandboxed runs can fail before any product code executes with `listen EPERM: operation not permitted 127.0.0.1` or repeated `Failed to start daemon`/metadata cleanup messages. Do not spend time debugging those as agent-device regressions; rerun the same command with escalation. Unit tests, typecheck, lint, and build can stay sandboxed unless they need platform devices or network/listener access. diff --git a/examples/test-app/README.md b/examples/test-app/README.md index b09c35ff2..583f879e6 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -52,7 +52,10 @@ The app declares `@expo/dom-webview` directly to keep Expo's development runtime on the SDK 56 native module; Android verification failed when the dev client resolved an older transitive copy. -From the repo root: +### iOS simulator + +From the repo root, install dependencies and run the development build on the +target simulator: ```bash pnpm test-app:install @@ -63,13 +66,51 @@ pnpm test-app:ios -- --device "iPhone 17 Pro" terminal running, then use a separate terminal for `agent-device` or Maestro commands. -Or on Android: +### iOS physical device + +Use the physical device name from `agent-device devices --platform ios` or +`xcrun devicectl list devices`. Keep the `expo run:ios` terminal running so +Metro stays visible to the development build: + +```bash +pnpm test-app:install +pnpm test-app:ios -- --device "" +``` + +Then verify the installed development build from another terminal with the same +physical device identifier: + +```bash +agent-device open com.callstack.agentdevicelab --platform ios --udid "" --session test-app-physical +agent-device snapshot -i --platform ios --udid "" --session test-app-physical +``` + +The snapshot should show the `Agent Device Tester` home screen, for example the +`Agent Device Tester` heading and tab bar. An already installed +`com.callstack.agentdevicelab` is not enough evidence by itself: confirm Metro +is running for the development build and verify the visible app surface before +using the session for manual logs, network, replay, or interaction checks. Close +the same session when verification is complete: + +```bash +agent-device close --platform ios --udid "" --session test-app-physical +``` + +### Android emulator or device + +Install dependencies and run the development build on the target Android +emulator or device: ```bash pnpm test-app:install pnpm test-app:android -- --device "$ANDROID_DEVICE" ``` +For Android app/package launches connected to local Metro, run `adb reverse` +for the Metro port when needed before opening the app with `agent-device`. + +### Running from the app folder + If you prefer to work from inside the app folder: ```bash @@ -87,9 +128,44 @@ pnpm android ``` After the first native build is installed, use `pnpm test-app:start` when you only -need to restart Metro for JavaScript or TypeScript changes. Once the app is -running, use `agent-device` against `Agent Device Tester` like any other target -app. +need to restart Metro for JavaScript or TypeScript changes. `test-app:start` +starts Metro only; it does not build, install, or prove a physical device is +running the development build. Once the app is running and verified with +`snapshot -i`, use `agent-device` against `Agent Device Tester` like any other +target app. + +### Non-default Metro ports + +If the default Metro port is already in use, start Metro on another port. Do not +reinstall the native development build just to change the JavaScript server port: + +```bash +pnpm test-app:start -- --port 8082 +``` + +If you are building and installing for the first time in that terminal, Expo's +`run:ios` and `run:android` commands also accept `--port`: + +```bash +pnpm test-app:ios -- --device "" --port 8082 +pnpm test-app:android -- --device "$ANDROID_DEVICE" --port 8082 +``` + +After the development build is installed, keep using the same native app. The +current `agent-device open` CLI does not accept `--metro-host` or `--metro-port`; +open the app normally, then use the Metro command surface for Metro-specific +actions: + +```bash +agent-device metro prepare --project-root examples/test-app --kind expo --port 8082 --public-base-url http://127.0.0.1:8082 +agent-device metro reload --metro-host 127.0.0.1 --metro-port 8082 +``` + +Use `metro prepare` when you want `agent-device` to start or reuse Metro and +print the runtime URLs. Use `metro reload` when Metro is already running and the +installed development build is connected to that server. For Android local +device/emulator runs, also run `adb reverse tcp:8082 tcp:8082` when the device +needs host port forwarding. ## Local Agent Device suites diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 878cb3912..3f78dbddf 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -339,6 +339,7 @@ Logs: Do not cat a full stale log into agent context. Open or grep only the relevant window when needed. logs clear --restart is the compact command to clear old logs and start a fresh capture; do not split it into logs stop, logs clear, logs start. On iOS simulators, logs scope by bundle id and resolved app executable, so use this instead of raw simctl log stream predicates. + On iOS physical devices, logs clear --restart relaunches the session app through devicectl process launch --console so stdout/stderr can be captured. For iOS simulator launch-time stdout/stderr, use --launch-console on the direct app launch: agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log --launch-console is only for direct iOS simulator app launches, not URL opens. diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index 12a7b6b0b..cd820878e 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -3,16 +3,66 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { finished } from 'node:stream/promises'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { AppError } from '../../kernel/errors.ts'; +import { withAppleToolProvider } from '../../platforms/apple/core/tool-provider.ts'; +import type { ExecResult } from '../../utils/exec.ts'; import { APP_LOG_PID_FILENAME, assertAndroidPackageArgSafe, buildAppleLogPredicate, - buildIosDeviceLogStreamArgs, + buildIosDeviceConsoleLaunchArgs, buildIosSimulatorLogStreamArgs, cleanupStaleAppLogProcesses, runAppLogDoctor, rotateAppLogIfNeeded, } from '../app-log.ts'; +import { startIosDeviceAppLog } from '../app-log-ios.ts'; + +const IOS_DEVICE_ID = '00008150-0000AAAA'; +const IOS_DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: IOS_DEVICE_ID, + name: 'iPhone', + kind: 'device', +}; +const IOS_DEVICE_HELP_WITHOUT_CONSOLE_CAPTURE = + 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n'; +const IOS_DEVICE_CONSOLE_CAPTURE_HELP = `USAGE: devicectl device process launch [] --device + +COMMAND OPTIONS: + --console Attaches the application to the console and waits for it to exit. + --terminate-existing Terminates any already-running instances of the app prior to launch.`; + +type FakeDevicectlRun = (args: string[]) => Promise; + +async function withFakeDevicectl( + run: FakeDevicectlRun, + fn: () => Promise, +): Promise<{ result: T; calls: string[][] }> { + const calls: string[][] = []; + const result = await withAppleToolProvider( + { + runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + devicectl: { + run: async (args) => { + calls.push(args); + return await run(args); + }, + }, + whichCommand: async () => false, + }, + fn, + ); + return { result, calls }; +} + +function makeAppLogWriteStream(prefix: string): fs.WriteStream { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); +} test('buildAppleLogPredicate includes bundle-aware filters', () => { const predicate = buildAppleLogPredicate('com.example.app'); @@ -64,17 +114,119 @@ test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () assert.equal(fs.existsSync(pidPath), false); }); -test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => { - assert.deepEqual(buildIosDeviceLogStreamArgs('00008150-0000AAAA'), [ +test('buildIosDeviceConsoleLaunchArgs builds expected devicectl command args', () => { + assert.deepEqual(buildIosDeviceConsoleLaunchArgs(IOS_DEVICE_ID, 'com.example.app'), [ 'devicectl', 'device', - 'log', - 'stream', + 'process', + 'launch', '--device', - '00008150-0000AAAA', + IOS_DEVICE_ID, + '--console', + '--terminate-existing', + 'com.example.app', ]); }); +test('startIosDeviceAppLog reports unsupported devicectl console capture before spawning', async () => { + const stream = makeAppLogWriteStream('agent-device-ios-device-log-'); + const { calls } = await withFakeDevicectl( + async () => ({ + stdout: IOS_DEVICE_HELP_WITHOUT_CONSOLE_CAPTURE, + stderr: '', + exitCode: 0, + }), + async () => { + await assert.rejects( + async () => await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.match(error.message, /iOS physical-device app console capture is not supported/); + assert.equal(error.details?.backend, 'ios-device'); + return true; + }, + ); + }, + ); + + await finished(stream).catch(() => {}); + assert.deepEqual(calls, [['device', 'process', 'launch', '--help']]); +}); + +test('startIosDeviceAppLog reports retryable failure when devicectl support probe fails', async () => { + const stream = makeAppLogWriteStream('agent-device-ios-device-log-timeout-'); + + await withFakeDevicectl( + async () => { + throw new Error('xcrun timed out after 5000ms'); + }, + async () => { + await assert.rejects( + async () => await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /Could not verify iOS physical-device app console capture/); + assert.equal(error.details?.stderr, 'xcrun timed out after 5000ms'); + return true; + }, + ); + }, + ); + + await finished(stream).catch(() => {}); +}); + +test('runAppLogDoctor reports supported iOS physical-device console capture', async () => { + const { result, calls } = await withFakeDevicectl( + async (args) => { + if (args.join(' ') === '--version') { + return { stdout: '506.6\n', stderr: '', exitCode: 0 }; + } + return { + stdout: IOS_DEVICE_CONSOLE_CAPTURE_HELP, + stderr: '', + exitCode: 0, + }; + }, + async () => await runAppLogDoctor(IOS_DEVICE, 'com.example.app'), + ); + + assert.deepEqual(calls, [['--version'], ['device', 'process', 'launch', '--help']]); + assert.equal(result.checks.devicectlAvailable, true); + assert.equal(result.checks.devicectlConsoleCapture, true); + assert.equal(result.notes.length, 0); +}); + +test('startIosDeviceAppLog marks clean devicectl console exit as ended', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-ios-device-console-')); + const fakeBinDir = path.join(root, 'bin'); + fs.mkdirSync(fakeBinDir); + const fakeXcrun = path.join(fakeBinDir, 'xcrun'); + fs.writeFileSync(fakeXcrun, '#!/bin/sh\nprintf "app output\\n"\nexit 0\n'); + fs.chmodSync(fakeXcrun, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = `${fakeBinDir}${path.delimiter}${previousPath ?? ''}`; + const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); + + try { + await withFakeDevicectl( + async () => ({ stdout: IOS_DEVICE_CONSOLE_CAPTURE_HELP, stderr: '', exitCode: 0 }), + async () => { + const appLog = await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []); + assert.equal(appLog.getState(), 'active'); + assert.equal((await appLog.wait).exitCode, 0); + assert.equal(appLog.getState(), 'ended'); + }, + ); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await finished(stream).catch(() => {}); + } +}); + test('buildIosSimulatorLogStreamArgs streams logs inside the simulator at info level', () => { assert.deepEqual( buildIosSimulatorLogStreamArgs({ diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts new file mode 100644 index 000000000..058a60076 --- /dev/null +++ b/src/daemon/app-log-doctor.ts @@ -0,0 +1,112 @@ +import { isIosFamily, isMacOs, type DeviceInfo } from '../kernel/device.ts'; +import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; +import { runAndroidAdb } from '../platforms/android/adb.ts'; +import { runCmd } from '../utils/exec.ts'; +import { + checkIosDeviceConsoleCaptureSupport, + IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, +} from './app-log-ios.ts'; + +export type AppLogDoctorResult = { + checks: Record; + notes: string[]; +}; + +export async function runAppLogDoctor( + device: DeviceInfo, + appBundleId?: string, +): Promise { + const checks: Record = {}; + const notes = buildAppLogDoctorNotes(appBundleId); + + if (device.platform === 'android') { + Object.assign(checks, await runAndroidAppLogDoctor(device, appBundleId)); + } + if (isIosFamily(device) && device.kind === 'simulator') { + Object.assign(checks, await runIosSimulatorAppLogDoctor()); + } + if (isIosFamily(device) && device.kind === 'device') { + const result = await runIosDeviceAppLogDoctor(); + Object.assign(checks, result.checks); + notes.push(...result.notes); + } + if (isMacOs(device)) { + Object.assign(checks, await runMacOsAppLogDoctor()); + } + return { checks, notes }; +} + +function buildAppLogDoctorNotes(appBundleId: string | undefined): string[] { + if (appBundleId) return []; + return ['No app bundle is tracked in this session. Run open first for app-scoped logs.']; +} + +async function runAndroidAppLogDoctor( + device: DeviceInfo, + appBundleId?: string, +): Promise> { + const checks: Record = {}; + checks.adbAvailable = await safeCheck(async () => { + const adb = await runAndroidAdb(device, ['shell', 'echo', 'ok'], { + allowFailure: true, + timeoutMs: 1_000, + }); + return adb.exitCode === 0; + }); + if (!appBundleId) return checks; + + checks.androidPidVisible = await safeCheck(async () => { + const pidof = await runAndroidAdb(device, ['shell', 'pidof', appBundleId], { + allowFailure: true, + timeoutMs: 1_000, + }); + return pidof.stdout.trim().length > 0; + }); + return checks; +} + +async function runIosSimulatorAppLogDoctor(): Promise> { + const simctlAvailable = await safeCheck(async () => { + const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); + return simctl.exitCode === 0; + }); + return { simctlAvailable }; +} + +async function runIosDeviceAppLogDoctor(): Promise { + const checks: Record = {}; + const notes: string[] = []; + checks.devicectlAvailable = await safeCheck(async () => { + const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); + return devicectl.exitCode === 0; + }); + if (!checks.devicectlAvailable) return { checks, notes }; + + const consoleCapture = await checkIosDeviceConsoleCaptureSupport(); + checks.devicectlConsoleCapture = consoleCapture.supported; + if (!consoleCapture.supported) { + if (consoleCapture.reason === 'probe-failed') { + notes.push(IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE); + } else { + notes.push(IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE); + } + } + return { checks, notes }; +} + +async function runMacOsAppLogDoctor(): Promise> { + const logAvailable = await safeCheck(async () => { + const log = await runCmd('log', ['help'], { allowFailure: true }); + return log.exitCode === 0; + }); + return { logAvailable }; +} + +async function safeCheck(check: () => Promise): Promise { + try { + return await check(); + } catch { + return false; + } +} diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index 9b14fa176..0d0521666 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -1,11 +1,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; -import { runCmd, runCmdBackground } from '../utils/exec.ts'; +import { AppError } from '../kernel/errors.ts'; +import { runCmd, runCmdBackground, type ExecResult } from '../utils/exec.ts'; import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; -import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts'; +import { + clearPidFile, + writePidFile, + type AppLogResult, + type AppLogState, +} from './app-log-process.ts'; import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; +export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED = { + message: 'iOS physical-device app console capture is not supported by the installed devicectl.', + hint: 'This devicectl does not expose process launch --console. Markers can still be written to app.log, but app output is not being captured. Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode until this Xcode toolchain exposes scriptable console capture.', +} as const; +const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED = { + message: 'Could not verify iOS physical-device app console capture support.', + hint: 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.', +} as const; +export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE = formatIosDeviceConsoleCaptureNote( + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, +); +export const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE = formatIosDeviceConsoleCaptureNote( + IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED, +); + +export type IosDeviceConsoleCaptureSupport = + | { supported: true; stderr?: string } + | { supported: false; reason: 'unsupported' | 'probe-failed'; stderr?: string }; + +let cachedSupportedIosDeviceConsoleCapture: IosDeviceConsoleCaptureSupport | undefined; + export function buildAppleLogPredicate( appBundleId: string, executableName?: string | undefined, @@ -55,8 +82,61 @@ export function buildIosSimulatorLogStreamArgs(params: { ); } -export function buildIosDeviceLogStreamArgs(deviceId: string): string[] { - return ['devicectl', 'device', 'log', 'stream', '--device', deviceId]; +export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { + return [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--console', + '--terminate-existing', + appBundleId, + ]; +} + +export async function checkIosDeviceConsoleCaptureSupport(): Promise { + if (cachedSupportedIosDeviceConsoleCapture) return cachedSupportedIosDeviceConsoleCapture; + try { + const result = await runXcrun(['devicectl', 'device', 'process', 'launch', '--help'], { + allowFailure: true, + timeoutMs: 5_000, + }); + const support = readIosDeviceConsoleCaptureSupport(result); + if (support.supported) cachedSupportedIosDeviceConsoleCapture = support; + return support; + } catch (error) { + return { + supported: false, + reason: 'probe-failed', + stderr: error instanceof Error ? error.message : undefined, + }; + } +} + +function readIosDeviceConsoleCaptureSupport(result: ExecResult): IosDeviceConsoleCaptureSupport { + const stderr = result.stderr.trim() || undefined; + if (result.exitCode !== 0) { + return { supported: false, reason: 'probe-failed', stderr }; + } + if (!isIosDeviceConsoleCaptureHelp(result.stdout, result.stderr)) { + return { supported: false, reason: 'unsupported', stderr }; + } + return { supported: true, stderr }; +} + +function formatIosDeviceConsoleCaptureNote(message: { message: string; hint: string }): string { + return `${message.message} ${message.hint}`; +} + +function isIosDeviceConsoleCaptureHelp(stdout: string, stderr: string): boolean { + const help = `${stdout}\n${stderr}`; + return ( + /\bUSAGE:\s+devicectl device process launch\b/i.test(help) && + /--console\b/.test(help) && + /--terminate-existing\b/.test(help) + ); } export async function readRecentIosSimulatorLogShowForBundle(params: { @@ -181,17 +261,41 @@ export async function startMacOsAppLog( export async function startIosDeviceAppLog( deviceId: string, + appBundleId: string, stream: fs.WriteStream, redactionPatterns: RegExp[], pidPath?: string, ): Promise { + const support = await checkIosDeviceConsoleCaptureSupport(); + if (!support.supported) { + stream.end(); + throw buildIosDeviceConsoleCaptureError(support); + } return startAppleAppLogStream({ backend: 'ios-device', cmd: 'xcrun', - args: buildIosDeviceLogStreamArgs(deviceId), + args: buildIosDeviceConsoleLaunchArgs(deviceId, appBundleId), stream, redactionPatterns, pidPath, + stopSignals: ['SIGKILL'], + }); +} + +function buildIosDeviceConsoleCaptureError( + support: Extract, +): AppError { + if (support.reason === 'probe-failed') { + return new AppError('COMMAND_FAILED', IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.message, { + backend: 'ios-device', + hint: IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.hint, + stderr: support.stderr, + }); + } + return new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { + backend: 'ios-device', + hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, + stderr: support.stderr, }); } @@ -202,8 +306,9 @@ function startAppleAppLogStream(params: { stream: fs.WriteStream; redactionPatterns: RegExp[]; pidPath?: string; + stopSignals?: NodeJS.Signals[]; }): AppLogResult { - let state: 'active' | 'failed' = 'active'; + let state: AppLogState = 'active'; const background = runCmdBackground(params.cmd, params.args, { allowFailure: true, captureOutput: false, @@ -219,7 +324,7 @@ function startAppleAppLogStream(params: { writer, }).then( (result) => { - if (result.exitCode !== 0) state = 'failed'; + state = result.exitCode === 0 ? 'ended' : 'failed'; clearPidFile(params.pidPath); return result; }, @@ -235,10 +340,10 @@ function startAppleAppLogStream(params: { startedAt: Date.now(), wait, stop: async () => { - if (!child.killed) child.kill('SIGINT'); - await waitForChildExit(wait); - if (!child.killed) child.kill('SIGKILL'); - await waitForChildExit(wait); + for (const signal of params.stopSignals ?? ['SIGINT', 'SIGKILL']) { + child.kill(signal); + await waitForChildExit(wait); + } clearPidFile(params.pidPath); }, }; diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index c9686d0de..f9d8513ae 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -6,7 +6,14 @@ import type { ExecResult } from '../utils/exec.ts'; export const APP_LOG_PID_FILENAME = 'app-log.pid'; -export type AppLogState = 'active' | 'recovering' | 'failed'; +export type AppLogState = 'active' | 'recovering' | 'ended' | 'failed'; + +export type AppLogFailure = { + backend: LogBackend; + code: string; + message: string; + hint?: string; +}; export type AppLogResult = { backend: LogBackend; @@ -42,7 +49,7 @@ function isManagedAppLogCommand(command: string): boolean { return ( normalized.includes('log stream') || normalized.includes('logcat') || - normalized.includes('devicectl device log stream') + normalized.includes('devicectl device process launch') ); } diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index de5255e39..fa9ff66ef 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -4,9 +4,6 @@ import { isIosFamily, isMacOs, type DeviceInfo } from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; import { tryGetPlugin } from '../core/platform-plugin/plugin.ts'; import { registerBuiltinPlatformPlugins } from '../core/interactors/register-builtins.ts'; -import { runCmd } from '../utils/exec.ts'; -import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; -import { runAndroidAdb } from '../platforms/android/adb.ts'; import { createScopedProvider } from '../utils/scoped-provider.ts'; import { assertAndroidPackageArgSafe, @@ -40,6 +37,7 @@ registerBuiltinPlatformPlugins(); export type { AppLogResult } from './app-log-process.ts'; export type { AppLogState } from './app-log-process.ts'; +export type { AppLogFailure } from './app-log-process.ts'; export { APP_LOG_PID_FILENAME, cleanupStaleAppLogProcesses } from './app-log-process.ts'; export { assertAndroidPackageArgSafe, @@ -47,14 +45,10 @@ export { } from './app-log-android.ts'; export { buildAppleLogPredicate, - buildIosDeviceLogStreamArgs, + buildIosDeviceConsoleLaunchArgs, buildIosSimulatorLogStreamArgs, } from './app-log-ios.ts'; - -export type AppLogDoctorResult = { - checks: Record; - notes: string[]; -}; +export { runAppLogDoctor, type AppLogDoctorResult } from './app-log-doctor.ts'; export type SessionNetworkCapture = { backend: LogBackend; @@ -79,6 +73,18 @@ export type AppLogStartRequest = { pidPath?: string; }; +type SessionNetworkCaptureParams = { + device: DeviceInfo; + appBundleId?: string; + appLogState?: AppLogState; + appLogStartedAt?: number; + appLogPath: string; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; + maxScanLines: number; +}; + export type AppLogProvider = { start(request: AppLogStartRequest): Promise; }; @@ -192,17 +198,9 @@ export function resolveLogBackend(device: DeviceInfo): LogBackend { return tryGetPlugin(device.platform)?.appLog?.resolveBackend(device) ?? 'android'; } -export async function readSessionNetworkCapture(params: { - device: DeviceInfo; - appBundleId?: string; - appLogState?: AppLogState; - appLogStartedAt?: number; - appLogPath: string; - maxEntries: number; - include: NetworkIncludeMode; - maxPayloadChars: number; - maxScanLines: number; -}): Promise { +export async function readSessionNetworkCapture( + params: SessionNetworkCaptureParams, +): Promise { const { device, appBundleId, @@ -247,6 +245,7 @@ export async function readSessionNetworkCapture(params: { } } } + const canRecoverIosSimulatorLogShow = isIosFamily(device) && device.kind === 'simulator' && Boolean(appBundleId); if (canRecoverIosSimulatorLogShow && dump.entries.length === 0) { @@ -360,7 +359,7 @@ async function startLocalAppLog({ const redactionPatterns = getAppLogRedactionPatterns(); if (isIosFamily(device)) { if (device.kind === 'device') { - return await startIosDeviceAppLog(device.id, stream, redactionPatterns, pidPath); + return await startIosDeviceAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); } return await startIosSimulatorAppLog( device.id, @@ -430,66 +429,6 @@ export async function stopAppLog(appLog: AppLogResult): Promise { await waitForChildExit(appLog.wait); } -export async function runAppLogDoctor( - device: DeviceInfo, - appBundleId?: string, -): Promise { - const checks: Record = {}; - const notes: string[] = []; - if (!appBundleId) { - notes.push( - 'No app bundle is tracked in this session. Run open first for app-scoped logs.', - ); - } - if (device.platform === 'android') { - try { - const adb = await runAndroidAdb(device, ['shell', 'echo', 'ok'], { - allowFailure: true, - timeoutMs: 1_000, - }); - checks.adbAvailable = adb.exitCode === 0; - } catch { - checks.adbAvailable = false; - } - if (appBundleId) { - try { - const pidof = await runAndroidAdb(device, ['shell', 'pidof', appBundleId], { - allowFailure: true, - timeoutMs: 1_000, - }); - checks.androidPidVisible = pidof.stdout.trim().length > 0; - } catch { - checks.androidPidVisible = false; - } - } - } - if (isIosFamily(device) && device.kind === 'simulator') { - try { - const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); - checks.simctlAvailable = simctl.exitCode === 0; - } catch { - checks.simctlAvailable = false; - } - } - if (isIosFamily(device) && device.kind === 'device') { - try { - const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); - checks.devicectlAvailable = devicectl.exitCode === 0; - } catch { - checks.devicectlAvailable = false; - } - } - if (isMacOs(device)) { - try { - const log = await runCmd('log', ['help'], { allowFailure: true }); - checks.logAvailable = log.exitCode === 0; - } catch { - checks.logAvailable = false; - } - } - return { checks, notes }; -} - export function appendAppLogMarker(outPath: string, marker: string): void { ensureLogPath(outPath); const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 42b0f29f1..18d4adc9c 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -78,7 +78,12 @@ vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { }); vi.mock('../../app-log.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, startAppLog: vi.fn(), stopAppLog: vi.fn(async () => {}) }; + return { + ...actual, + runAppLogDoctor: vi.fn(async () => ({ checks: {}, notes: [] })), + startAppLog: vi.fn(), + stopAppLog: vi.fn(async () => {}), + }; }); vi.mock('../session-deploy.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -102,6 +107,10 @@ import { LeaseRegistry } from '../../lease-registry.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; import { AppError } from '../../../kernel/errors.ts'; +import { + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, +} from '../../app-log-ios.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; @@ -125,7 +134,7 @@ import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId, } from '../../../platforms/apple/core/apps.ts'; -import { startAppLog, stopAppLog } from '../../app-log.ts'; +import { runAppLogDoctor, startAppLog, stopAppLog } from '../../app-log.ts'; import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts'; @@ -151,6 +160,7 @@ const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDee const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); const mockStartAppLog = vi.mocked(startAppLog); const mockStopAppLog = vi.mocked(stopAppLog); +const mockRunAppLogDoctor = vi.mocked(runAppLogDoctor); const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); @@ -210,6 +220,8 @@ beforeEach(() => { mockStartAppLog.mockReset(); mockStopAppLog.mockReset(); mockStopAppLog.mockResolvedValue(undefined); + mockRunAppLogDoctor.mockReset(); + mockRunAppLogDoctor.mockResolvedValue({ checks: {}, notes: [] }); mockDefaultInstallOpsIos.mockReset(); mockDefaultInstallOpsAndroid.mockReset(); mockDefaultReinstallOpsIos.mockReset(); @@ -4489,6 +4501,181 @@ test('logs clear --restart requires app session bundle id', async () => { } }); +function makeIosDeviceLogSession(): { + sessionStore: ReturnType; + sessionName: string; +} { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-console-logs'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'ios', + id: '00008150-0000AAAA', + name: 'iPhone', + kind: 'device', + }), + appBundleId: 'com.example.app', + }); + return { sessionStore, sessionName }; +} + +function mockIosDeviceLogBackend(): void { + mockStartAppLog.mockResolvedValue({ + backend: 'ios-device', + startedAt: 1_712_040_000_000, + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, + notes: [], + }); +} + +function mockUnsupportedIosDeviceLogBackend(): void { + mockStartAppLog.mockRejectedValue( + new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { + backend: 'ios-device', + hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, + }), + ); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, + notes: [IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE], + }); +} + +async function runLogsCommandForSession( + sessionStore: ReturnType, + sessionName: string, + action: 'clear' | 'path' | 'doctor', + flags: Record = {}, +) { + return await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: [action], + flags, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); +} + +function expectActiveIosDeviceLogsPath( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(true); + expect(response.data?.state).toBe('active'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.failureCode).toBeUndefined(); + expect(response.data?.failureMessage).toBeUndefined(); + expect(response.data?.startedAt).toBe('2024-04-02T06:40:00.000Z'); +} + +function expectEndedIosDeviceLogsPath(response: Awaited>) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(false); + expect(response.data?.state).toBe('ended'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.notes).toContain( + 'The app log stream process ended. Run logs clear --restart before the next capture window.', + ); +} + +function expectActiveIosDeviceLogsDoctor( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(true); + expect(response.data?.state).toBe('active'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.checks).toEqual({ + devicectlAvailable: true, + devicectlConsoleCapture: true, + }); + expect(response.data?.notes).toEqual([]); +} + +function expectUnsupportedIosDeviceLogsDoctor( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(false); + expect(response.data?.state).toBe('failed'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); + expect(response.data?.notes).toEqual([IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE]); +} + +test('logs clear --restart starts active iOS physical-device console capture', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + mockIosDeviceLogBackend(); + + const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { + restart: true, + }); + expect(restartResponse?.ok).toBe(true); + if (restartResponse && restartResponse.ok) { + expect(restartResponse.data?.restarted).toBe(true); + } + expect(mockStartAppLog).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: '00008150-0000AAAA' }), + 'com.example.app', + expect.stringContaining('app.log'), + expect.stringContaining('app-log.pid'), + ); + + expectActiveIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); + expectActiveIosDeviceLogsDoctor( + await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + ); +}); + +test('logs path reports cleanly ended iOS physical-device console capture as inactive', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + const session = sessionStore.get(sessionName); + if (!session) throw new Error('Expected test session'); + sessionStore.set(sessionName, { + ...session, + appLog: { + platform: 'apple', + backend: 'ios-device', + outPath: '/tmp/app.log', + startedAt: 1_712_040_000_000, + getState: () => 'ended', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + expectEndedIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); +}); + +test('logs doctor deduplicates unsupported iOS physical-device console capture notes', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + mockUnsupportedIosDeviceLogBackend(); + + const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { + restart: true, + }); + expect(restartResponse?.ok).toBe(false); + expectUnsupportedIosDeviceLogsDoctor( + await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + ); +}); + test('network requires an active session', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index bd7af1ff7..704e12557 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -25,6 +25,9 @@ import { runAppLogDoctor, startAppLog, stopAppLog, + type AppLogFailure, + type AppLogResult, + type AppLogState, } from '../app-log.ts'; import { buildPerfFramesResponseData, @@ -37,6 +40,7 @@ import { handleAudioCommand } from './session-audio.ts'; import { handleNativePerfCommand as handleAppleNativePerfCommand } from './session-perf-xctrace.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../kernel/contracts.ts'; import type { LogBackend } from '../network-log.ts'; +import { uniqueStrings } from '../action-utils.ts'; import { LOG_ACTION_VALUES as LOG_ACTIONS, type LogAction as LogsAction, @@ -57,6 +61,16 @@ type LogsHandlerParams = ObservabilityParams & { session: SessionState; restart: boolean; }; +type SessionLogStatus = { + active: boolean; + state: AppLogState | 'inactive'; + backend: LogBackend; + startedAt?: number; + failureCode?: string; + failureMessage?: string; + hint?: string; + notes?: string[]; +}; const LOG_ACTION_HANDLERS: Record< LogsAction, @@ -75,8 +89,98 @@ const LOG_ACTION_HANDLERS: Record< handleLogsStop(session, sessionName, sessionStore), }; -function resolveSessionLogBackendLabel(session: SessionState): LogBackend { - return session.appLog?.backend ?? resolveLogBackend(session.device); +function resolveSessionLogStatus(session: SessionState): SessionLogStatus { + if (session.appLog) { + const state = session.appLog.getState(); + const active = state === 'active' || state === 'recovering'; + return { + active, + state, + backend: session.appLog.backend, + startedAt: session.appLog.startedAt, + notes: buildAppLogStateNotes(state), + }; + } + if (session.appLogFailure) { + return { + active: false, + state: 'failed', + backend: session.appLogFailure.backend, + failureCode: session.appLogFailure.code, + failureMessage: session.appLogFailure.message, + hint: session.appLogFailure.hint, + notes: [buildAppLogFailureNote(session.appLogFailure)], + }; + } + return { + active: false, + state: 'inactive', + backend: resolveLogBackend(session.device), + }; +} + +function buildAppLogFailure( + normalized: ReturnType, + backend: LogBackend, +): AppLogFailure { + return { + backend, + code: normalized.code, + message: normalized.message, + hint: normalized.hint, + }; +} + +function buildAppLogFailureNote(failure: AppLogFailure): string { + return failure.hint ? `${failure.message} ${failure.hint}` : failure.message; +} + +function buildAppLogStateNotes(state: AppLogState): string[] | undefined { + if (state === 'failed') { + return [ + 'The app log stream process exited with an error. Run logs doctor for backend diagnostics.', + ]; + } + if (state === 'ended') { + return [ + 'The app log stream process ended. Run logs clear --restart before the next capture window.', + ]; + } + return undefined; +} + +function mergeLogDoctorNotes( + doctorNotes: string[], + status: Pick, +): string[] { + return uniqueStrings([...doctorNotes, ...(status.notes ?? [])]); +} + +function buildSessionAppLog( + session: SessionState, + outPath: string, + appLog: AppLogResult, +): NonNullable { + return { + ...appLog, + platform: session.device.platform, + outPath, + }; +} + +function storeAppLogStartFailure( + sessionStore: SessionStore, + sessionName: string, + session: SessionState, + error: unknown, +): DaemonFailureResponse { + const normalized = normalizeError(error); + sessionStore.set(sessionName, { + ...session, + appLog: undefined, + appLogFailure: buildAppLogFailure(normalized, resolveLogBackend(session.device)), + }); + return { ok: false, error: normalized }; } export async function handleSessionObservabilityCommands( @@ -335,19 +439,23 @@ function handleLogsPath( ): DaemonResponse { const logPath = sessionStore.resolveAppLogPath(sessionName); const metadata = getAppLogPathMetadata(logPath); + const status = resolveSessionLogStatus(session); return { ok: true, data: { path: logPath, - active: Boolean(session.appLog), - state: session.appLog?.getState() ?? 'inactive', - backend: resolveSessionLogBackendLabel(session), + active: status.active, + state: status.state, + backend: status.backend, sizeBytes: metadata.sizeBytes, modifiedAt: metadata.modifiedAt, - startedAt: session.appLog?.startedAt - ? new Date(session.appLog.startedAt).toISOString() - : undefined, - hint: 'Grep the file for token-efficient debugging, e.g. grep -n "Error\\|Exception" ', + startedAt: status.startedAt ? new Date(status.startedAt).toISOString() : undefined, + failureCode: status.failureCode, + failureMessage: status.failureMessage, + hint: + status.hint ?? + 'Grep the file for token-efficient debugging, e.g. grep -n "Error\\|Exception" ', + notes: status.notes, }, }; } @@ -359,14 +467,19 @@ async function handleLogsDoctor( ): Promise { const logPath = sessionStore.resolveAppLogPath(sessionName); const doctor = await runAppLogDoctor(session.device, session.appBundleId); + const status = resolveSessionLogStatus(session); return { ok: true, data: { path: logPath, - active: Boolean(session.appLog), - state: session.appLog?.getState() ?? 'inactive', + active: status.active, + state: status.state, + backend: status.backend, checks: doctor.checks, - notes: doctor.notes, + failureCode: status.failureCode, + failureMessage: status.failureMessage, + hint: status.hint, + notes: mergeLogDoctorNotes(doctor.notes, status), }, }; } @@ -396,7 +509,9 @@ async function handleLogsClear( } const logPath = sessionStore.resolveAppLogPath(sessionName); if (!restart) { - return { ok: true, data: clearAppLogFiles(logPath) }; + const cleared = clearAppLogFiles(logPath); + sessionStore.set(sessionName, { ...session, appLogFailure: undefined }); + return { ok: true, data: cleared }; } const appBundleId = session.appBundleId; if (!appBundleId) { @@ -415,20 +530,12 @@ async function handleLogsClear( const appLogStream = await startAppLog(session.device, appBundleId, logPath, appLogPidPath); sessionStore.set(sessionName, { ...session, - appLog: { - platform: session.device.platform, - backend: appLogStream.backend, - outPath: logPath, - startedAt: appLogStream.startedAt, - getState: appLogStream.getState, - stop: appLogStream.stop, - wait: appLogStream.wait, - }, + appLog: buildSessionAppLog(session, logPath, appLogStream), + appLogFailure: undefined, }); return { ok: true, data: { ...cleared, restarted: true } }; } catch (err) { - sessionStore.set(sessionName, { ...session, appLog: undefined }); - return { ok: false, error: normalizeError(err) }; + return storeAppLogStartFailure(sessionStore, sessionName, session, err); } } @@ -458,19 +565,12 @@ async function handleLogsStart( ); sessionStore.set(sessionName, { ...session, - appLog: { - platform: session.device.platform, - backend: appLogStream.backend, - outPath: appLogPath, - startedAt: appLogStream.startedAt, - getState: appLogStream.getState, - stop: appLogStream.stop, - wait: appLogStream.wait, - }, + appLog: buildSessionAppLog(session, appLogPath, appLogStream), + appLogFailure: undefined, }); return { ok: true, data: { path: appLogPath, started: true } }; } catch (err) { - return { ok: false, error: normalizeError(err) }; + return storeAppLogStartFailure(sessionStore, sessionName, session, err); } } @@ -484,7 +584,7 @@ async function handleLogsStop( } const outPath = session.appLog.outPath; await stopAppLog(session.appLog); - sessionStore.set(sessionName, { ...session, appLog: undefined }); + sessionStore.set(sessionName, { ...session, appLog: undefined, appLogFailure: undefined }); return { ok: true, data: { path: outPath, stopped: true } }; } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index f1e3aa968..09c49d41a 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,7 +18,7 @@ import type { RecordingExportQuality } from '../core/recording-export-quality.ts import type { DeviceInfo, Platform, PlatformSelector } from '../kernel/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; -import type { AppLogState } from './app-log-process.ts'; +import type { AppLogFailure, AppLogState } from './app-log-process.ts'; import type { DeviceLease } from './lease-registry.ts'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; import type { @@ -329,6 +329,7 @@ export type SessionState = { stop: () => Promise; wait: Promise; }; + appLogFailure?: AppLogFailure; }; export type SessionReplayControl = diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index c471c7533..98721f533 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1645,6 +1645,7 @@ test('usageForCommand resolves debugging help topic', () => { 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, /relaunches the session app through devicectl process launch --console/); 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/);