From 5eda03700bb1cea0f5537cc9623968b66732fbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 14:33:18 +0200 Subject: [PATCH 1/9] fix: report unsupported ios device log streaming --- src/daemon/__tests__/app-log.test.ts | 114 +++++++++++++++++ src/daemon/app-log-ios.ts | 40 ++++++ src/daemon/app-log-process.ts | 8 ++ src/daemon/app-log.ts | 11 ++ src/daemon/handlers/__tests__/session.test.ts | 116 +++++++++++++++++- src/daemon/handlers/session-observability.ts | 102 ++++++++++++--- src/daemon/types.ts | 3 +- 7 files changed, 376 insertions(+), 18 deletions(-) diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index 12a7b6b0b..216c53bc9 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -3,6 +3,9 @@ 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 { AppError } from '../../kernel/errors.ts'; +import { withAppleToolProvider } from '../../platforms/apple/core/tool-provider.ts'; import { APP_LOG_PID_FILENAME, assertAndroidPackageArgSafe, @@ -13,6 +16,7 @@ import { runAppLogDoctor, rotateAppLogIfNeeded, } from '../app-log.ts'; +import { startIosDeviceAppLog } from '../app-log-ios.ts'; test('buildAppleLogPredicate includes bundle-aware filters', () => { const predicate = buildAppleLogPredicate('com.example.app'); @@ -75,6 +79,116 @@ test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => ]); }); +test('startIosDeviceAppLog reports unsupported devicectl log stream before spawning', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-ios-device-log-')); + const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); + const devicectlCalls: string[][] = []; + + await withAppleToolProvider( + { + runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + devicectl: { + run: async (args) => { + devicectlCalls.push(args); + return { + stdout: + 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n', + stderr: '', + exitCode: 0, + }; + }, + }, + whichCommand: async () => false, + }, + async () => { + await assert.rejects( + async () => await startIosDeviceAppLog('00008150-0000AAAA', stream, []), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.match(error.message, /iOS physical-device app log streaming is not supported/); + assert.equal(error.details?.backend, 'ios-device'); + return true; + }, + ); + }, + ); + + await finished(stream).catch(() => {}); + assert.deepEqual(devicectlCalls, [['device', 'log', 'stream', '--help']]); +}); + +test('startIosDeviceAppLog reports unsupported when devicectl support probe fails', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-ios-device-log-timeout-')); + const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); + + await withAppleToolProvider( + { + runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + devicectl: { + run: async () => { + throw new Error('xcrun timed out after 5000ms'); + }, + }, + whichCommand: async () => false, + }, + async () => { + await assert.rejects( + async () => await startIosDeviceAppLog('00008150-0000AAAA', stream, []), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.match(error.message, /iOS physical-device app log streaming is not supported/); + assert.equal(error.details?.stderr, 'xcrun timed out after 5000ms'); + return true; + }, + ); + }, + ); + + await finished(stream).catch(() => {}); +}); + +test('runAppLogDoctor reports unsupported iOS physical-device log stream', async () => { + const devicectlCalls: string[][] = []; + const result = await withAppleToolProvider( + { + runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + devicectl: { + run: async (args) => { + devicectlCalls.push(args); + if (args.join(' ') === '--version') { + return { stdout: '506.6\n', stderr: '', exitCode: 0 }; + } + return { + stdout: + 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n', + stderr: '', + exitCode: 0, + }; + }, + }, + whichCommand: async () => false, + }, + async () => + await runAppLogDoctor( + { + platform: 'apple', + appleOs: 'ios', + id: '00008150-0000AAAA', + name: 'iPhone', + kind: 'device', + }, + 'com.example.app', + ), + ); + + assert.deepEqual(devicectlCalls, [['--version'], ['device', 'log', 'stream', '--help']]); + assert.equal(result.checks.devicectlAvailable, true); + assert.equal(result.checks.devicectlDeviceLogStream, false); + assert.ok(result.notes.some((note) => note.includes('does not expose'))); +}); + test('buildIosSimulatorLogStreamArgs streams logs inside the simulator at info level', () => { assert.deepEqual( buildIosSimulatorLogStreamArgs({ diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index 9b14fa176..f45d678ba 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -1,11 +1,17 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; +import { AppError } from '../kernel/errors.ts'; import { runCmd, runCmdBackground } from '../utils/exec.ts'; import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts'; import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; +const IOS_DEVICE_LOG_STREAM_UNSUPPORTED_MESSAGE = + 'iOS physical-device app log streaming is not supported by the installed devicectl.'; +const IOS_DEVICE_LOG_STREAM_UNSUPPORTED_HINT = + 'This devicectl does not expose a device log stream subcommand. 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 a scriptable stream.'; + export function buildAppleLogPredicate( appBundleId: string, executableName?: string | undefined, @@ -59,6 +65,31 @@ export function buildIosDeviceLogStreamArgs(deviceId: string): string[] { return ['devicectl', 'device', 'log', 'stream', '--device', deviceId]; } +export async function checkIosDeviceLogStreamSupport(): Promise<{ + supported: boolean; + stderr?: string; +}> { + try { + const result = await runXcrun(['devicectl', 'device', 'log', 'stream', '--help'], { + allowFailure: true, + timeoutMs: 5_000, + }); + return { + supported: result.exitCode === 0 && isIosDeviceLogStreamHelp(result.stdout, result.stderr), + stderr: result.stderr.trim() || undefined, + }; + } catch (error) { + return { + supported: false, + stderr: error instanceof Error ? error.message : undefined, + }; + } +} + +function isIosDeviceLogStreamHelp(stdout: string, stderr: string): boolean { + return /\bUSAGE:\s+devicectl device log stream\b/i.test(`${stdout}\n${stderr}`); +} + export async function readRecentIosSimulatorLogShowForBundle(params: { deviceId: string; appBundleId: string; @@ -185,6 +216,15 @@ export async function startIosDeviceAppLog( redactionPatterns: RegExp[], pidPath?: string, ): Promise { + const support = await checkIosDeviceLogStreamSupport(); + if (!support.supported) { + stream.end(); + throw new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_LOG_STREAM_UNSUPPORTED_MESSAGE, { + backend: 'ios-device', + hint: IOS_DEVICE_LOG_STREAM_UNSUPPORTED_HINT, + stderr: support.stderr, + }); + } return startAppleAppLogStream({ backend: 'ios-device', cmd: 'xcrun', diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index c9686d0de..183a82518 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -8,6 +8,14 @@ export const APP_LOG_PID_FILENAME = 'app-log.pid'; export type AppLogState = 'active' | 'recovering' | 'failed'; +export type AppLogFailure = { + backend: LogBackend; + code: string; + message: string; + hint?: string; + occurredAt: number; +}; + export type AppLogResult = { backend: LogBackend; getState: () => AppLogState; diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index de5255e39..f5fd85783 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -16,6 +16,7 @@ import { startAndroidAppLog, } from './app-log-android.ts'; import { + checkIosDeviceLogStreamSupport, readRecentIosSimulatorLogShowForBundle, startIosDeviceAppLog, startIosSimulatorAppLog, @@ -40,6 +41,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, @@ -475,6 +477,15 @@ export async function runAppLogDoctor( try { const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); checks.devicectlAvailable = devicectl.exitCode === 0; + if (checks.devicectlAvailable) { + const logStream = await checkIosDeviceLogStreamSupport(); + checks.devicectlDeviceLogStream = logStream.supported; + if (!logStream.supported) { + notes.push( + 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ); + } + } } catch { checks.devicectlAvailable = false; } diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 42b0f29f1..e6c89e8b7 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(); @@ -125,7 +130,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 +156,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 +216,8 @@ beforeEach(() => { mockStartAppLog.mockReset(); mockStopAppLog.mockReset(); mockStopAppLog.mockResolvedValue(undefined); + mockRunAppLogDoctor.mockReset(); + mockRunAppLogDoctor.mockResolvedValue({ checks: {}, notes: [] }); mockDefaultInstallOpsIos.mockReset(); mockDefaultInstallOpsAndroid.mockReset(); mockDefaultReinstallOpsIos.mockReset(); @@ -4489,6 +4497,110 @@ test('logs clear --restart requires app session bundle id', async () => { } }); +test('logs path and doctor report unsupported iOS physical-device backend as inactive failure', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-logs-unsupported'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'ios', + id: '00008150-0000AAAA', + name: 'iPhone', + kind: 'device', + }), + appBundleId: 'com.example.app', + }); + mockStartAppLog.mockRejectedValue( + new AppError( + 'UNSUPPORTED_OPERATION', + 'iOS physical-device app log streaming is not supported by the installed devicectl.', + { + backend: 'ios-device', + hint: 'Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode.', + }, + ), + ); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlDeviceLogStream: false }, + notes: ['Installed devicectl does not expose a scriptable iOS physical-device app log stream.'], + }); + + const restartResponse = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['clear'], + flags: { restart: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(restartResponse?.ok).toBe(false); + if (restartResponse && !restartResponse.ok) { + expect(restartResponse.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(restartResponse.error.hint).toMatch(/Console\.app\/Xcode/); + } + + const pathResponse = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['path'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(pathResponse?.ok).toBe(true); + if (pathResponse && pathResponse.ok) { + expect(pathResponse.data?.active).toBe(false); + expect(pathResponse.data?.state).toBe('failed'); + expect(pathResponse.data?.backend).toBe('ios-device'); + expect(pathResponse.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); + expect(pathResponse.data?.failureMessage).toMatch(/physical-device app log streaming/); + expect(pathResponse.data?.hint).toMatch(/Console\.app\/Xcode/); + expect(pathResponse.data?.notes).toContain( + 'iOS physical-device app log streaming is not supported by the installed devicectl.', + ); + } + + const doctorResponse = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['doctor'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(doctorResponse?.ok).toBe(true); + if (doctorResponse && doctorResponse.ok) { + expect(doctorResponse.data?.active).toBe(false); + expect(doctorResponse.data?.state).toBe('failed'); + expect(doctorResponse.data?.backend).toBe('ios-device'); + expect(doctorResponse.data?.checks).toEqual({ + devicectlAvailable: true, + devicectlDeviceLogStream: false, + }); + expect(doctorResponse.data?.notes).toContain( + 'Installed devicectl does not expose a scriptable iOS physical-device app log stream.', + ); + expect(doctorResponse.data?.notes).toContain( + 'iOS physical-device app log streaming is not supported by the installed devicectl.', + ); + } +}); + 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..64678ff8c 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -37,6 +37,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 type { AppLogFailure, AppLogState } from '../app-log.ts'; import { LOG_ACTION_VALUES as LOG_ACTIONS, type LogAction as LogsAction, @@ -57,6 +58,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 +86,47 @@ 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(); + return { + active: state !== 'failed', + state, + backend: session.appLog.backend, + startedAt: session.appLog.startedAt, + notes: + state === 'failed' + ? ['The app log stream process exited. Run logs doctor for backend diagnostics.'] + : undefined, + }; + } + 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: [session.appLogFailure.message], + }; + } + return { + active: false, + state: 'inactive', + backend: resolveLogBackend(session.device), + }; +} + +function buildAppLogFailure(error: unknown, backend: LogBackend): AppLogFailure { + const normalized = normalizeError(error); + return { + backend, + code: normalized.code, + message: normalized.message, + hint: normalized.hint, + occurredAt: Date.now(), + }; } export async function handleSessionObservabilityCommands( @@ -335,19 +385,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 +413,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: [...doctor.notes, ...(status.notes ?? [])], }, }; } @@ -396,7 +455,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) { @@ -424,10 +485,15 @@ async function handleLogsClear( stop: appLogStream.stop, wait: appLogStream.wait, }, + appLogFailure: undefined, }); return { ok: true, data: { ...cleared, restarted: true } }; } catch (err) { - sessionStore.set(sessionName, { ...session, appLog: undefined }); + sessionStore.set(sessionName, { + ...session, + appLog: undefined, + appLogFailure: buildAppLogFailure(err, resolveLogBackend(session.device)), + }); return { ok: false, error: normalizeError(err) }; } } @@ -467,9 +533,15 @@ async function handleLogsStart( stop: appLogStream.stop, wait: appLogStream.wait, }, + appLogFailure: undefined, }); return { ok: true, data: { path: appLogPath, started: true } }; } catch (err) { + sessionStore.set(sessionName, { + ...session, + appLog: undefined, + appLogFailure: buildAppLogFailure(err, resolveLogBackend(session.device)), + }); return { ok: false, error: normalizeError(err) }; } } @@ -484,7 +556,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 = From 5f33a5f04f6e670d8587729a408df941ab457653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 14:54:55 +0200 Subject: [PATCH 2/9] docs: clarify test app device verification --- AGENTS.md | 1 + examples/test-app/README.md | 53 +++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) 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..a06347d10 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,11 @@ 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. ## Local Agent Device suites From b607ae3917911cff782209f3da26412fa199e79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 15:01:20 +0200 Subject: [PATCH 3/9] docs: clarify alternate metro port flow --- examples/test-app/README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/examples/test-app/README.md b/examples/test-app/README.md index a06347d10..583f879e6 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -134,6 +134,39 @@ 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 The repo includes two local suites for iterating on the fixture app: From 5ee7e70f9e0f46403318b764807792fabd0c4684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 15:10:40 +0200 Subject: [PATCH 4/9] refactor: split app log diagnostics helpers --- src/daemon/app-log.ts | 355 +++++++++++------- src/daemon/handlers/__tests__/session.test.ts | 128 ++++--- 2 files changed, 284 insertions(+), 199 deletions(-) diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index f5fd85783..24fbc4281 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -81,6 +81,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; }; @@ -194,107 +206,28 @@ 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 { - const { - device, - appBundleId, - appLogState, - appLogStartedAt, - appLogPath, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - } = params; +export async function readSessionNetworkCapture( + params: SessionNetworkCaptureParams, +): Promise { + const { device, appLogState } = params; const backend = resolveLogBackend(device); - let dump = readRecentNetworkTraffic(appLogPath, { - backend, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); + let dump = readSessionAppNetworkDump(params, backend); const notes: string[] = []; - const androidRecovery = await resolveAndroidNetworkRecoveryContext({ - device, - appBundleId, - appLogPath, - appLogState, - }); + const androidRecovery = await recoverAndroidNetworkDump(params, dump); if (androidRecovery) { - const recovered = await readRecentAndroidLogcatForPackage(device.id, appBundleId as string); - if (recovered) { - const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { - path: `${appLogPath} (adb logcat recovery)`, - backend: 'android', - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); - if (recoveredDump.entries.length > 0) { - dump = mergeNetworkDumps(recoveredDump, dump, maxEntries); - notes.push(buildAndroidRecoveryNote(androidRecovery, recovered.recoveredPids)); - } - } - } - const canRecoverIosSimulatorLogShow = - isIosFamily(device) && device.kind === 'simulator' && Boolean(appBundleId); - if (canRecoverIosSimulatorLogShow && dump.entries.length === 0) { - const recovered = await readRecentIosSimulatorNetworkCapture({ - deviceId: device.id, - appBundleId: appBundleId as string, - startedAt: appLogStartedAt, - simulatorSetPath: device.simulatorSetPath, - appLogPath, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); - if (recovered) { - if (recovered.dump.entries.length > 0) { - dump = mergeNetworkDumps(recovered.dump, dump, maxEntries); - notes.push( - `Recovered ${recovered.dump.entries.length} iOS simulator HTTP entr${ - recovered.dump.entries.length === 1 ? 'y' : 'ies' - } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`, - ); - } else if (recovered.recoveredLineCount > 0) { - notes.push( - `Recovered ${recovered.recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, - ); - } - } + dump = androidRecovery.dump; + notes.push(androidRecovery.note); } - if (appLogState === undefined) { - notes.push( - 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', - ); - } else if (appLogState !== 'active' && notes.length === 0) { - if (isIosFamily(device) && device.kind === 'simulator') { - notes.push( - 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', - ); - } else { - notes.push( - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ); - } + const iosRecovery = await recoverIosSimulatorNetworkDump(params, dump); + if (iosRecovery) { + dump = iosRecovery.dump; + notes.push(...iosRecovery.notes); } + notes.push(...buildAppLogStateNotes(device, appLogState, notes.length > 0)); + if (dump.entries.length === 0) { notes.push(buildNoHttpEntriesNote(device)); } @@ -302,6 +235,122 @@ export async function readSessionNetworkCapture(params: { return { backend, dump, notes }; } +function readSessionAppNetworkDump( + params: SessionNetworkCaptureParams, + backend: LogBackend, +): NetworkDump { + return readRecentNetworkTraffic(params.appLogPath, { + backend, + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); +} + +async function recoverAndroidNetworkDump( + params: SessionNetworkCaptureParams, + currentDump: NetworkDump, +): Promise<{ dump: NetworkDump; note: string } | null> { + const recovery = await resolveAndroidNetworkRecoveryContext(params); + if (!recovery || !params.appBundleId) return null; + + const recovered = await readRecentAndroidLogcatForPackage(params.device.id, params.appBundleId); + if (!recovered) return null; + + const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { + path: `${params.appLogPath} (adb logcat recovery)`, + backend: 'android', + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); + if (recoveredDump.entries.length === 0) return null; + + return { + dump: mergeNetworkDumps(recoveredDump, currentDump, params.maxEntries), + note: buildAndroidRecoveryNote(recovery, recovered.recoveredPids), + }; +} + +async function recoverIosSimulatorNetworkDump( + params: SessionNetworkCaptureParams, + currentDump: NetworkDump, +): Promise<{ dump: NetworkDump; notes: string[] } | null> { + if (!canRecoverIosSimulatorLogShow(params, currentDump)) return null; + + const recovered = await readRecentIosSimulatorNetworkCapture({ + deviceId: params.device.id, + appBundleId: params.appBundleId as string, + startedAt: params.appLogStartedAt, + simulatorSetPath: params.device.simulatorSetPath, + appLogPath: params.appLogPath, + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); + if (!recovered) return null; + + return { + dump: + recovered.dump.entries.length > 0 + ? mergeNetworkDumps(recovered.dump, currentDump, params.maxEntries) + : currentDump, + notes: buildIosSimulatorRecoveryNotes(recovered), + }; +} + +function canRecoverIosSimulatorLogShow( + params: SessionNetworkCaptureParams, + dump: NetworkDump, +): boolean { + return ( + isIosFamily(params.device) && + params.device.kind === 'simulator' && + Boolean(params.appBundleId) && + dump.entries.length === 0 + ); +} + +function buildIosSimulatorRecoveryNotes(recovered: IosSimulatorNetworkRecovery): string[] { + if (recovered.dump.entries.length > 0) { + return [ + `Recovered ${recovered.dump.entries.length} iOS simulator HTTP entr${ + recovered.dump.entries.length === 1 ? 'y' : 'ies' + } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`, + ]; + } + if (recovered.recoveredLineCount > 0) { + return [ + `Recovered ${recovered.recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, + ]; + } + return []; +} + +function buildAppLogStateNotes( + device: DeviceInfo, + appLogState: AppLogState | undefined, + alreadyRecovered: boolean, +): string[] { + if (appLogState === undefined) { + return [ + 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', + ]; + } + if (appLogState === 'active' || alreadyRecovered) return []; + if (isIosFamily(device) && device.kind === 'simulator') { + return [ + 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', + ]; + } + return [ + 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ]; +} + async function resolveAndroidNetworkRecoveryContext(params: { device: DeviceInfo; appBundleId?: string; @@ -444,63 +493,89 @@ export async function runAppLogDoctor( ); } 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; - } - } + Object.assign(checks, await runAndroidAppLogDoctor(device, appBundleId)); } if (isIosFamily(device) && device.kind === 'simulator') { - try { - const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); - checks.simctlAvailable = simctl.exitCode === 0; - } catch { - checks.simctlAvailable = false; - } + Object.assign(checks, await runIosSimulatorAppLogDoctor()); } if (isIosFamily(device) && device.kind === 'device') { - try { - const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); - checks.devicectlAvailable = devicectl.exitCode === 0; - if (checks.devicectlAvailable) { - const logStream = await checkIosDeviceLogStreamSupport(); - checks.devicectlDeviceLogStream = logStream.supported; - if (!logStream.supported) { - notes.push( - 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ); - } - } - } catch { - checks.devicectlAvailable = false; - } + const result = await runIosDeviceAppLogDoctor(); + Object.assign(checks, result.checks); + notes.push(...result.notes); } if (isMacOs(device)) { - try { - const log = await runCmd('log', ['help'], { allowFailure: true }); - checks.logAvailable = log.exitCode === 0; - } catch { - checks.logAvailable = false; - } + Object.assign(checks, await runMacOsAppLogDoctor()); } return { checks, notes }; } +async function runAndroidAppLogDoctor( + device: DeviceInfo, + appBundleId?: string, +): Promise> { + const checks: Record = {}; + 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) return checks; + + 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; + } + return checks; +} + +async function runIosSimulatorAppLogDoctor(): Promise> { + try { + const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); + return { simctlAvailable: simctl.exitCode === 0 }; + } catch { + return { simctlAvailable: false }; + } +} + +async function runIosDeviceAppLogDoctor(): Promise { + const checks: Record = {}; + const notes: string[] = []; + try { + const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); + checks.devicectlAvailable = devicectl.exitCode === 0; + } catch { + checks.devicectlAvailable = false; + } + if (!checks.devicectlAvailable) return { checks, notes }; + + const logStream = await checkIosDeviceLogStreamSupport(); + checks.devicectlDeviceLogStream = logStream.supported; + if (!logStream.supported) { + notes.push( + 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ); + } + return { checks, notes }; +} + +async function runMacOsAppLogDoctor(): Promise> { + try { + const log = await runCmd('log', ['help'], { allowFailure: true }); + return { logAvailable: log.exitCode === 0 }; + } catch { + return { logAvailable: false }; + } +} + 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 e6c89e8b7..dceb9e0e6 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -4497,7 +4497,10 @@ test('logs clear --restart requires app session bundle id', async () => { } }); -test('logs path and doctor report unsupported iOS physical-device backend as inactive failure', async () => { +function makeUnsupportedIosDeviceLogSession(): { + sessionStore: ReturnType; + sessionName: string; +} { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-logs-unsupported'; sessionStore.set(sessionName, { @@ -4510,6 +4513,10 @@ test('logs path and doctor report unsupported iOS physical-device backend as ina }), appBundleId: 'com.example.app', }); + return { sessionStore, sessionName }; +} + +function mockUnsupportedIosDeviceLogBackend(): void { mockStartAppLog.mockRejectedValue( new AppError( 'UNSUPPORTED_OPERATION', @@ -4524,81 +4531,84 @@ test('logs path and doctor report unsupported iOS physical-device backend as ina checks: { devicectlAvailable: true, devicectlDeviceLogStream: false }, notes: ['Installed devicectl does not expose a scriptable iOS physical-device app log stream.'], }); +} - const restartResponse = await handleSessionCommands({ +async function runLogsCommandForSession( + sessionStore: ReturnType, + sessionName: string, + action: 'clear' | 'path' | 'doctor', + flags: Record = {}, +) { + return await handleSessionCommands({ req: { token: 't', session: sessionName, command: 'logs', - positionals: ['clear'], - flags: { restart: true }, + positionals: [action], + flags, }, sessionName, logPath: path.join(os.tmpdir(), 'daemon.log'), sessionStore, invoke: noopInvoke, }); +} + +function expectUnsupportedIosDeviceLogsPath( + 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?.failureMessage).toMatch(/physical-device app log streaming/); + expect(response.data?.hint).toMatch(/Console\.app\/Xcode/); + expect(response.data?.notes).toContain( + 'iOS physical-device app log streaming is not supported by the installed devicectl.', + ); +} + +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?.checks).toEqual({ + devicectlAvailable: true, + devicectlDeviceLogStream: false, + }); + expect(response.data?.notes).toContain( + 'Installed devicectl does not expose a scriptable iOS physical-device app log stream.', + ); + expect(response.data?.notes).toContain( + 'iOS physical-device app log streaming is not supported by the installed devicectl.', + ); +} + +test('logs path and doctor report unsupported iOS physical-device backend as inactive failure', async () => { + const { sessionStore, sessionName } = makeUnsupportedIosDeviceLogSession(); + mockUnsupportedIosDeviceLogBackend(); + + const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { + restart: true, + }); expect(restartResponse?.ok).toBe(false); if (restartResponse && !restartResponse.ok) { expect(restartResponse.error.code).toBe('UNSUPPORTED_OPERATION'); expect(restartResponse.error.hint).toMatch(/Console\.app\/Xcode/); } - const pathResponse = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['path'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(pathResponse?.ok).toBe(true); - if (pathResponse && pathResponse.ok) { - expect(pathResponse.data?.active).toBe(false); - expect(pathResponse.data?.state).toBe('failed'); - expect(pathResponse.data?.backend).toBe('ios-device'); - expect(pathResponse.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); - expect(pathResponse.data?.failureMessage).toMatch(/physical-device app log streaming/); - expect(pathResponse.data?.hint).toMatch(/Console\.app\/Xcode/); - expect(pathResponse.data?.notes).toContain( - 'iOS physical-device app log streaming is not supported by the installed devicectl.', - ); - } - - const doctorResponse = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['doctor'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(doctorResponse?.ok).toBe(true); - if (doctorResponse && doctorResponse.ok) { - expect(doctorResponse.data?.active).toBe(false); - expect(doctorResponse.data?.state).toBe('failed'); - expect(doctorResponse.data?.backend).toBe('ios-device'); - expect(doctorResponse.data?.checks).toEqual({ - devicectlAvailable: true, - devicectlDeviceLogStream: false, - }); - expect(doctorResponse.data?.notes).toContain( - 'Installed devicectl does not expose a scriptable iOS physical-device app log stream.', - ); - expect(doctorResponse.data?.notes).toContain( - 'iOS physical-device app log streaming is not supported by the installed devicectl.', - ); - } + expectUnsupportedIosDeviceLogsPath( + await runLogsCommandForSession(sessionStore, sessionName, 'path'), + ); + expectUnsupportedIosDeviceLogsDoctor( + await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + ); }); test('network requires an active session', async () => { From 5fb810283c7a9e235245dce928a3971cf0248cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 17:15:33 +0200 Subject: [PATCH 5/9] refactor: tighten app log diagnostics shape --- src/daemon/__tests__/app-log.test.ts | 136 ++++++++++--------- src/daemon/app-log-doctor.ts | 106 +++++++++++++++ src/daemon/app-log.ts | 105 +------------- src/daemon/handlers/session-observability.ts | 75 +++++----- 4 files changed, 218 insertions(+), 204 deletions(-) create mode 100644 src/daemon/app-log-doctor.ts diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index 216c53bc9..276b70347 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -4,8 +4,10 @@ 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, @@ -18,6 +20,45 @@ import { } 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_LOG_STREAM_UNAVAILABLE_HELP = + 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n'; + +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'); assert.match(predicate, /subsystem == "com\.example\.app"/); @@ -69,40 +110,27 @@ test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () }); test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => { - assert.deepEqual(buildIosDeviceLogStreamArgs('00008150-0000AAAA'), [ + assert.deepEqual(buildIosDeviceLogStreamArgs(IOS_DEVICE_ID), [ 'devicectl', 'device', 'log', 'stream', '--device', - '00008150-0000AAAA', + IOS_DEVICE_ID, ]); }); test('startIosDeviceAppLog reports unsupported devicectl log stream before spawning', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-ios-device-log-')); - const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); - const devicectlCalls: string[][] = []; - - await withAppleToolProvider( - { - runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - devicectl: { - run: async (args) => { - devicectlCalls.push(args); - return { - stdout: - 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n', - stderr: '', - exitCode: 0, - }; - }, - }, - whichCommand: async () => false, - }, + const stream = makeAppLogWriteStream('agent-device-ios-device-log-'); + const { calls } = await withFakeDevicectl( + async () => ({ + stdout: IOS_DEVICE_LOG_STREAM_UNAVAILABLE_HELP, + stderr: '', + exitCode: 0, + }), async () => { await assert.rejects( - async () => await startIosDeviceAppLog('00008150-0000AAAA', stream, []), + async () => await startIosDeviceAppLog(IOS_DEVICE_ID, stream, []), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'UNSUPPORTED_OPERATION'); @@ -115,26 +143,19 @@ test('startIosDeviceAppLog reports unsupported devicectl log stream before spawn ); await finished(stream).catch(() => {}); - assert.deepEqual(devicectlCalls, [['device', 'log', 'stream', '--help']]); + assert.deepEqual(calls, [['device', 'log', 'stream', '--help']]); }); test('startIosDeviceAppLog reports unsupported when devicectl support probe fails', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-ios-device-log-timeout-')); - const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); + const stream = makeAppLogWriteStream('agent-device-ios-device-log-timeout-'); - await withAppleToolProvider( - { - runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - devicectl: { - run: async () => { - throw new Error('xcrun timed out after 5000ms'); - }, - }, - whichCommand: async () => false, + await withFakeDevicectl( + async () => { + throw new Error('xcrun timed out after 5000ms'); }, async () => { await assert.rejects( - async () => await startIosDeviceAppLog('00008150-0000AAAA', stream, []), + async () => await startIosDeviceAppLog(IOS_DEVICE_ID, stream, []), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'UNSUPPORTED_OPERATION'); @@ -150,40 +171,21 @@ test('startIosDeviceAppLog reports unsupported when devicectl support probe fail }); test('runAppLogDoctor reports unsupported iOS physical-device log stream', async () => { - const devicectlCalls: string[][] = []; - const result = await withAppleToolProvider( - { - runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - devicectl: { - run: async (args) => { - devicectlCalls.push(args); - if (args.join(' ') === '--version') { - return { stdout: '506.6\n', stderr: '', exitCode: 0 }; - } - return { - stdout: - 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n', - stderr: '', - exitCode: 0, - }; - }, - }, - whichCommand: async () => false, + const { result, calls } = await withFakeDevicectl( + async (args) => { + if (args.join(' ') === '--version') { + return { stdout: '506.6\n', stderr: '', exitCode: 0 }; + } + return { + stdout: IOS_DEVICE_LOG_STREAM_UNAVAILABLE_HELP, + stderr: '', + exitCode: 0, + }; }, - async () => - await runAppLogDoctor( - { - platform: 'apple', - appleOs: 'ios', - id: '00008150-0000AAAA', - name: 'iPhone', - kind: 'device', - }, - 'com.example.app', - ), + async () => await runAppLogDoctor(IOS_DEVICE, 'com.example.app'), ); - assert.deepEqual(devicectlCalls, [['--version'], ['device', 'log', 'stream', '--help']]); + assert.deepEqual(calls, [['--version'], ['device', 'log', 'stream', '--help']]); assert.equal(result.checks.devicectlAvailable, true); assert.equal(result.checks.devicectlDeviceLogStream, false); assert.ok(result.notes.some((note) => note.includes('does not expose'))); diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts new file mode 100644 index 000000000..2f04b6d7b --- /dev/null +++ b/src/daemon/app-log-doctor.ts @@ -0,0 +1,106 @@ +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 { checkIosDeviceLogStreamSupport } 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 = {}; + 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) return checks; + + 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; + } + return checks; +} + +async function runIosSimulatorAppLogDoctor(): Promise> { + try { + const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); + return { simctlAvailable: simctl.exitCode === 0 }; + } catch { + return { simctlAvailable: false }; + } +} + +async function runIosDeviceAppLogDoctor(): Promise { + const checks: Record = {}; + const notes: string[] = []; + try { + const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); + checks.devicectlAvailable = devicectl.exitCode === 0; + } catch { + checks.devicectlAvailable = false; + } + if (!checks.devicectlAvailable) return { checks, notes }; + + const logStream = await checkIosDeviceLogStreamSupport(); + checks.devicectlDeviceLogStream = logStream.supported; + if (!logStream.supported) { + notes.push( + 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ); + } + return { checks, notes }; +} + +async function runMacOsAppLogDoctor(): Promise> { + try { + const log = await runCmd('log', ['help'], { allowFailure: true }); + return { logAvailable: log.exitCode === 0 }; + } catch { + return { logAvailable: false }; + } +} diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 24fbc4281..e4b530849 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, @@ -16,7 +13,6 @@ import { startAndroidAppLog, } from './app-log-android.ts'; import { - checkIosDeviceLogStreamSupport, readRecentIosSimulatorLogShowForBundle, startIosDeviceAppLog, startIosSimulatorAppLog, @@ -52,11 +48,7 @@ export { buildIosDeviceLogStreamArgs, 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; @@ -481,101 +473,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') { - 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 }; -} - -async function runAndroidAppLogDoctor( - device: DeviceInfo, - appBundleId?: string, -): Promise> { - const checks: Record = {}; - 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) return checks; - - 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; - } - return checks; -} - -async function runIosSimulatorAppLogDoctor(): Promise> { - try { - const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); - return { simctlAvailable: simctl.exitCode === 0 }; - } catch { - return { simctlAvailable: false }; - } -} - -async function runIosDeviceAppLogDoctor(): Promise { - const checks: Record = {}; - const notes: string[] = []; - try { - const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); - checks.devicectlAvailable = devicectl.exitCode === 0; - } catch { - checks.devicectlAvailable = false; - } - if (!checks.devicectlAvailable) return { checks, notes }; - - const logStream = await checkIosDeviceLogStreamSupport(); - checks.devicectlDeviceLogStream = logStream.supported; - if (!logStream.supported) { - notes.push( - 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ); - } - return { checks, notes }; -} - -async function runMacOsAppLogDoctor(): Promise> { - try { - const log = await runCmd('log', ['help'], { allowFailure: true }); - return { logAvailable: log.exitCode === 0 }; - } catch { - return { logAvailable: false }; - } -} - 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/session-observability.ts b/src/daemon/handlers/session-observability.ts index 64678ff8c..33b32099c 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,7 +40,6 @@ 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 type { AppLogFailure, AppLogState } from '../app-log.ts'; import { LOG_ACTION_VALUES as LOG_ACTIONS, type LogAction as LogsAction, @@ -118,8 +120,10 @@ function resolveSessionLogStatus(session: SessionState): SessionLogStatus { }; } -function buildAppLogFailure(error: unknown, backend: LogBackend): AppLogFailure { - const normalized = normalizeError(error); +function buildAppLogFailure( + normalized: ReturnType, + backend: LogBackend, +): AppLogFailure { return { backend, code: normalized.code, @@ -129,6 +133,37 @@ function buildAppLogFailure(error: unknown, backend: LogBackend): AppLogFailure }; } +function buildSessionAppLog( + session: SessionState, + outPath: string, + appLog: AppLogResult, +): NonNullable { + return { + platform: session.device.platform, + backend: appLog.backend, + outPath, + startedAt: appLog.startedAt, + getState: appLog.getState, + stop: appLog.stop, + wait: appLog.wait, + }; +} + +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( params: ObservabilityParams, ): Promise { @@ -476,25 +511,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, - appLogFailure: buildAppLogFailure(err, resolveLogBackend(session.device)), - }); - return { ok: false, error: normalizeError(err) }; + return storeAppLogStartFailure(sessionStore, sessionName, session, err); } } @@ -524,25 +546,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) { - sessionStore.set(sessionName, { - ...session, - appLog: undefined, - appLogFailure: buildAppLogFailure(err, resolveLogBackend(session.device)), - }); - return { ok: false, error: normalizeError(err) }; + return storeAppLogStartFailure(sessionStore, sessionName, session, err); } } From 6ef5d81069d17bc82738eee26adaebbb14c41272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 18:08:40 +0200 Subject: [PATCH 6/9] fix: capture ios device logs with devicectl console --- src/cli/parser/cli-help.ts | 1 + src/daemon/__tests__/app-log.test.ts | 44 ++++++---- src/daemon/app-log-doctor.ts | 10 +-- src/daemon/app-log-ios.ts | 57 ++++++++----- src/daemon/app-log-process.ts | 3 +- src/daemon/app-log.ts | 4 +- src/daemon/handlers/__tests__/session.test.ts | 82 +++++++++---------- src/utils/__tests__/args.test.ts | 1 + 8 files changed, 112 insertions(+), 90 deletions(-) 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 276b70347..1e559195f 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -12,7 +12,7 @@ import { APP_LOG_PID_FILENAME, assertAndroidPackageArgSafe, buildAppleLogPredicate, - buildIosDeviceLogStreamArgs, + buildIosDeviceConsoleLaunchArgs, buildIosSimulatorLogStreamArgs, cleanupStaleAppLogProcesses, runAppLogDoctor, @@ -28,8 +28,13 @@ const IOS_DEVICE: DeviceInfo = { name: 'iPhone', kind: 'device', }; -const IOS_DEVICE_LOG_STREAM_UNAVAILABLE_HELP = +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; @@ -109,32 +114,35 @@ 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(IOS_DEVICE_ID), [ +test('buildIosDeviceConsoleLaunchArgs builds expected devicectl command args', () => { + assert.deepEqual(buildIosDeviceConsoleLaunchArgs(IOS_DEVICE_ID, 'com.example.app'), [ 'devicectl', 'device', - 'log', - 'stream', + 'process', + 'launch', '--device', IOS_DEVICE_ID, + '--console', + '--terminate-existing', + 'com.example.app', ]); }); -test('startIosDeviceAppLog reports unsupported devicectl log stream before spawning', async () => { +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_LOG_STREAM_UNAVAILABLE_HELP, + stdout: IOS_DEVICE_HELP_WITHOUT_CONSOLE_CAPTURE, stderr: '', exitCode: 0, }), async () => { await assert.rejects( - async () => await startIosDeviceAppLog(IOS_DEVICE_ID, stream, []), + 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 log streaming is not supported/); + assert.match(error.message, /iOS physical-device app console capture is not supported/); assert.equal(error.details?.backend, 'ios-device'); return true; }, @@ -143,7 +151,7 @@ test('startIosDeviceAppLog reports unsupported devicectl log stream before spawn ); await finished(stream).catch(() => {}); - assert.deepEqual(calls, [['device', 'log', 'stream', '--help']]); + assert.deepEqual(calls, [['device', 'process', 'launch', '--help']]); }); test('startIosDeviceAppLog reports unsupported when devicectl support probe fails', async () => { @@ -155,11 +163,11 @@ test('startIosDeviceAppLog reports unsupported when devicectl support probe fail }, async () => { await assert.rejects( - async () => await startIosDeviceAppLog(IOS_DEVICE_ID, stream, []), + 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 log streaming is not supported/); + assert.match(error.message, /iOS physical-device app console capture is not supported/); assert.equal(error.details?.stderr, 'xcrun timed out after 5000ms'); return true; }, @@ -170,14 +178,14 @@ test('startIosDeviceAppLog reports unsupported when devicectl support probe fail await finished(stream).catch(() => {}); }); -test('runAppLogDoctor reports unsupported iOS physical-device log stream', async () => { +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_LOG_STREAM_UNAVAILABLE_HELP, + stdout: IOS_DEVICE_CONSOLE_CAPTURE_HELP, stderr: '', exitCode: 0, }; @@ -185,10 +193,10 @@ test('runAppLogDoctor reports unsupported iOS physical-device log stream', async async () => await runAppLogDoctor(IOS_DEVICE, 'com.example.app'), ); - assert.deepEqual(calls, [['--version'], ['device', 'log', 'stream', '--help']]); + assert.deepEqual(calls, [['--version'], ['device', 'process', 'launch', '--help']]); assert.equal(result.checks.devicectlAvailable, true); - assert.equal(result.checks.devicectlDeviceLogStream, false); - assert.ok(result.notes.some((note) => note.includes('does not expose'))); + assert.equal(result.checks.devicectlConsoleCapture, true); + assert.equal(result.notes.length, 0); }); test('buildIosSimulatorLogStreamArgs streams logs inside the simulator at info level', () => { diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts index 2f04b6d7b..076b00deb 100644 --- a/src/daemon/app-log-doctor.ts +++ b/src/daemon/app-log-doctor.ts @@ -2,7 +2,7 @@ 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 { checkIosDeviceLogStreamSupport } from './app-log-ios.ts'; +import { checkIosDeviceConsoleCaptureSupport } from './app-log-ios.ts'; export type AppLogDoctorResult = { checks: Record; @@ -86,11 +86,11 @@ async function runIosDeviceAppLogDoctor(): Promise { } if (!checks.devicectlAvailable) return { checks, notes }; - const logStream = await checkIosDeviceLogStreamSupport(); - checks.devicectlDeviceLogStream = logStream.supported; - if (!logStream.supported) { + const consoleCapture = await checkIosDeviceConsoleCaptureSupport(); + checks.devicectlConsoleCapture = consoleCapture.supported; + if (!consoleCapture.supported) { notes.push( - 'Installed devicectl does not expose a scriptable iOS physical-device app log stream. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', ); } return { checks, notes }; diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index f45d678ba..ee5b3ab17 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -7,10 +7,10 @@ import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts'; import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; -const IOS_DEVICE_LOG_STREAM_UNSUPPORTED_MESSAGE = - 'iOS physical-device app log streaming is not supported by the installed devicectl.'; -const IOS_DEVICE_LOG_STREAM_UNSUPPORTED_HINT = - 'This devicectl does not expose a device log stream subcommand. 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 a scriptable stream.'; +const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE = + 'iOS physical-device app console capture is not supported by the installed devicectl.'; +const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_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.'; export function buildAppleLogPredicate( appBundleId: string, @@ -61,21 +61,32 @@ 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 checkIosDeviceLogStreamSupport(): Promise<{ +export async function checkIosDeviceConsoleCaptureSupport(): Promise<{ supported: boolean; stderr?: string; }> { try { - const result = await runXcrun(['devicectl', 'device', 'log', 'stream', '--help'], { + const result = await runXcrun(['devicectl', 'device', 'process', 'launch', '--help'], { allowFailure: true, timeoutMs: 5_000, }); return { - supported: result.exitCode === 0 && isIosDeviceLogStreamHelp(result.stdout, result.stderr), + supported: + result.exitCode === 0 && isIosDeviceConsoleCaptureHelp(result.stdout, result.stderr), stderr: result.stderr.trim() || undefined, }; } catch (error) { @@ -86,8 +97,13 @@ export async function checkIosDeviceLogStreamSupport(): Promise<{ } } -function isIosDeviceLogStreamHelp(stdout: string, stderr: string): boolean { - return /\bUSAGE:\s+devicectl device log stream\b/i.test(`${stdout}\n${stderr}`); +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: { @@ -212,26 +228,28 @@ export async function startMacOsAppLog( export async function startIosDeviceAppLog( deviceId: string, + appBundleId: string, stream: fs.WriteStream, redactionPatterns: RegExp[], pidPath?: string, ): Promise { - const support = await checkIosDeviceLogStreamSupport(); + const support = await checkIosDeviceConsoleCaptureSupport(); if (!support.supported) { stream.end(); - throw new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_LOG_STREAM_UNSUPPORTED_MESSAGE, { + throw new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE, { backend: 'ios-device', - hint: IOS_DEVICE_LOG_STREAM_UNSUPPORTED_HINT, + hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_HINT, stderr: support.stderr, }); } return startAppleAppLogStream({ backend: 'ios-device', cmd: 'xcrun', - args: buildIosDeviceLogStreamArgs(deviceId), + args: buildIosDeviceConsoleLaunchArgs(deviceId, appBundleId), stream, redactionPatterns, pidPath, + stopSignals: ['SIGKILL'], }); } @@ -242,6 +260,7 @@ function startAppleAppLogStream(params: { stream: fs.WriteStream; redactionPatterns: RegExp[]; pidPath?: string; + stopSignals?: NodeJS.Signals[]; }): AppLogResult { let state: 'active' | 'failed' = 'active'; const background = runCmdBackground(params.cmd, params.args, { @@ -275,10 +294,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 183a82518..d29e4f54c 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -50,7 +50,8 @@ function isManagedAppLogCommand(command: string): boolean { return ( normalized.includes('log stream') || normalized.includes('logcat') || - normalized.includes('devicectl device log stream') + 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 e4b530849..727d3fe41 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -45,7 +45,7 @@ export { } from './app-log-android.ts'; export { buildAppleLogPredicate, - buildIosDeviceLogStreamArgs, + buildIosDeviceConsoleLaunchArgs, buildIosSimulatorLogStreamArgs, } from './app-log-ios.ts'; export { runAppLogDoctor, type AppLogDoctorResult } from './app-log-doctor.ts'; @@ -403,7 +403,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, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index dceb9e0e6..f389fdbd3 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -4497,12 +4497,12 @@ test('logs clear --restart requires app session bundle id', async () => { } }); -function makeUnsupportedIosDeviceLogSession(): { +function makeIosDeviceLogSession(): { sessionStore: ReturnType; sessionName: string; } { const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-logs-unsupported'; + const sessionName = 'ios-device-console-logs'; sessionStore.set(sessionName, { ...makeSession(sessionName, { platform: 'apple', @@ -4516,20 +4516,17 @@ function makeUnsupportedIosDeviceLogSession(): { return { sessionStore, sessionName }; } -function mockUnsupportedIosDeviceLogBackend(): void { - mockStartAppLog.mockRejectedValue( - new AppError( - 'UNSUPPORTED_OPERATION', - 'iOS physical-device app log streaming is not supported by the installed devicectl.', - { - backend: 'ios-device', - hint: 'Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode.', - }, - ), - ); +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, devicectlDeviceLogStream: false }, - notes: ['Installed devicectl does not expose a scriptable iOS physical-device app log stream.'], + checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, + notes: [], }); } @@ -4554,59 +4551,54 @@ async function runLogsCommandForSession( }); } -function expectUnsupportedIosDeviceLogsPath( +function expectActiveIosDeviceLogsPath( 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?.active).toBe(true); + expect(response.data?.state).toBe('active'); expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); - expect(response.data?.failureMessage).toMatch(/physical-device app log streaming/); - expect(response.data?.hint).toMatch(/Console\.app\/Xcode/); - expect(response.data?.notes).toContain( - 'iOS physical-device app log streaming is not supported by the installed devicectl.', - ); + expect(response.data?.failureCode).toBeUndefined(); + expect(response.data?.failureMessage).toBeUndefined(); + expect(response.data?.startedAt).toBe('2024-04-02T06:40:00.000Z'); } -function expectUnsupportedIosDeviceLogsDoctor( +function expectActiveIosDeviceLogsDoctor( 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?.active).toBe(true); + expect(response.data?.state).toBe('active'); expect(response.data?.backend).toBe('ios-device'); expect(response.data?.checks).toEqual({ devicectlAvailable: true, - devicectlDeviceLogStream: false, + devicectlConsoleCapture: true, }); - expect(response.data?.notes).toContain( - 'Installed devicectl does not expose a scriptable iOS physical-device app log stream.', - ); - expect(response.data?.notes).toContain( - 'iOS physical-device app log streaming is not supported by the installed devicectl.', - ); + expect(response.data?.notes).toEqual([]); } -test('logs path and doctor report unsupported iOS physical-device backend as inactive failure', async () => { - const { sessionStore, sessionName } = makeUnsupportedIosDeviceLogSession(); - mockUnsupportedIosDeviceLogBackend(); +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(false); - if (restartResponse && !restartResponse.ok) { - expect(restartResponse.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(restartResponse.error.hint).toMatch(/Console\.app\/Xcode/); + expect(restartResponse?.ok).toBe(true); + if (restartResponse && restartResponse.ok) { + expect(restartResponse.data?.restarted).toBe(true); } - - expectUnsupportedIosDeviceLogsPath( - await runLogsCommandForSession(sessionStore, sessionName, 'path'), + expect(mockStartAppLog).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: '00008150-0000AAAA' }), + 'com.example.app', + expect.stringContaining('app.log'), + expect.stringContaining('app-log.pid'), ); - expectUnsupportedIosDeviceLogsDoctor( + + expectActiveIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); + expectActiveIosDeviceLogsDoctor( await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), ); }); 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/); From 7ebb1e1b4ce19fb272693b16d3cd13f99ee646d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 20:08:04 +0200 Subject: [PATCH 7/9] fix: tighten ios device log capture state --- src/daemon/__tests__/app-log.test.ts | 34 +++++++- src/daemon/app-log-doctor.ts | 12 ++- src/daemon/app-log-ios.ts | 55 ++++++++++--- src/daemon/app-log-process.ts | 3 +- src/daemon/handlers/__tests__/session.test.ts | 77 +++++++++++++++++++ src/daemon/handlers/session-observability.ts | 36 +++++++-- 6 files changed, 190 insertions(+), 27 deletions(-) diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index 1e559195f..cd820878e 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -154,7 +154,7 @@ test('startIosDeviceAppLog reports unsupported devicectl console capture before assert.deepEqual(calls, [['device', 'process', 'launch', '--help']]); }); -test('startIosDeviceAppLog reports unsupported when devicectl support probe fails', async () => { +test('startIosDeviceAppLog reports retryable failure when devicectl support probe fails', async () => { const stream = makeAppLogWriteStream('agent-device-ios-device-log-timeout-'); await withFakeDevicectl( @@ -166,8 +166,8 @@ test('startIosDeviceAppLog reports unsupported when devicectl support probe fail 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.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; }, @@ -199,6 +199,34 @@ test('runAppLogDoctor reports supported iOS physical-device console capture', as 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 index 076b00deb..b089bc928 100644 --- a/src/daemon/app-log-doctor.ts +++ b/src/daemon/app-log-doctor.ts @@ -89,9 +89,15 @@ async function runIosDeviceAppLogDoctor(): Promise { const consoleCapture = await checkIosDeviceConsoleCaptureSupport(); checks.devicectlConsoleCapture = consoleCapture.supported; if (!consoleCapture.supported) { - notes.push( - 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ); + if (consoleCapture.reason === 'probe-failed') { + notes.push( + 'Could not verify iOS physical-device app console capture support. Retry after devicectl is responsive, then rerun logs doctor.', + ); + } else { + notes.push( + 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ); + } } return { checks, notes }; } diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index ee5b3ab17..a0ef5f712 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -4,13 +4,30 @@ import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; import { AppError } from '../kernel/errors.ts'; import { runCmd, runCmdBackground } 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'; const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE = 'iOS physical-device app console capture is not supported by the installed devicectl.'; const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_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.'; +const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_MESSAGE = + 'Could not verify iOS physical-device app console capture support.'; +const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_HINT = + 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.'; + +type IosDeviceConsoleCaptureSupport = { + supported: boolean; + reason?: 'unsupported' | 'probe-failed'; + stderr?: string; +}; + +let cachedSupportedIosDeviceConsoleCapture: IosDeviceConsoleCaptureSupport | undefined; export function buildAppleLogPredicate( appBundleId: string, @@ -75,23 +92,30 @@ export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: s ]; } -export async function checkIosDeviceConsoleCaptureSupport(): Promise<{ - supported: boolean; - stderr?: string; -}> { +export async function checkIosDeviceConsoleCaptureSupport(): Promise { + if (cachedSupportedIosDeviceConsoleCapture) return cachedSupportedIosDeviceConsoleCapture; try { const result = await runXcrun(['devicectl', 'device', 'process', 'launch', '--help'], { allowFailure: true, timeoutMs: 5_000, }); - return { - supported: - result.exitCode === 0 && isIosDeviceConsoleCaptureHelp(result.stdout, result.stderr), - stderr: result.stderr.trim() || undefined, - }; + if (result.exitCode !== 0) { + return { + supported: false, + reason: 'probe-failed', + stderr: result.stderr.trim() || undefined, + }; + } + const supported = isIosDeviceConsoleCaptureHelp(result.stdout, result.stderr); + const support: IosDeviceConsoleCaptureSupport = supported + ? { supported: true, stderr: result.stderr.trim() || undefined } + : { supported: false, reason: 'unsupported', stderr: result.stderr.trim() || undefined }; + if (support.supported) cachedSupportedIosDeviceConsoleCapture = support; + return support; } catch (error) { return { supported: false, + reason: 'probe-failed', stderr: error instanceof Error ? error.message : undefined, }; } @@ -236,6 +260,13 @@ export async function startIosDeviceAppLog( const support = await checkIosDeviceConsoleCaptureSupport(); if (!support.supported) { stream.end(); + if (support.reason === 'probe-failed') { + throw 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, + }); + } throw new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE, { backend: 'ios-device', hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_HINT, @@ -262,7 +293,7 @@ function startAppleAppLogStream(params: { 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, @@ -278,7 +309,7 @@ function startAppleAppLogStream(params: { writer, }).then( (result) => { - if (result.exitCode !== 0) state = 'failed'; + state = result.exitCode === 0 ? 'ended' : 'failed'; clearPidFile(params.pidPath); return result; }, diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index d29e4f54c..8b52fc1e6 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -6,14 +6,13 @@ 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; - occurredAt: number; }; export type AppLogResult = { diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index f389fdbd3..ea4dfa486 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -4530,6 +4530,25 @@ function mockIosDeviceLogBackend(): void { }); } +function mockUnsupportedIosDeviceLogBackend(): void { + mockStartAppLog.mockRejectedValue( + new AppError( + 'UNSUPPORTED_OPERATION', + 'iOS physical-device app console capture is not supported by the installed devicectl.', + { + backend: 'ios-device', + hint: 'Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode.', + }, + ), + ); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, + notes: [ + 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ], + }); +} + async function runLogsCommandForSession( sessionStore: ReturnType, sessionName: string, @@ -4564,6 +4583,17 @@ function expectActiveIosDeviceLogsPath( 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>, ) { @@ -4579,6 +4609,20 @@ function expectActiveIosDeviceLogsDoctor( 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([ + 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', + ]); +} + test('logs clear --restart starts active iOS physical-device console capture', async () => { const { sessionStore, sessionName } = makeIosDeviceLogSession(); mockIosDeviceLogBackend(); @@ -4603,6 +4647,39 @@ test('logs clear --restart starts active iOS physical-device console capture', a ); }); +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 33b32099c..c71f012dd 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -40,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, @@ -91,15 +92,13 @@ const LOG_ACTION_HANDLERS: Record< function resolveSessionLogStatus(session: SessionState): SessionLogStatus { if (session.appLog) { const state = session.appLog.getState(); + const active = state === 'active' || state === 'recovering'; return { - active: state !== 'failed', + active, state, backend: session.appLog.backend, startedAt: session.appLog.startedAt, - notes: - state === 'failed' - ? ['The app log stream process exited. Run logs doctor for backend diagnostics.'] - : undefined, + notes: buildAppLogStateNotes(state), }; } if (session.appLogFailure) { @@ -129,10 +128,33 @@ function buildAppLogFailure( code: normalized.code, message: normalized.message, hint: normalized.hint, - occurredAt: Date.now(), }; } +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[] { + if (status.failureCode === 'UNSUPPORTED_OPERATION' && doctorNotes.length > 0) { + return uniqueStrings(doctorNotes); + } + return uniqueStrings([...doctorNotes, ...(status.notes ?? [])]); +} + function buildSessionAppLog( session: SessionState, outPath: string, @@ -460,7 +482,7 @@ async function handleLogsDoctor( failureCode: status.failureCode, failureMessage: status.failureMessage, hint: status.hint, - notes: [...doctor.notes, ...(status.notes ?? [])], + notes: mergeLogDoctorNotes(doctor.notes, status), }, }; } From 4574273311123476b329cd616943b55818874f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 20:12:14 +0200 Subject: [PATCH 8/9] refactor: clarify ios log capture support flow --- src/daemon/app-log-doctor.ts | 14 ++-- src/daemon/app-log-ios.ts | 67 +++++++++++-------- src/daemon/handlers/__tests__/session.test.ts | 9 +-- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts index b089bc928..4c32786c3 100644 --- a/src/daemon/app-log-doctor.ts +++ b/src/daemon/app-log-doctor.ts @@ -2,7 +2,11 @@ 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 } from './app-log-ios.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; @@ -90,13 +94,9 @@ async function runIosDeviceAppLogDoctor(): Promise { checks.devicectlConsoleCapture = consoleCapture.supported; if (!consoleCapture.supported) { if (consoleCapture.reason === 'probe-failed') { - notes.push( - 'Could not verify iOS physical-device app console capture support. Retry after devicectl is responsive, then rerun logs doctor.', - ); + notes.push(IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE); } else { - notes.push( - 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ); + notes.push(IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE); } } return { checks, notes }; diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index a0ef5f712..d4addb7b0 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; import { AppError } from '../kernel/errors.ts'; -import { runCmd, runCmdBackground } from '../utils/exec.ts'; +import { runCmd, runCmdBackground, type ExecResult } from '../utils/exec.ts'; import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; import { clearPidFile, @@ -20,12 +20,14 @@ const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_MESSAGE = 'Could not verify iOS physical-device app console capture support.'; const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_HINT = 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.'; +export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE = + 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.'; +export const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE = + 'Could not verify iOS physical-device app console capture support. Retry after devicectl is responsive, then rerun logs doctor.'; -type IosDeviceConsoleCaptureSupport = { - supported: boolean; - reason?: 'unsupported' | 'probe-failed'; - stderr?: string; -}; +export type IosDeviceConsoleCaptureSupport = + | { supported: true; stderr?: string } + | { supported: false; reason: 'unsupported' | 'probe-failed'; stderr?: string }; let cachedSupportedIosDeviceConsoleCapture: IosDeviceConsoleCaptureSupport | undefined; @@ -99,17 +101,7 @@ export async function checkIosDeviceConsoleCaptureSupport(): Promise, +): 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, + }); +} + function startAppleAppLogStream(params: { backend: AppLogResult['backend']; cmd: string; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index ea4dfa486..a9eccce49 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -107,6 +107,7 @@ 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_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'; @@ -4543,9 +4544,7 @@ function mockUnsupportedIosDeviceLogBackend(): void { ); mockRunAppLogDoctor.mockResolvedValue({ checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, - notes: [ - 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ], + notes: [IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE], }); } @@ -4618,9 +4617,7 @@ function expectUnsupportedIosDeviceLogsDoctor( 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([ - 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.', - ]); + expect(response.data?.notes).toEqual([IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE]); } test('logs clear --restart starts active iOS physical-device console capture', async () => { From 90ce7ad9a09b24361fd6fc16dc0fbd5ef3ed0bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 20:54:53 +0200 Subject: [PATCH 9/9] refactor: consolidate ios log diagnostics flow --- src/daemon/app-log-doctor.ts | 46 ++-- src/daemon/app-log-ios.ts | 38 ++-- src/daemon/app-log-process.ts | 1 - src/daemon/app-log.ts | 208 +++++++----------- src/daemon/handlers/__tests__/session.test.ts | 17 +- src/daemon/handlers/session-observability.ts | 17 +- 6 files changed, 142 insertions(+), 185 deletions(-) diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts index 4c32786c3..058a60076 100644 --- a/src/daemon/app-log-doctor.ts +++ b/src/daemon/app-log-doctor.ts @@ -47,47 +47,40 @@ async function runAndroidAppLogDoctor( appBundleId?: string, ): Promise> { const checks: Record = {}; - try { + checks.adbAvailable = await safeCheck(async () => { const adb = await runAndroidAdb(device, ['shell', 'echo', 'ok'], { allowFailure: true, timeoutMs: 1_000, }); - checks.adbAvailable = adb.exitCode === 0; - } catch { - checks.adbAvailable = false; - } + return adb.exitCode === 0; + }); if (!appBundleId) return checks; - try { + checks.androidPidVisible = await safeCheck(async () => { const pidof = await runAndroidAdb(device, ['shell', 'pidof', appBundleId], { allowFailure: true, timeoutMs: 1_000, }); - checks.androidPidVisible = pidof.stdout.trim().length > 0; - } catch { - checks.androidPidVisible = false; - } + return pidof.stdout.trim().length > 0; + }); return checks; } async function runIosSimulatorAppLogDoctor(): Promise> { - try { + const simctlAvailable = await safeCheck(async () => { const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); - return { simctlAvailable: simctl.exitCode === 0 }; - } catch { - return { simctlAvailable: false }; - } + return simctl.exitCode === 0; + }); + return { simctlAvailable }; } async function runIosDeviceAppLogDoctor(): Promise { const checks: Record = {}; const notes: string[] = []; - try { + checks.devicectlAvailable = await safeCheck(async () => { const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); - checks.devicectlAvailable = devicectl.exitCode === 0; - } catch { - checks.devicectlAvailable = false; - } + return devicectl.exitCode === 0; + }); if (!checks.devicectlAvailable) return { checks, notes }; const consoleCapture = await checkIosDeviceConsoleCaptureSupport(); @@ -103,10 +96,17 @@ async function runIosDeviceAppLogDoctor(): Promise { } async function runMacOsAppLogDoctor(): Promise> { - try { + const logAvailable = await safeCheck(async () => { const log = await runCmd('log', ['help'], { allowFailure: true }); - return { logAvailable: log.exitCode === 0 }; + return log.exitCode === 0; + }); + return { logAvailable }; +} + +async function safeCheck(check: () => Promise): Promise { + try { + return await check(); } catch { - return { logAvailable: false }; + return false; } } diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts index d4addb7b0..0d0521666 100644 --- a/src/daemon/app-log-ios.ts +++ b/src/daemon/app-log-ios.ts @@ -12,18 +12,20 @@ import { } from './app-log-process.ts'; import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; -const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE = - 'iOS physical-device app console capture is not supported by the installed devicectl.'; -const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_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.'; -const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_MESSAGE = - 'Could not verify iOS physical-device app console capture support.'; -const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_HINT = - 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.'; -export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE = - 'Installed devicectl does not expose scriptable iOS physical-device app console capture. Markers can still be written, but app output is not being captured by agent-device on this toolchain.'; -export const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE = - 'Could not verify iOS physical-device app console capture support. Retry after devicectl is responsive, then rerun logs doctor.'; +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 } @@ -124,6 +126,10 @@ function readIosDeviceConsoleCaptureSupport(result: ExecResult): IosDeviceConsol 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 ( @@ -280,15 +286,15 @@ function buildIosDeviceConsoleCaptureError( support: Extract, ): AppError { if (support.reason === 'probe-failed') { - return new AppError('COMMAND_FAILED', IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_MESSAGE, { + return new AppError('COMMAND_FAILED', IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.message, { backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_HINT, + hint: IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.hint, stderr: support.stderr, }); } - return new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_MESSAGE, { + return new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_HINT, + hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, stderr: support.stderr, }); } diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index 8b52fc1e6..f9d8513ae 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -49,7 +49,6 @@ 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 727d3fe41..fa9ff66ef 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -201,24 +201,96 @@ export function resolveLogBackend(device: DeviceInfo): LogBackend { export async function readSessionNetworkCapture( params: SessionNetworkCaptureParams, ): Promise { - const { device, appLogState } = params; + const { + device, + appBundleId, + appLogState, + appLogStartedAt, + appLogPath, + maxEntries, + include, + maxPayloadChars, + maxScanLines, + } = params; const backend = resolveLogBackend(device); - let dump = readSessionAppNetworkDump(params, backend); + let dump = readRecentNetworkTraffic(appLogPath, { + backend, + maxEntries, + include, + maxPayloadChars, + maxScanLines, + }); const notes: string[] = []; - const androidRecovery = await recoverAndroidNetworkDump(params, dump); + const androidRecovery = await resolveAndroidNetworkRecoveryContext({ + device, + appBundleId, + appLogPath, + appLogState, + }); if (androidRecovery) { - dump = androidRecovery.dump; - notes.push(androidRecovery.note); + const recovered = await readRecentAndroidLogcatForPackage(device.id, appBundleId as string); + if (recovered) { + const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { + path: `${appLogPath} (adb logcat recovery)`, + backend: 'android', + maxEntries, + include, + maxPayloadChars, + maxScanLines, + }); + if (recoveredDump.entries.length > 0) { + dump = mergeNetworkDumps(recoveredDump, dump, maxEntries); + notes.push(buildAndroidRecoveryNote(androidRecovery, recovered.recoveredPids)); + } + } } - const iosRecovery = await recoverIosSimulatorNetworkDump(params, dump); - if (iosRecovery) { - dump = iosRecovery.dump; - notes.push(...iosRecovery.notes); + const canRecoverIosSimulatorLogShow = + isIosFamily(device) && device.kind === 'simulator' && Boolean(appBundleId); + if (canRecoverIosSimulatorLogShow && dump.entries.length === 0) { + const recovered = await readRecentIosSimulatorNetworkCapture({ + deviceId: device.id, + appBundleId: appBundleId as string, + startedAt: appLogStartedAt, + simulatorSetPath: device.simulatorSetPath, + appLogPath, + maxEntries, + include, + maxPayloadChars, + maxScanLines, + }); + if (recovered) { + if (recovered.dump.entries.length > 0) { + dump = mergeNetworkDumps(recovered.dump, dump, maxEntries); + notes.push( + `Recovered ${recovered.dump.entries.length} iOS simulator HTTP entr${ + recovered.dump.entries.length === 1 ? 'y' : 'ies' + } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`, + ); + } else if (recovered.recoveredLineCount > 0) { + notes.push( + `Recovered ${recovered.recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, + ); + } + } } - notes.push(...buildAppLogStateNotes(device, appLogState, notes.length > 0)); + if (appLogState === undefined) { + notes.push( + 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', + ); + } else if (appLogState !== 'active' && notes.length === 0) { + if (isIosFamily(device) && device.kind === 'simulator') { + notes.push( + 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', + ); + } else { + notes.push( + 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ); + } + } if (dump.entries.length === 0) { notes.push(buildNoHttpEntriesNote(device)); @@ -227,122 +299,6 @@ export async function readSessionNetworkCapture( return { backend, dump, notes }; } -function readSessionAppNetworkDump( - params: SessionNetworkCaptureParams, - backend: LogBackend, -): NetworkDump { - return readRecentNetworkTraffic(params.appLogPath, { - backend, - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); -} - -async function recoverAndroidNetworkDump( - params: SessionNetworkCaptureParams, - currentDump: NetworkDump, -): Promise<{ dump: NetworkDump; note: string } | null> { - const recovery = await resolveAndroidNetworkRecoveryContext(params); - if (!recovery || !params.appBundleId) return null; - - const recovered = await readRecentAndroidLogcatForPackage(params.device.id, params.appBundleId); - if (!recovered) return null; - - const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { - path: `${params.appLogPath} (adb logcat recovery)`, - backend: 'android', - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); - if (recoveredDump.entries.length === 0) return null; - - return { - dump: mergeNetworkDumps(recoveredDump, currentDump, params.maxEntries), - note: buildAndroidRecoveryNote(recovery, recovered.recoveredPids), - }; -} - -async function recoverIosSimulatorNetworkDump( - params: SessionNetworkCaptureParams, - currentDump: NetworkDump, -): Promise<{ dump: NetworkDump; notes: string[] } | null> { - if (!canRecoverIosSimulatorLogShow(params, currentDump)) return null; - - const recovered = await readRecentIosSimulatorNetworkCapture({ - deviceId: params.device.id, - appBundleId: params.appBundleId as string, - startedAt: params.appLogStartedAt, - simulatorSetPath: params.device.simulatorSetPath, - appLogPath: params.appLogPath, - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); - if (!recovered) return null; - - return { - dump: - recovered.dump.entries.length > 0 - ? mergeNetworkDumps(recovered.dump, currentDump, params.maxEntries) - : currentDump, - notes: buildIosSimulatorRecoveryNotes(recovered), - }; -} - -function canRecoverIosSimulatorLogShow( - params: SessionNetworkCaptureParams, - dump: NetworkDump, -): boolean { - return ( - isIosFamily(params.device) && - params.device.kind === 'simulator' && - Boolean(params.appBundleId) && - dump.entries.length === 0 - ); -} - -function buildIosSimulatorRecoveryNotes(recovered: IosSimulatorNetworkRecovery): string[] { - if (recovered.dump.entries.length > 0) { - return [ - `Recovered ${recovered.dump.entries.length} iOS simulator HTTP entr${ - recovered.dump.entries.length === 1 ? 'y' : 'ies' - } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`, - ]; - } - if (recovered.recoveredLineCount > 0) { - return [ - `Recovered ${recovered.recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, - ]; - } - return []; -} - -function buildAppLogStateNotes( - device: DeviceInfo, - appLogState: AppLogState | undefined, - alreadyRecovered: boolean, -): string[] { - if (appLogState === undefined) { - return [ - 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', - ]; - } - if (appLogState === 'active' || alreadyRecovered) return []; - if (isIosFamily(device) && device.kind === 'simulator') { - return [ - 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', - ]; - } - return [ - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ]; -} - async function resolveAndroidNetworkRecoveryContext(params: { device: DeviceInfo; appBundleId?: string; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index a9eccce49..18d4adc9c 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -107,7 +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_NOTE } from '../../app-log-ios.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'; @@ -4533,14 +4536,10 @@ function mockIosDeviceLogBackend(): void { function mockUnsupportedIosDeviceLogBackend(): void { mockStartAppLog.mockRejectedValue( - new AppError( - 'UNSUPPORTED_OPERATION', - 'iOS physical-device app console capture is not supported by the installed devicectl.', - { - backend: 'ios-device', - hint: 'Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode.', - }, - ), + 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 }, diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index c71f012dd..704e12557 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -109,7 +109,7 @@ function resolveSessionLogStatus(session: SessionState): SessionLogStatus { failureCode: session.appLogFailure.code, failureMessage: session.appLogFailure.message, hint: session.appLogFailure.hint, - notes: [session.appLogFailure.message], + notes: [buildAppLogFailureNote(session.appLogFailure)], }; } return { @@ -131,6 +131,10 @@ function buildAppLogFailure( }; } +function buildAppLogFailureNote(failure: AppLogFailure): string { + return failure.hint ? `${failure.message} ${failure.hint}` : failure.message; +} + function buildAppLogStateNotes(state: AppLogState): string[] | undefined { if (state === 'failed') { return [ @@ -147,11 +151,8 @@ function buildAppLogStateNotes(state: AppLogState): string[] | undefined { function mergeLogDoctorNotes( doctorNotes: string[], - status: Pick, + status: Pick, ): string[] { - if (status.failureCode === 'UNSUPPORTED_OPERATION' && doctorNotes.length > 0) { - return uniqueStrings(doctorNotes); - } return uniqueStrings([...doctorNotes, ...(status.notes ?? [])]); } @@ -161,13 +162,9 @@ function buildSessionAppLog( appLog: AppLogResult, ): NonNullable { return { + ...appLog, platform: session.device.platform, - backend: appLog.backend, outPath, - startedAt: appLog.startedAt, - getState: appLog.getState, - stop: appLog.stop, - wait: appLog.wait, }; }