From 48fa16c93aea1894762019b3f4e6c194817076f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 11:26:46 +0200 Subject: [PATCH 01/11] feat: add iOS device perf sampling --- README.md | 4 +- src/daemon/handlers/__tests__/session.test.ts | 105 +++- src/daemon/handlers/session-perf.ts | 16 +- src/platforms/ios/__tests__/index.test.ts | 32 ++ src/platforms/ios/__tests__/perf.test.ts | 192 ++++++- src/platforms/ios/devicectl.ts | 86 ++- src/platforms/ios/perf.ts | 513 +++++++++++++++++- website/docs/docs/commands.md | 4 +- website/docs/docs/introduction.md | 2 +- 9 files changed, 890 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 51b06e15..19c86a88 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ In non-JSON mode, core mutating commands print a short success acknowledgment so - Startup timing is available on iOS and Android from `open` command round-trip sampling. - Android app sessions also sample CPU (`adb shell dumpsys cpuinfo`) and memory (`adb shell dumpsys meminfo `) when the session has an active app package context. -- Apple app sessions on macOS and iOS simulators also sample CPU and memory from process snapshots resolved from the active app bundle ID. -- Physical iOS devices still report CPU and memory as unavailable in this release. +- Apple app sessions on macOS and iOS simulators sample CPU and memory from process snapshots resolved from the active app bundle ID. +- Physical iOS devices sample CPU and memory from a short `xcrun xctrace` Activity Monitor capture against the connected device, so `perf` can take a few seconds longer there than on simulators or macOS. ## Where To Go Next diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 35e4d340..df271fe5 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -128,6 +128,7 @@ const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android); beforeEach(() => { + vi.useRealTimers(); mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); @@ -2135,7 +2136,9 @@ test('perf samples Apple cpu and memory metrics on iOS simulator app sessions', expect(cpu?.matchedProcesses).toEqual(['ExampleSimExec']); }); -test('perf degrades Apple cpu and memory metrics on physical iOS devices', async () => { +test('perf samples Apple cpu and memory metrics on physical iOS devices', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); const sessionStore = makeSessionStore(); const sessionName = 'perf-session-ios-device'; sessionStore.set(sessionName, { @@ -2148,6 +2151,91 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async }), appBundleId: 'com.example.device', }); + let exportCount = 0; + mockRunCmd.mockImplementation(async (_cmd, args) => { + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'apps' + ) { + const outputIndex = args.indexOf('--json-output'); + fs.writeFileSync( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + apps: [ + { + bundleIdentifier: 'com.example.device', + name: 'Example Device App', + url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/', + }, + ], + }, + }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'processes' + ) { + const outputIndex = args.indexOf('--json-output'); + fs.writeFileSync( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + runningProcesses: [ + { + executable: + 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp', + processIdentifier: 4001, + }, + ], + }, + }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'record') { + vi.setSystemTime(new Date(Date.now() + 1000)); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'export') { + const outputIndex = args.indexOf('--output'); + exportCount += 1; + fs.writeFileSync( + args[outputIndex + 1]!, + [ + '', + '', + '', + '', + 'start', + 'process', + 'cpu-total', + 'memory-real', + 'pid', + '', + '', + '123', + '4001', + exportCount === 1 + ? '100000000' + : '350000000', + '8388608', + '4001', + '', + '', + '', + ].join(''), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); const response = await handleSessionCommands({ req: { @@ -2167,13 +2255,14 @@ test('perf degrades Apple cpu and memory metrics on physical iOS devices', async if (!response?.ok) throw new Error('Expected perf response to succeed for physical iOS session'); const memory = (response.data?.metrics as any)?.memory; const cpu = (response.data?.metrics as any)?.cpu; - expect(memory?.available).toBe(false); - expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i); - expect(cpu?.available).toBe(false); - expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i); + expect(memory?.available).toBe(true); + expect(memory?.residentMemoryKb).toBe(8192); + expect(cpu?.available).toBe(true); + expect(cpu?.usagePercent).toBe(25); + expect(cpu?.matchedProcesses).toEqual(['ExampleDeviceApp']); }); -test('perf reports physical iOS cpu and memory as unsupported even without an app bundle id', async () => { +test('perf reports physical iOS cpu and memory as unavailable without an app bundle id', async () => { const sessionStore = makeSessionStore(); const sessionName = 'perf-session-ios-device-no-bundle'; sessionStore.set(sessionName, { @@ -2207,9 +2296,9 @@ test('perf reports physical iOS cpu and memory as unsupported even without an ap const memory = (response.data?.metrics as any)?.memory; const cpu = (response.data?.metrics as any)?.cpu; expect(memory?.available).toBe(false); - expect(memory?.reason).toMatch(/not yet implemented for physical iOS devices/i); + expect(memory?.reason).toMatch(/no apple app bundle id is associated with this session/i); expect(cpu?.available).toBe(false); - expect(cpu?.reason).toMatch(/not yet implemented for physical iOS devices/i); + expect(cpu?.reason).toMatch(/no apple app bundle id is associated with this session/i); }); test('open URL on existing iOS session clears stale app bundle id', async () => { diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index 6f388041..d2eef628 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -8,11 +8,7 @@ import { sampleAndroidCpuPerf, sampleAndroidMemoryPerf, } from '../../platforms/android/perf.ts'; -import { - APPLE_DEVICE_PERF_UNAVAILABLE_REASON, - buildAppleSamplingMetadata, - sampleApplePerfMetrics, -} from '../../platforms/ios/perf.ts'; +import { buildAppleSamplingMetadata, sampleApplePerfMetrics } from '../../platforms/ios/perf.ts'; import { PERF_STARTUP_SAMPLE_LIMIT, PERF_UNAVAILABLE_REASON, @@ -109,12 +105,6 @@ export async function buildPerfResponseData( return response; } - if (isUnsupportedAppleDevicePerfSession(session)) { - response.metrics.memory = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON }; - response.metrics.cpu = { available: false, reason: APPLE_DEVICE_PERF_UNAVAILABLE_REASON }; - return response; - } - if (!session.appBundleId) { const reason = buildMissingAppPerfReason(session); response.metrics.memory = { available: false, reason }; @@ -143,10 +133,6 @@ function buildMissingAppPerfReason(session: SessionState): string { return 'No Apple app bundle ID is associated with this session. Run open first.'; } -function isUnsupportedAppleDevicePerfSession(session: SessionState): boolean { - return session.device.platform === 'ios' && session.device.kind === 'device'; -} - function buildPlatformSamplingMetadata(session: SessionState): Record { if (session.device.platform === 'android') { return { diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 7a150b1f..94f45a99 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -78,6 +78,7 @@ import { withDiagnosticsScope } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../utils/errors.ts'; import { runCmd } from '../../../utils/exec.ts'; import { retryWithPolicy } from '../../../utils/retry.ts'; +import { parseIosDeviceProcessesPayload } from '../devicectl.ts'; const IOS_TEST_DEVICE: DeviceInfo = { platform: 'ios', @@ -1585,6 +1586,7 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => { { bundleIdentifier: 'com.apple.Maps', name: 'Maps', + url: 'file:///Applications/Maps.app/', }, { bundleIdentifier: 'com.example.NoName', @@ -1597,9 +1599,11 @@ test('parseIosDeviceAppsPayload maps devicectl app entries', () => { assert.deepEqual(apps[0], { bundleId: 'com.apple.Maps', name: 'Maps', + url: 'file:///Applications/Maps.app/', }); assert.equal(apps[1].bundleId, 'com.example.NoName'); assert.equal(apps[1].name, 'com.example.NoName'); + assert.equal(apps[1].url, undefined); }); test('parseIosDeviceAppsPayload ignores malformed entries', () => { @@ -1611,6 +1615,34 @@ test('parseIosDeviceAppsPayload ignores malformed entries', () => { assert.deepEqual(apps, []); }); +test('parseIosDeviceProcessesPayload maps running process entries', () => { + const processes = parseIosDeviceProcessesPayload({ + result: { + runningProcesses: [ + { + executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo', + processIdentifier: 421, + }, + { + executable: 'file:///usr/libexec/backboardd', + processIdentifier: 72, + }, + ], + }, + }); + + assert.deepEqual(processes, [ + { + executable: 'file:///private/var/containers/Bundle/Application/ABC123/Demo.app/Demo', + pid: 421, + }, + { + executable: 'file:///usr/libexec/backboardd', + pid: 72, + }, + ]); +}); + test('resolveIosApp resolves app display name on iOS physical devices', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-app-resolve-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index 01fc8904..6c5f90a8 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -9,7 +9,7 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => { return { ...actual, runCmd: vi.fn(actual.runCmd) }; }); -import { sampleApplePerfMetrics, parseApplePsOutput } from '../perf.ts'; +import { parseApplePsOutput, parseIosDevicePerfTable, sampleApplePerfMetrics } from '../perf.ts'; import { runCmd } from '../../../utils/exec.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; @@ -42,6 +42,7 @@ const IOS_DEVICE: DeviceInfo = { beforeEach(() => { vi.resetAllMocks(); + vi.useRealTimers(); }); test('parseApplePsOutput reads pid cpu rss and command columns', () => { @@ -67,6 +68,80 @@ test('parseApplePsOutput reads pid cpu rss and command columns', () => { ]); }); +test('parseIosDevicePerfTable reads Activity Monitor cpu time and memory columns', () => { + const rows = parseIosDevicePerfTable( + [ + '', + '', + '', + '', + 'start', + 'process', + 'cpu-total', + 'memory-real', + 'pid', + '', + '', + '123', + '101', + '250000000', + '12582912', + '', + '', + '', + '456', + '202', + '125000000', + '4194304', + '202', + '', + '', + '789', + '303', + '125000000', + '2097152', + '303', + '', + '', + '1000', + '', + '', + '', + '', + '', + '', + '', + ].join(''), + ); + + assert.deepEqual(rows, [ + { + pid: 101, + processName: 'ExampleApp', + cpuTimeNs: 250000000, + residentMemoryBytes: 12582912, + }, + { + pid: 202, + processName: 'ExampleHelper', + cpuTimeNs: 125000000, + residentMemoryBytes: 4194304, + }, + { + pid: 303, + processName: 'ExampleRef', + cpuTimeNs: 125000000, + residentMemoryBytes: 2097152, + }, + { + pid: 303, + processName: 'ExampleRef', + cpuTimeNs: 125000000, + residentMemoryBytes: 2097152, + }, + ]); +}); + test('sampleApplePerfMetrics aggregates host ps metrics for macOS app bundle', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-macos-perf-')); const bundlePath = path.join(tmpDir, 'Example.app'); @@ -156,9 +231,116 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async () } }); -test('sampleApplePerfMetrics rejects physical iOS devices for now', async () => { - await assert.rejects( - () => sampleApplePerfMetrics(IOS_DEVICE, 'com.example.device'), - /not yet implemented for physical iOS devices/i, +test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); + + const firstCaptureXml = [ + '', + '', + '', + '', + 'start', + 'process', + 'cpu-total', + 'memory-real', + 'pid', + '', + '', + '123', + '4001', + '100000000', + '8388608', + '4001', + '', + '', + '124', + '5001', + '75000000', + '4194304', + '5001', + '', + '', + '', + ].join(''); + const secondCaptureXml = firstCaptureXml.replace( + '100000000', + '350000000', ); + let exportCount = 0; + + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd !== 'xcrun') { + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + } + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'apps' + ) { + const outputIndex = args.indexOf('--json-output'); + await fs.writeFile( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + apps: [ + { + bundleIdentifier: 'com.example.device', + name: 'Example Device App', + url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/', + }, + ], + }, + }), + 'utf8', + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'processes' + ) { + const outputIndex = args.indexOf('--json-output'); + await fs.writeFile( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + runningProcesses: [ + { + executable: + 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp', + processIdentifier: 4001, + }, + ], + }, + }), + 'utf8', + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'record') { + vi.setSystemTime(new Date(Date.now() + 1000)); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'export') { + const outputIndex = args.indexOf('--output'); + exportCount += 1; + await fs.writeFile( + args[outputIndex + 1]!, + exportCount === 1 ? firstCaptureXml : secondCaptureXml, + 'utf8', + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + throw new Error(`unexpected xcrun args: ${args.join(' ')}`); + }); + + const metrics = await sampleApplePerfMetrics(IOS_DEVICE, 'com.example.device'); + assert.equal(metrics.cpu.usagePercent, 25); + assert.equal(metrics.memory.residentMemoryKb, 8192); + assert.equal(metrics.cpu.method, 'xctrace-activity-monitor'); + assert.deepEqual(metrics.cpu.matchedProcesses, ['ExampleDeviceApp']); }); diff --git a/src/platforms/ios/devicectl.ts b/src/platforms/ios/devicectl.ts index 6e14ede9..90398911 100644 --- a/src/platforms/ios/devicectl.ts +++ b/src/platforms/ios/devicectl.ts @@ -11,6 +11,7 @@ import { IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; export type IosAppInfo = { bundleId: string; name: string; + url?: string; }; type IosDeviceAppsPayload = { @@ -18,6 +19,21 @@ type IosDeviceAppsPayload = { apps?: Array<{ bundleIdentifier?: unknown; name?: unknown; + url?: unknown; + }>; + }; +}; + +export type IosDeviceProcessInfo = { + executable: string; + pid: number; +}; + +type IosDeviceProcessesPayload = { + result?: { + runningProcesses?: Array<{ + executable?: unknown; + processIdentifier?: unknown; }>; }; }; @@ -97,6 +113,53 @@ export async function listIosDeviceApps( } } +export async function listIosDeviceProcesses(device: DeviceInfo): Promise { + const jsonPath = path.join( + os.tmpdir(), + `agent-device-ios-processes-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const args = [ + 'devicectl', + 'device', + 'info', + 'processes', + '--device', + device.id, + '--json-output', + jsonPath, + ]; + const result = await runCmd('xcrun', args, { + allowFailure: true, + timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, + }); + + try { + if (result.exitCode !== 0) { + const stdout = String(result.stdout ?? ''); + const stderr = String(result.stderr ?? ''); + throw new AppError('COMMAND_FAILED', 'Failed to list iOS processes', { + cmd: 'xcrun', + args, + exitCode: result.exitCode, + stdout, + stderr, + deviceId: device.id, + hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }); + } + const jsonText = await fs.readFile(jsonPath, 'utf8'); + return parseIosDeviceProcessesPayload(JSON.parse(jsonText)); + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError('COMMAND_FAILED', 'Failed to parse iOS process list', { + deviceId: device.id, + cause: String(error), + }); + } finally { + await fs.unlink(jsonPath).catch(() => {}); + } +} + export function parseIosDeviceAppsPayload(payload: unknown): IosAppInfo[] { const apps = (payload as IosDeviceAppsPayload | null | undefined)?.result?.apps; if (!Array.isArray(apps)) return []; @@ -109,7 +172,28 @@ export function parseIosDeviceAppsPayload(payload: unknown): IosAppInfo[] { if (!bundleId) continue; const name = typeof entry.name === 'string' && entry.name.trim().length > 0 ? entry.name.trim() : bundleId; - parsed.push({ bundleId, name }); + const url = + typeof entry.url === 'string' && entry.url.trim().length > 0 ? entry.url.trim() : undefined; + parsed.push({ bundleId, name, url }); + } + return parsed; +} + +export function parseIosDeviceProcessesPayload(payload: unknown): IosDeviceProcessInfo[] { + const processes = (payload as IosDeviceProcessesPayload | null | undefined)?.result + ?.runningProcesses; + if (!Array.isArray(processes)) return []; + + const parsed: IosDeviceProcessInfo[] = []; + for (const entry of processes) { + if (!entry || typeof entry !== 'object') continue; + const executable = typeof entry.executable === 'string' ? entry.executable.trim() : ''; + const pid = + typeof entry.processIdentifier === 'number' && Number.isFinite(entry.processIdentifier) + ? entry.processIdentifier + : NaN; + if (!executable || !Number.isFinite(pid)) continue; + parsed.push({ executable, pid }); } return parsed; } diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index f25725ba..533fb3fd 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -1,29 +1,43 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { uniqueStrings } from '../../daemon/action-utils.ts'; +import { + IOS_DEVICECTL_DEFAULT_HINT, + listIosDeviceApps, + listIosDeviceProcesses, + resolveIosDevicectlHint, + type IosDeviceProcessInfo, +} from './devicectl.ts'; import { readInfoPlistString } from './plist.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; export const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot'; export const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot'; -export const APPLE_DEVICE_PERF_UNAVAILABLE_REASON = - 'CPU and memory sampling are not yet implemented for physical iOS devices.'; +export const IOS_DEVICE_CPU_SAMPLE_METHOD = 'xctrace-activity-monitor'; +export const IOS_DEVICE_MEMORY_SAMPLE_METHOD = 'xctrace-activity-monitor'; const APPLE_PERF_TIMEOUT_MS = 15_000; +// Physical device tracing can take materially longer to initialize than the 1s sample window. +const IOS_DEVICE_PERF_RECORD_TIMEOUT_MS = 60_000; +const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000; +const IOS_DEVICE_PERF_TRACE_DURATION = '1s'; export type AppleCpuPerfSample = { usagePercent: number; measuredAt: string; - method: typeof APPLE_CPU_SAMPLE_METHOD; + method: typeof APPLE_CPU_SAMPLE_METHOD | typeof IOS_DEVICE_CPU_SAMPLE_METHOD; matchedProcesses: string[]; }; export type AppleMemoryPerfSample = { residentMemoryKb: number; measuredAt: string; - method: typeof APPLE_MEMORY_SAMPLE_METHOD; + method: typeof APPLE_MEMORY_SAMPLE_METHOD | typeof IOS_DEVICE_MEMORY_SAMPLE_METHOD; matchedProcesses: string[]; }; @@ -34,17 +48,32 @@ type AppleProcessSample = { command: string; }; +type IosDevicePerfProcessSample = { + pid: number; + processName: string; + cpuTimeNs: number | null; + residentMemoryBytes: number | null; +}; + +type ParsedXmlElement = { + raw: string; + id?: string; + ref?: string; + fmt?: string; + text: string | null; +}; + +type IosDevicePerfCapture = { + capturedAtMs: number; + xml: string; +}; + export async function sampleApplePerfMetrics( device: DeviceInfo, appBundleId: string, ): Promise<{ cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample }> { if (device.platform === 'ios' && device.kind === 'device') { - throw new AppError('UNSUPPORTED_OPERATION', APPLE_DEVICE_PERF_UNAVAILABLE_REASON, { - platform: device.platform, - deviceKind: device.kind, - appBundleId, - hint: 'Use an iOS simulator or macOS app session for CPU/memory perf sampling for now.', - }); + return await sampleIosDevicePerfMetrics(device, appBundleId); } const executable = await resolveAppleExecutable(device, appBundleId); @@ -60,35 +89,29 @@ export async function sampleApplePerfMetrics( const matchedProcesses = uniqueStrings( processes.map((process) => path.basename(readProcessCommandToken(process.command))), ); - return { - cpu: { - usagePercent: roundPercent( - processes.reduce((total, process) => total + process.cpuPercent, 0), - ), - measuredAt, - method: APPLE_CPU_SAMPLE_METHOD, - matchedProcesses, - }, - memory: { - residentMemoryKb: Math.round(processes.reduce((total, process) => total + process.rssKb, 0)), - measuredAt, - method: APPLE_MEMORY_SAMPLE_METHOD, - matchedProcesses, - }, - }; + return buildApplePerfSamples({ + usagePercent: processes.reduce((total, process) => total + process.cpuPercent, 0), + residentMemoryKb: processes.reduce((total, process) => total + process.rssKb, 0), + measuredAt, + matchedProcesses, + cpuMethod: APPLE_CPU_SAMPLE_METHOD, + memoryMethod: APPLE_MEMORY_SAMPLE_METHOD, + }); } export function buildAppleSamplingMetadata(device: DeviceInfo): Record { if (device.platform === 'ios' && device.kind === 'device') { return { memory: { - method: APPLE_MEMORY_SAMPLE_METHOD, - description: APPLE_DEVICE_PERF_UNAVAILABLE_REASON, + method: IOS_DEVICE_MEMORY_SAMPLE_METHOD, + description: + 'Resident memory snapshot from a short xctrace Activity Monitor sample on the connected iOS device.', unit: 'kB', }, cpu: { - method: APPLE_CPU_SAMPLE_METHOD, - description: APPLE_DEVICE_PERF_UNAVAILABLE_REASON, + method: IOS_DEVICE_CPU_SAMPLE_METHOD, + description: + 'Recent CPU usage snapshot from a short xctrace Activity Monitor sample on the connected iOS device.', unit: 'percent', }, }; @@ -131,6 +154,70 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] { return rows; } +export function parseIosDevicePerfTable(xml: string): IosDevicePerfProcessSample[] { + const schemaMatch = xml.match( + /([\s\S]*?)<\/schema>/, + ); + if (!schemaMatch) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to parse xctrace activity-monitor-process-live schema', + ); + } + const mnemonics = [...schemaMatch[1].matchAll(/([^<]+)<\/mnemonic>/g)].map( + (match) => match[1] ?? '', + ); + const pidIndex = mnemonics.indexOf('pid'); + const processIndex = mnemonics.indexOf('process'); + const cpuTimeIndex = mnemonics.indexOf('cpu-total'); + const residentMemoryIndex = mnemonics.indexOf('memory-real'); + if (pidIndex < 0 || processIndex < 0 || cpuTimeIndex < 0 || residentMemoryIndex < 0) { + throw new AppError( + 'COMMAND_FAILED', + 'xctrace activity-monitor-process-live export is missing expected columns', + ); + } + + const rows = [...xml.matchAll(/([\s\S]*?)<\/row>/g)]; + const samples: IosDevicePerfProcessSample[] = []; + const references = new Map< + string, + { + numberValue?: number | null; + processName?: string | null; + } + >(); + for (const row of rows) { + const elements = splitTopLevelXmlElements(row[1] ?? '').map(parseXmlElement); + if (elements.length === 0) continue; + for (const element of elements) { + const nestedPidMatch = element.raw.match(/]*\bid="([^"]+)"[^>]*>([^<]+)<\/pid>/); + if (nestedPidMatch) { + const pidValue = Number(nestedPidMatch[2]); + references.set(nestedPidMatch[1], { + numberValue: Number.isFinite(pidValue) ? pidValue : null, + }); + } + if (!element.id) continue; + references.set(element.id, { + numberValue: parseDirectXmlNumber(element), + processName: readDirectProcessNameFromXml(element), + }); + } + + const pid = resolveXmlNumber(elements[pidIndex], references); + const processName = resolveProcessName(elements[processIndex], references); + if (pid === null || !Number.isFinite(pid) || !processName) continue; + samples.push({ + pid, + processName, + cpuTimeNs: resolveXmlNumber(elements[cpuTimeIndex], references), + residentMemoryBytes: resolveXmlNumber(elements[residentMemoryIndex], references), + }); + } + return samples; +} + async function resolveAppleExecutable( device: DeviceInfo, appBundleId: string, @@ -160,6 +247,225 @@ async function resolveAppleExecutable( }; } +async function sampleIosDevicePerfMetrics( + device: DeviceInfo, + appBundleId: string, +): Promise<{ cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample }> { + const processes = await resolveIosDevicePerfTarget(device, appBundleId); + const measuredAt = new Date().toISOString(); + const firstCapture = await captureIosDevicePerfTable(device, appBundleId); + const secondCapture = await captureIosDevicePerfTable(device, appBundleId); + const firstSnapshot = summarizeIosDevicePerfSnapshot( + parseIosDevicePerfTable(firstCapture.xml), + processes, + appBundleId, + device, + ); + const secondSnapshot = summarizeIosDevicePerfSnapshot( + parseIosDevicePerfTable(secondCapture.xml), + processes, + appBundleId, + device, + ); + + const elapsedMs = secondCapture.capturedAtMs - firstCapture.capturedAtMs; + if (elapsedMs <= 0) { + throw new AppError( + 'COMMAND_FAILED', + `Invalid Activity Monitor sample window for ${appBundleId}`, + { + appBundleId, + deviceId: device.id, + }, + ); + } + if ( + firstSnapshot.cpuTimeNs === null || + secondSnapshot.cpuTimeNs === null || + secondSnapshot.residentMemoryBytes === null + ) { + throw new AppError('COMMAND_FAILED', `Incomplete Activity Monitor sample for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + hint: 'Keep the app running in the foreground while perf samples the device, then retry.', + }); + } + + const cpuDeltaNs = Math.max(0, secondSnapshot.cpuTimeNs - firstSnapshot.cpuTimeNs); + const usagePercent = (cpuDeltaNs / (elapsedMs * 1_000_000)) * 100; + + return buildApplePerfSamples({ + usagePercent, + residentMemoryKb: secondSnapshot.residentMemoryBytes / 1024, + measuredAt, + matchedProcesses: secondSnapshot.matchedProcesses, + cpuMethod: IOS_DEVICE_CPU_SAMPLE_METHOD, + memoryMethod: IOS_DEVICE_MEMORY_SAMPLE_METHOD, + }); +} + +async function resolveIosDevicePerfTarget( + device: DeviceInfo, + appBundleId: string, +): Promise { + const apps = await listIosDeviceApps(device, 'all'); + const app = apps.find((candidate) => candidate.bundleId === appBundleId); + if (!app) { + throw new AppError('APP_NOT_INSTALLED', `No iOS device app found for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + }); + } + if (!app.url) { + throw new AppError('COMMAND_FAILED', `Missing app bundle URL for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + }); + } + + const appBundleUrl = app.url.replace(/\/$/, ''); + const appBundlePath = fileURLToPath(appBundleUrl); + const processes = (await listIosDeviceProcesses(device)).filter((process) => + process.executable.startsWith(`${appBundleUrl}/`), + ); + if (processes.length === 0) { + throw new AppError('COMMAND_FAILED', `No running process found for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + appBundlePath, + hint: 'Run open for this session again to ensure the iOS app is active, then retry perf.', + }); + } + + return processes; +} + +async function captureIosDevicePerfTable( + device: DeviceInfo, + appBundleId: string, +): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-perf-')); + const tracePath = path.join(tempDir, 'sample.trace'); + const exportPath = path.join(tempDir, 'activity-monitor-process-live.xml'); + try { + const recordArgs = [ + 'xctrace', + 'record', + '--template', + 'Activity Monitor', + '--device', + device.id, + '--all-processes', + '--time-limit', + IOS_DEVICE_PERF_TRACE_DURATION, + '--output', + tracePath, + '--quiet', + '--no-prompt', + ]; + const recordResult = await runCmd('xcrun', recordArgs, { + allowFailure: true, + timeoutMs: IOS_DEVICE_PERF_RECORD_TIMEOUT_MS, + }); + const capturedAtMs = Date.now(); + if (recordResult.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to record iOS device Activity Monitor sample for ${appBundleId}`, + { + cmd: 'xcrun', + args: recordArgs, + exitCode: recordResult.exitCode, + stdout: recordResult.stdout, + stderr: recordResult.stderr, + appBundleId, + deviceId: device.id, + hint: resolveIosDevicePerfHint(recordResult.stdout, recordResult.stderr), + }, + ); + } + + const exportArgs = [ + 'xctrace', + 'export', + '--input', + tracePath, + '--xpath', + '/trace-toc/run/data/table[@schema="activity-monitor-process-live"]', + '--output', + exportPath, + ]; + const exportResult = await runCmd('xcrun', exportArgs, { + allowFailure: true, + timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, + }); + if (exportResult.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to export iOS device perf sample for ${appBundleId}`, + { + cmd: 'xcrun', + args: exportArgs, + exitCode: exportResult.exitCode, + stdout: exportResult.stdout, + stderr: exportResult.stderr, + appBundleId, + deviceId: device.id, + hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), + }, + ); + } + return { + capturedAtMs, + xml: await fs.readFile(exportPath, 'utf8'), + }; + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function summarizeIosDevicePerfSnapshot( + samples: IosDevicePerfProcessSample[], + processes: IosDeviceProcessInfo[], + appBundleId: string, + device: DeviceInfo, +): { + cpuTimeNs: number | null; + residentMemoryBytes: number | null; + matchedProcesses: string[]; +} { + const processIds = new Set(processes.map((process) => process.pid)); + const processNames = new Set( + processes.map((process) => path.basename(fileURLToPath(process.executable))), + ); + const matchedSamples = samples.filter( + (sample) => processIds.has(sample.pid) || processNames.has(sample.processName), + ); + if (matchedSamples.length === 0) { + throw new AppError('COMMAND_FAILED', `No Activity Monitor sample found for ${appBundleId}`, { + appBundleId, + deviceId: device.id, + hint: 'Keep the app running in the foreground while perf samples the device, then retry.', + }); + } + + const cpuTimeValues = matchedSamples + .map((sample) => sample.cpuTimeNs) + .filter((value): value is number => value !== null); + const residentMemoryValues = matchedSamples + .map((sample) => sample.residentMemoryBytes) + .filter((value): value is number => value !== null); + return { + cpuTimeNs: + cpuTimeValues.length > 0 ? cpuTimeValues.reduce((total, value) => total + value, 0) : null, + residentMemoryBytes: + residentMemoryValues.length > 0 + ? residentMemoryValues.reduce((total, value) => total + value, 0) + : null, + matchedProcesses: uniqueStrings(matchedSamples.map((sample) => sample.processName)), + }; +} + async function resolveMacOsBundlePath(appBundleId: string): Promise { const query = `kMDItemCFBundleIdentifier == "${appBundleId.replaceAll('"', '\\"')}"`; const result = await runCmd('mdfind', [query], { @@ -268,6 +574,153 @@ function readProcessCommandToken(command: string): string { return token; } +function buildApplePerfSamples(args: { + usagePercent: number; + residentMemoryKb: number; + measuredAt: string; + matchedProcesses: string[]; + cpuMethod: AppleCpuPerfSample['method']; + memoryMethod: AppleMemoryPerfSample['method']; +}): { cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample } { + return { + cpu: { + usagePercent: roundPercent(args.usagePercent), + measuredAt: args.measuredAt, + method: args.cpuMethod, + matchedProcesses: args.matchedProcesses, + }, + memory: { + residentMemoryKb: Math.round(args.residentMemoryKb), + measuredAt: args.measuredAt, + method: args.memoryMethod, + matchedProcesses: args.matchedProcesses, + }, + }; +} + +function splitTopLevelXmlElements(xml: string): string[] { + const children: string[] = []; + let cursor = 0; + while (cursor < xml.length) { + const start = xml.indexOf('<', cursor); + if (start < 0) break; + const openEnd = xml.indexOf('>', start); + if (openEnd < 0) break; + const openTag = xml.slice(start + 1, openEnd).trim(); + if (!openTag || openTag.startsWith('/') || openTag.startsWith('?') || openTag.startsWith('!')) { + cursor = openEnd + 1; + continue; + } + const nameMatch = openTag.match(/^([^\s/>]+)/); + const name = nameMatch?.[1]; + if (!name) { + cursor = openEnd + 1; + continue; + } + if (openTag.endsWith('/')) { + children.push(xml.slice(start, openEnd + 1)); + cursor = openEnd + 1; + continue; + } + + let depth = 1; + let position = openEnd + 1; + while (depth > 0) { + const nextStart = xml.indexOf('<', position); + if (nextStart < 0) break; + const nextEnd = xml.indexOf('>', nextStart); + if (nextEnd < 0) break; + const nextTag = xml.slice(nextStart + 1, nextEnd).trim(); + const nextNameMatch = nextTag.match(/^\/?([^\s/>]+)/); + const nextName = nextNameMatch?.[1]; + if (nextName === name) { + if (nextTag.startsWith('/')) { + depth -= 1; + } else if (!nextTag.endsWith('/')) { + depth += 1; + } + } + position = nextEnd + 1; + } + children.push(xml.slice(start, position)); + cursor = position; + } + return children; +} + +function parseXmlElement(raw: string): ParsedXmlElement { + const openEnd = raw.indexOf('>'); + const openTag = openEnd >= 0 ? raw.slice(0, openEnd + 1) : raw; + const closeStart = raw.lastIndexOf('= 0 && closeStart > openEnd + ? raw + .slice(openEnd + 1, closeStart) + .replace(/<[^>]+>/g, '') + .trim() || null + : null; + return { + raw, + id: readXmlAttribute(openTag, 'id'), + ref: readXmlAttribute(openTag, 'ref'), + fmt: readXmlAttribute(openTag, 'fmt'), + text, + }; +} + +function parseDirectXmlNumber(element: ParsedXmlElement | undefined): number | null { + if (!element || element.raw.includes(', +): number | null { + if (!element) return null; + if (element.ref) { + return references.get(element.ref)?.numberValue ?? null; + } + return parseDirectXmlNumber(element); +} + +function readDirectProcessNameFromXml(element: ParsedXmlElement | undefined): string | null { + const fmt = element?.fmt?.trim() ?? ''; + if (!fmt) return null; + return fmt.replace(/\s+\(\d+\)$/, '').trim(); +} + +function resolveProcessName( + element: ParsedXmlElement | undefined, + references: Map, +): string | null { + if (!element) return null; + if (element.ref) { + return references.get(element.ref)?.processName ?? null; + } + return readDirectProcessNameFromXml(element); +} + +function readXmlAttribute(openTag: string, attribute: string): string | undefined { + const match = openTag.match(new RegExp(`\\b${attribute}="([^"]+)"`)); + return match?.[1]; +} + +function resolveIosDevicePerfHint(stdout: string, stderr: string): string { + const devicectlHint = resolveIosDevicectlHint(stdout, stderr); + if (devicectlHint) return devicectlHint; + const text = `${stdout}\n${stderr}`.toLowerCase(); + if (text.includes('no device matched') || text.includes('failed to find device')) { + return IOS_DEVICECTL_DEFAULT_HINT; + } + if (text.includes('timed out')) { + return 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.'; + } + return 'Ensure the iOS device is unlocked, trusted, visible to xctrace, and the target app stays active while perf samples it.'; +} + function roundPercent(value: number): number { return Math.round(value * 10) / 10; } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f1fcaf03..234936ba 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -520,11 +520,11 @@ agent-device metrics --json - `cpu` from process CPU usage snapshots reported as a recent percentage - Platform support: - `startup`: iOS simulator, iOS physical device, Android emulator/device - - `memory` and `cpu`: Android emulator/device, macOS app sessions, and iOS simulators with an active app session (`open ` first) - - physical iOS devices still report `memory` and `cpu` as unavailable in this release + - `memory` and `cpu`: Android emulator/device, macOS app sessions, iOS simulators with an active app session (`open ` first), and iOS physical devices with an active app session - `fps` is still unavailable on all platforms in this release. - If no startup sample exists yet for the session, run `open ` first and retry `perf`. - If the session has no app package/bundle ID yet, `memory` and `cpu` remain unavailable until you `open `. +- On physical iOS devices, `perf` records a short `xcrun xctrace` Activity Monitor sample. Keep the device unlocked, connected, and the app active in the foreground while sampling. - Interpretation note: this startup metric is command round-trip timing and does not represent true first frame / first interactive app instrumentation. - CPU data is a lightweight process snapshot, so an idle app may legitimately read as `0`. diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 7c1c2523..a97ce6d1 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -25,7 +25,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t - iOS `appstate` is session-scoped on the selected target device. - iOS/tvOS simulator-only: `settings`, `push`, `clipboard`. - Apple simulators and macOS desktop app sessions: `alert`, `pinch`. -- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions and Apple app sessions on macOS or iOS simulators also expose CPU and memory snapshots when an app identifier is available in the session. +- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions and Apple app sessions on macOS, iOS simulators, or connected iOS devices also expose CPU and memory snapshots when an app identifier is available in the session. - iOS `record` supports simulators and physical devices. - Simulators use native `simctl io ... recordVideo`. - Physical devices use runner screenshot capture (`XCUIScreen.main.screenshot()` frames) stitched into MP4, so FPS is best-effort (not guaranteed 60 even with `--fps 60`). From ee6d558bd3be0a053c6089a013971913e6d6844c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:08:21 +0200 Subject: [PATCH 02/11] fix: restart stale daemon after source changes --- src/daemon-client.ts | 9 +-- src/daemon/code-signature.ts | 75 +++++++++++++++++++++++ src/daemon/server-lifecycle.ts | 15 +---- src/utils/__tests__/daemon-client.test.ts | 17 +++-- 4 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 src/daemon/code-signature.ts diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 857d024c..7393b369 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -13,6 +13,7 @@ import { runCmdDetached, runCmdSync } from './utils/exec.ts'; import { findProjectRoot, readVersion } from './utils/version.ts'; import { createRequestId, emitDiagnostic, withDiagnosticTimer } from './utils/diagnostics.ts'; import { isAgentDeviceDaemonProcess, stopProcessForTakeover } from './utils/process-identity.ts'; +import { computeDaemonCodeSignature as computeSharedDaemonCodeSignature } from './daemon/code-signature.ts'; import { resolveDaemonPaths, resolveDaemonServerMode, @@ -738,13 +739,7 @@ export function computeDaemonCodeSignature( entryPath: string, root: string = findProjectRoot(), ): string { - try { - const stat = fs.statSync(entryPath); - const relativePath = path.relative(root, entryPath) || entryPath; - return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; - } catch { - return 'unknown'; - } + return computeSharedDaemonCodeSignature(entryPath, root); } async function sendRequest( diff --git a/src/daemon/code-signature.ts b/src/daemon/code-signature.ts new file mode 100644 index 00000000..623be060 --- /dev/null +++ b/src/daemon/code-signature.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { findProjectRoot } from '../utils/version.ts'; + +export function resolveDaemonCodeSignature( + entryPath: string | undefined = process.argv[1], + root: string = findProjectRoot(), +): string { + if (!entryPath) return 'unknown'; + return computeDaemonCodeSignature(entryPath, root); +} + +export function computeDaemonCodeSignature( + entryPath: string, + root: string = findProjectRoot(), +): string { + const targetPath = resolveDaemonCodeSignatureTarget(entryPath, root); + try { + const stat = fs.statSync(targetPath); + if (!stat.isDirectory()) { + return formatSingleFileSignature(targetPath, stat, root); + } + + const hash = crypto.createHash('sha256'); + let fileCount = 0; + for (const filePath of walkSignatureFiles(targetPath)) { + const fileStat = fs.statSync(filePath); + const relativePath = path.relative(root, filePath) || path.basename(filePath); + hash.update( + `${relativePath}:${fileStat.size}:${Math.trunc(fileStat.mtimeMs)}:${fileStat.mode}\n`, + ); + fileCount += 1; + } + + const relativeTarget = path.relative(root, targetPath) || path.basename(targetPath); + return `${relativeTarget}:${fileCount}:${hash.digest('hex').slice(0, 16)}`; + } catch { + return 'unknown'; + } +} + +function resolveDaemonCodeSignatureTarget(entryPath: string, root: string): string { + const resolvedEntryPath = path.resolve(entryPath); + const sourceDaemonEntry = path.join(root, 'src', 'daemon.ts'); + const distDaemonEntry = path.join(root, 'dist', 'src', 'daemon.js'); + if (resolvedEntryPath === sourceDaemonEntry) { + return path.join(root, 'src'); + } + if (resolvedEntryPath === distDaemonEntry) { + return path.join(root, 'dist', 'src'); + } + return resolvedEntryPath; +} + +function formatSingleFileSignature(filePath: string, stat: fs.Stats, root: string): string { + const relativePath = path.relative(root, filePath) || path.basename(filePath); + return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; +} + +function* walkSignatureFiles(dirPath: string): Generator { + const entries = fs + .readdirSync(dirPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + yield* walkSignatureFiles(entryPath); + continue; + } + if (entry.isFile()) { + yield entryPath; + } + } +} diff --git a/src/daemon/server-lifecycle.ts b/src/daemon/server-lifecycle.ts index 9ee0dca4..8c488a42 100644 --- a/src/daemon/server-lifecycle.ts +++ b/src/daemon/server-lifecycle.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import path from 'node:path'; -import { findProjectRoot, readVersion } from '../utils/version.ts'; +import { readVersion } from '../utils/version.ts'; import { isAgentDeviceDaemonProcess, readProcessStartTime } from '../utils/process-identity.ts'; +import { resolveDaemonCodeSignature as resolveSharedDaemonCodeSignature } from './code-signature.ts'; export type DaemonLockInfo = { pid: number; @@ -11,16 +11,7 @@ export type DaemonLockInfo = { }; export function resolveDaemonCodeSignature(): string { - const entryPath = process.argv[1]; - if (!entryPath) return 'unknown'; - try { - const stat = fs.statSync(entryPath); - const root = findProjectRoot(); - const relativePath = path.relative(root, entryPath) || entryPath; - return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; - } catch { - return 'unknown'; - } + return resolveSharedDaemonCodeSignature(process.argv[1]); } export function writeInfo( diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index e33d279a..839e3c54 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -1032,14 +1032,21 @@ test('downloadRemoteArtifact times out stalled artifact responses and removes pa } }); -test('computeDaemonCodeSignature includes relative path, size, and mtime', () => { +test('computeDaemonCodeSignature fingerprints the daemon source tree', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-')); try { - const daemonEntryPath = path.join(root, 'dist', 'src', 'daemon.js'); - fs.mkdirSync(path.dirname(daemonEntryPath), { recursive: true }); + const daemonEntryPath = path.join(root, 'src', 'daemon.ts'); + const perfPath = path.join(root, 'src', 'platforms', 'ios', 'perf.ts'); + fs.mkdirSync(path.dirname(perfPath), { recursive: true }); fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); - const signature = computeDaemonCodeSignature(daemonEntryPath, root); - assert.match(signature, /^dist\/src\/daemon\.js:\d+:\d+$/); + fs.writeFileSync(perfPath, 'export const value = 1;\n', 'utf8'); + + const firstSignature = computeDaemonCodeSignature(daemonEntryPath, root); + assert.match(firstSignature, /^src:\d+:[a-f0-9]{16}$/); + + fs.writeFileSync(perfPath, 'export const value = 2;\n', 'utf8'); + const secondSignature = computeDaemonCodeSignature(daemonEntryPath, root); + assert.notEqual(secondSignature, firstSignature); } finally { fs.rmSync(root, { recursive: true, force: true }); } From dfebcb551ad9b465de061e072ba66139f88d14c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:14:13 +0200 Subject: [PATCH 03/11] chore: ignore tests in daemon signature --- src/daemon/code-signature.ts | 5 ++++- src/utils/__tests__/daemon-client.test.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/daemon/code-signature.ts b/src/daemon/code-signature.ts index 623be060..a1e2a4c2 100644 --- a/src/daemon/code-signature.ts +++ b/src/daemon/code-signature.ts @@ -63,12 +63,15 @@ function* walkSignatureFiles(dirPath: string): Generator { .readdirSync(dirPath, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { + if (entry.isDirectory() && entry.name === '__tests__') { + continue; + } const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { yield* walkSignatureFiles(entryPath); continue; } - if (entry.isFile()) { + if (entry.isFile() && !entry.name.includes('.test.')) { yield entryPath; } } diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index 839e3c54..e7d5266f 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -1052,6 +1052,27 @@ test('computeDaemonCodeSignature fingerprints the daemon source tree', () => { } }); +test('computeDaemonCodeSignature ignores source test files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-tests-')); + try { + const daemonEntryPath = path.join(root, 'src', 'daemon.ts'); + const runtimePath = path.join(root, 'src', 'platforms', 'ios', 'perf.ts'); + const testPath = path.join(root, 'src', 'platforms', 'ios', '__tests__', 'perf.test.ts'); + fs.mkdirSync(path.dirname(testPath), { recursive: true }); + fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); + fs.writeFileSync(runtimePath, 'export const value = 1;\n', 'utf8'); + fs.writeFileSync(testPath, 'export const ignored = 1;\n', 'utf8'); + + const firstSignature = computeDaemonCodeSignature(daemonEntryPath, root); + fs.writeFileSync(testPath, 'export const ignored = 2;\n', 'utf8'); + const secondSignature = computeDaemonCodeSignature(daemonEntryPath, root); + + assert.equal(secondSignature, firstSignature); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('stopDaemonProcessForTakeover terminates a matching daemon process', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-test-')); const daemonDir = path.join(root, 'agent-device', 'dist', 'src'); From 7d5f248b7cc500044356b327620486ce646a5051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:17:14 +0200 Subject: [PATCH 04/11] fix: dedupe iOS perf samples by pid --- src/platforms/ios/__tests__/perf.test.ts | 24 ++++++++++++--- src/platforms/ios/perf.ts | 39 +++++++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index 6c5f90a8..d61e3e06 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -263,10 +263,26 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy '', '', ].join(''); - const secondCaptureXml = firstCaptureXml.replace( - '100000000', - '350000000', - ); + const secondCaptureXml = firstCaptureXml + .replace( + '100000000', + '350000000', + ) + .replace( + '124', + [ + '', + '', + '123', + '4001', + '350000000', + '8388608', + '4001', + '', + '', + '124', + ].join(''), + ); let exportCount = 0; mockRunCmd.mockImplementation(async (cmd, args) => { diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index 533fb3fd..c956f26b 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -449,10 +449,29 @@ function summarizeIosDevicePerfSnapshot( }); } - const cpuTimeValues = matchedSamples + const latestSamplesByPid = new Map(); + for (const sample of matchedSamples) { + const previous = latestSamplesByPid.get(sample.pid); + if (!previous) { + latestSamplesByPid.set(sample.pid, sample); + continue; + } + latestSamplesByPid.set(sample.pid, { + pid: sample.pid, + processName: sample.processName || previous.processName, + cpuTimeNs: maxNullableNumber(previous.cpuTimeNs, sample.cpuTimeNs), + residentMemoryBytes: maxNullableNumber( + previous.residentMemoryBytes, + sample.residentMemoryBytes, + ), + }); + } + + const latestSamples = [...latestSamplesByPid.values()]; + const cpuTimeValues = latestSamples .map((sample) => sample.cpuTimeNs) .filter((value): value is number => value !== null); - const residentMemoryValues = matchedSamples + const residentMemoryValues = latestSamples .map((sample) => sample.residentMemoryBytes) .filter((value): value is number => value !== null); return { @@ -462,7 +481,7 @@ function summarizeIosDevicePerfSnapshot( residentMemoryValues.length > 0 ? residentMemoryValues.reduce((total, value) => total + value, 0) : null, - matchedProcesses: uniqueStrings(matchedSamples.map((sample) => sample.processName)), + matchedProcesses: uniqueStrings(latestSamples.map((sample) => sample.processName)), }; } @@ -704,8 +723,12 @@ function resolveProcessName( } function readXmlAttribute(openTag: string, attribute: string): string | undefined { - const match = openTag.match(new RegExp(`\\b${attribute}="([^"]+)"`)); - return match?.[1]; + for (const match of openTag.matchAll(/\b([^\s=/>]+)="([^"]*)"/g)) { + if (match[1] === attribute) { + return match[2]; + } + } + return undefined; } function resolveIosDevicePerfHint(stdout: string, stderr: string): string { @@ -724,3 +747,9 @@ function resolveIosDevicePerfHint(stdout: string, stderr: string): string { function roundPercent(value: number): number { return Math.round(value * 10) / 10; } + +function maxNullableNumber(left: number | null, right: number | null): number | null { + if (left === null) return right; + if (right === null) return left; + return Math.max(left, right); +} From 9392b2b113506094c003900ec6b00e710f2d1c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:28:24 +0200 Subject: [PATCH 05/11] chore: move daemon invalidation out of perf PR --- src/daemon-client.ts | 9 ++- src/daemon/code-signature.ts | 78 ----------------------- src/daemon/server-lifecycle.ts | 15 ++++- src/utils/__tests__/daemon-client.test.ts | 38 ++--------- 4 files changed, 24 insertions(+), 116 deletions(-) delete mode 100644 src/daemon/code-signature.ts diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 7393b369..857d024c 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -13,7 +13,6 @@ import { runCmdDetached, runCmdSync } from './utils/exec.ts'; import { findProjectRoot, readVersion } from './utils/version.ts'; import { createRequestId, emitDiagnostic, withDiagnosticTimer } from './utils/diagnostics.ts'; import { isAgentDeviceDaemonProcess, stopProcessForTakeover } from './utils/process-identity.ts'; -import { computeDaemonCodeSignature as computeSharedDaemonCodeSignature } from './daemon/code-signature.ts'; import { resolveDaemonPaths, resolveDaemonServerMode, @@ -739,7 +738,13 @@ export function computeDaemonCodeSignature( entryPath: string, root: string = findProjectRoot(), ): string { - return computeSharedDaemonCodeSignature(entryPath, root); + try { + const stat = fs.statSync(entryPath); + const relativePath = path.relative(root, entryPath) || entryPath; + return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; + } catch { + return 'unknown'; + } } async function sendRequest( diff --git a/src/daemon/code-signature.ts b/src/daemon/code-signature.ts deleted file mode 100644 index a1e2a4c2..00000000 --- a/src/daemon/code-signature.ts +++ /dev/null @@ -1,78 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { findProjectRoot } from '../utils/version.ts'; - -export function resolveDaemonCodeSignature( - entryPath: string | undefined = process.argv[1], - root: string = findProjectRoot(), -): string { - if (!entryPath) return 'unknown'; - return computeDaemonCodeSignature(entryPath, root); -} - -export function computeDaemonCodeSignature( - entryPath: string, - root: string = findProjectRoot(), -): string { - const targetPath = resolveDaemonCodeSignatureTarget(entryPath, root); - try { - const stat = fs.statSync(targetPath); - if (!stat.isDirectory()) { - return formatSingleFileSignature(targetPath, stat, root); - } - - const hash = crypto.createHash('sha256'); - let fileCount = 0; - for (const filePath of walkSignatureFiles(targetPath)) { - const fileStat = fs.statSync(filePath); - const relativePath = path.relative(root, filePath) || path.basename(filePath); - hash.update( - `${relativePath}:${fileStat.size}:${Math.trunc(fileStat.mtimeMs)}:${fileStat.mode}\n`, - ); - fileCount += 1; - } - - const relativeTarget = path.relative(root, targetPath) || path.basename(targetPath); - return `${relativeTarget}:${fileCount}:${hash.digest('hex').slice(0, 16)}`; - } catch { - return 'unknown'; - } -} - -function resolveDaemonCodeSignatureTarget(entryPath: string, root: string): string { - const resolvedEntryPath = path.resolve(entryPath); - const sourceDaemonEntry = path.join(root, 'src', 'daemon.ts'); - const distDaemonEntry = path.join(root, 'dist', 'src', 'daemon.js'); - if (resolvedEntryPath === sourceDaemonEntry) { - return path.join(root, 'src'); - } - if (resolvedEntryPath === distDaemonEntry) { - return path.join(root, 'dist', 'src'); - } - return resolvedEntryPath; -} - -function formatSingleFileSignature(filePath: string, stat: fs.Stats, root: string): string { - const relativePath = path.relative(root, filePath) || path.basename(filePath); - return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; -} - -function* walkSignatureFiles(dirPath: string): Generator { - const entries = fs - .readdirSync(dirPath, { withFileTypes: true }) - .sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - if (entry.isDirectory() && entry.name === '__tests__') { - continue; - } - const entryPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - yield* walkSignatureFiles(entryPath); - continue; - } - if (entry.isFile() && !entry.name.includes('.test.')) { - yield entryPath; - } - } -} diff --git a/src/daemon/server-lifecycle.ts b/src/daemon/server-lifecycle.ts index 8c488a42..9ee0dca4 100644 --- a/src/daemon/server-lifecycle.ts +++ b/src/daemon/server-lifecycle.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import { readVersion } from '../utils/version.ts'; +import path from 'node:path'; +import { findProjectRoot, readVersion } from '../utils/version.ts'; import { isAgentDeviceDaemonProcess, readProcessStartTime } from '../utils/process-identity.ts'; -import { resolveDaemonCodeSignature as resolveSharedDaemonCodeSignature } from './code-signature.ts'; export type DaemonLockInfo = { pid: number; @@ -11,7 +11,16 @@ export type DaemonLockInfo = { }; export function resolveDaemonCodeSignature(): string { - return resolveSharedDaemonCodeSignature(process.argv[1]); + const entryPath = process.argv[1]; + if (!entryPath) return 'unknown'; + try { + const stat = fs.statSync(entryPath); + const root = findProjectRoot(); + const relativePath = path.relative(root, entryPath) || entryPath; + return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; + } catch { + return 'unknown'; + } } export function writeInfo( diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index e7d5266f..e33d279a 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -1032,42 +1032,14 @@ test('downloadRemoteArtifact times out stalled artifact responses and removes pa } }); -test('computeDaemonCodeSignature fingerprints the daemon source tree', () => { +test('computeDaemonCodeSignature includes relative path, size, and mtime', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-')); try { - const daemonEntryPath = path.join(root, 'src', 'daemon.ts'); - const perfPath = path.join(root, 'src', 'platforms', 'ios', 'perf.ts'); - fs.mkdirSync(path.dirname(perfPath), { recursive: true }); + const daemonEntryPath = path.join(root, 'dist', 'src', 'daemon.js'); + fs.mkdirSync(path.dirname(daemonEntryPath), { recursive: true }); fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); - fs.writeFileSync(perfPath, 'export const value = 1;\n', 'utf8'); - - const firstSignature = computeDaemonCodeSignature(daemonEntryPath, root); - assert.match(firstSignature, /^src:\d+:[a-f0-9]{16}$/); - - fs.writeFileSync(perfPath, 'export const value = 2;\n', 'utf8'); - const secondSignature = computeDaemonCodeSignature(daemonEntryPath, root); - assert.notEqual(secondSignature, firstSignature); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('computeDaemonCodeSignature ignores source test files', () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-tests-')); - try { - const daemonEntryPath = path.join(root, 'src', 'daemon.ts'); - const runtimePath = path.join(root, 'src', 'platforms', 'ios', 'perf.ts'); - const testPath = path.join(root, 'src', 'platforms', 'ios', '__tests__', 'perf.test.ts'); - fs.mkdirSync(path.dirname(testPath), { recursive: true }); - fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); - fs.writeFileSync(runtimePath, 'export const value = 1;\n', 'utf8'); - fs.writeFileSync(testPath, 'export const ignored = 1;\n', 'utf8'); - - const firstSignature = computeDaemonCodeSignature(daemonEntryPath, root); - fs.writeFileSync(testPath, 'export const ignored = 2;\n', 'utf8'); - const secondSignature = computeDaemonCodeSignature(daemonEntryPath, root); - - assert.equal(secondSignature, firstSignature); + const signature = computeDaemonCodeSignature(daemonEntryPath, root); + assert.match(signature, /^dist\/src\/daemon\.js:\d+:\d+$/); } finally { fs.rmSync(root, { recursive: true, force: true }); } From d6d78df6d9ca2b0073427b9705dd334d507515b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 14:32:42 +0200 Subject: [PATCH 06/11] refactor: use fast xml parser for iOS perf exports --- package.json | 1 + pnpm-lock.yaml | 65 +++---- src/platforms/ios/__tests__/perf.test.ts | 6 +- src/platforms/ios/perf.ts | 238 +++++++++++++---------- 4 files changed, 171 insertions(+), 139 deletions(-) diff --git a/package.json b/package.json index 5801ece1..2b0ed567 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "android" ], "dependencies": { + "fast-xml-parser": "^5.5.10", "pngjs": "^7.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1990b81..f2634891 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + fast-xml-parser: + specifier: ^5.5.10 + version: 5.5.10 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -65,28 +68,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.37.0': resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.37.0': resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.37.0': resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.37.0': resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==} @@ -209,56 +208,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.42.0': resolution: {integrity: sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.42.0': resolution: {integrity: sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.42.0': resolution: {integrity: sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.42.0': resolution: {integrity: sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.42.0': resolution: {integrity: sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.42.0': resolution: {integrity: sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.42.0': resolution: {integrity: sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.42.0': resolution: {integrity: sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==} @@ -331,56 +322,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.57.0': resolution: {integrity: sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.57.0': resolution: {integrity: sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.57.0': resolution: {integrity: sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.57.0': resolution: {integrity: sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.57.0': resolution: {integrity: sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.57.0': resolution: {integrity: sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.57.0': resolution: {integrity: sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.57.0': resolution: {integrity: sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==} @@ -441,42 +424,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -549,25 +526,21 @@ packages: resolution: {integrity: sha512-vD2+ztbMmeBR65jBlwUZCNIjUzO0exp/LaPSMIhLlqPlk670gMCQ7fmKo3tSgQ9tobfizEA/Atdy3/lW1Rl64A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rspack/binding-linux-arm64-musl@2.0.0-beta.8': resolution: {integrity: sha512-jJ1XB7Yz9YdPRA6MJ35S9/mb+3jeI4p9v78E3dexzCPA3G4X7WXbyOcRbUlYcyOlE5MtX5O19rDexqWlkD9tVw==} cpu: [arm64] os: [linux] - libc: [musl] '@rspack/binding-linux-x64-gnu@2.0.0-beta.8': resolution: {integrity: sha512-qy+fK/tiYw3KvGjTGGMu/mWOdvBYrMO8xva/ouiaRTrx64PPZ6vyqFXOUfHj9rhY5L6aU2NTObpV6HZHcBtmhQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rspack/binding-linux-x64-musl@2.0.0-beta.8': resolution: {integrity: sha512-eJF1IsayHhsURu5Dp6fzdr5jYGeJmoREOZAc9UV3aEqY6zNAcWgZT1RwKCCujJylmHgCTCOuxqdK/VdFJqWDyw==} cpu: [x64] os: [linux] - libc: [musl] '@rspack/binding-wasm32-wasi@2.0.0-beta.8': resolution: {integrity: sha512-HssdOQE8i+nUWoK+NDeD5OSyNxf80k3elKCl/due3WunoNn0h6tUTSZ8QB+bhcT4tjH9vTbibWZIT91avtvUNw==} @@ -1020,6 +993,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.10: + resolution: {integrity: sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1226,28 +1206,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1522,6 +1498,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-expression-matcher@1.4.0: + resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} + engines: {node: '>=14.0.0'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1770,6 +1750,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2845,6 +2828,16 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.4.0 + + fast-xml-parser@5.5.10: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.4.0 + strnum: 2.2.3 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -3682,6 +3675,8 @@ snapshots: dependencies: entities: 6.0.1 + path-expression-matcher@1.4.0: {} + path-parse@1.0.7: {} pathe@2.0.3: {} @@ -3962,6 +3957,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.2.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index d61e3e06..991d7b88 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -68,8 +68,8 @@ test('parseApplePsOutput reads pid cpu rss and command columns', () => { ]); }); -test('parseIosDevicePerfTable reads Activity Monitor cpu time and memory columns', () => { - const rows = parseIosDevicePerfTable( +test('parseIosDevicePerfTable reads Activity Monitor cpu time and memory columns', async () => { + const rows = await parseIosDevicePerfTable( [ '', '', @@ -359,4 +359,6 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy assert.equal(metrics.memory.residentMemoryKb, 8192); assert.equal(metrics.cpu.method, 'xctrace-activity-monitor'); assert.deepEqual(metrics.cpu.matchedProcesses, ['ExampleDeviceApp']); + assert.equal(metrics.cpu.measuredAt, '2026-04-01T10:00:02.000Z'); + assert.equal(metrics.memory.measuredAt, '2026-04-01T10:00:02.000Z'); }); diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index c956f26b..07476eff 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -55,12 +55,11 @@ type IosDevicePerfProcessSample = { residentMemoryBytes: number | null; }; -type ParsedXmlElement = { - raw: string; - id?: string; - ref?: string; - fmt?: string; +type XmlNode = { + name: string; + attributes: Record; text: string | null; + children: XmlNode[]; }; type IosDevicePerfCapture = { @@ -68,6 +67,12 @@ type IosDevicePerfCapture = { xml: string; }; +type XmlParserInstance = { + parse(xml: string): unknown; +}; + +let xmlParserPromise: Promise | null = null; + export async function sampleApplePerfMetrics( device: DeviceInfo, appBundleId: string, @@ -154,19 +159,21 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] { return rows; } -export function parseIosDevicePerfTable(xml: string): IosDevicePerfProcessSample[] { - const schemaMatch = xml.match( - /([\s\S]*?)<\/schema>/, +export async function parseIosDevicePerfTable(xml: string): Promise { + const document = await parseXmlDocument(xml); + const schema = findFirstXmlNode( + document, + (node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live', ); - if (!schemaMatch) { + if (!schema) { throw new AppError( 'COMMAND_FAILED', 'Failed to parse xctrace activity-monitor-process-live schema', ); } - const mnemonics = [...schemaMatch[1].matchAll(/([^<]+)<\/mnemonic>/g)].map( - (match) => match[1] ?? '', - ); + const mnemonics = schema.children + .filter((child) => child.name === 'col') + .map((column) => readFirstChildText(column, 'mnemonic') ?? ''); const pidIndex = mnemonics.indexOf('pid'); const processIndex = mnemonics.indexOf('process'); const cpuTimeIndex = mnemonics.indexOf('cpu-total'); @@ -178,7 +185,7 @@ export function parseIosDevicePerfTable(xml: string): IosDevicePerfProcessSample ); } - const rows = [...xml.matchAll(/([\s\S]*?)<\/row>/g)]; + const rows = findAllXmlNodes(document, (node) => node.name === 'row'); const samples: IosDevicePerfProcessSample[] = []; const references = new Map< string, @@ -188,18 +195,21 @@ export function parseIosDevicePerfTable(xml: string): IosDevicePerfProcessSample } >(); for (const row of rows) { - const elements = splitTopLevelXmlElements(row[1] ?? '').map(parseXmlElement); + const elements = row.children; if (elements.length === 0) continue; for (const element of elements) { - const nestedPidMatch = element.raw.match(/]*\bid="([^"]+)"[^>]*>([^<]+)<\/pid>/); - if (nestedPidMatch) { - const pidValue = Number(nestedPidMatch[2]); - references.set(nestedPidMatch[1], { + const nestedPid = findFirstXmlNode( + element.children, + (child) => child.name === 'pid' && typeof child.attributes.id === 'string', + ); + if (nestedPid?.attributes.id) { + const pidValue = Number(nestedPid.text); + references.set(nestedPid.attributes.id, { numberValue: Number.isFinite(pidValue) ? pidValue : null, }); } - if (!element.id) continue; - references.set(element.id, { + if (!element.attributes.id) continue; + references.set(element.attributes.id, { numberValue: parseDirectXmlNumber(element), processName: readDirectProcessNameFromXml(element), }); @@ -252,17 +262,16 @@ async function sampleIosDevicePerfMetrics( appBundleId: string, ): Promise<{ cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample }> { const processes = await resolveIosDevicePerfTarget(device, appBundleId); - const measuredAt = new Date().toISOString(); const firstCapture = await captureIosDevicePerfTable(device, appBundleId); const secondCapture = await captureIosDevicePerfTable(device, appBundleId); const firstSnapshot = summarizeIosDevicePerfSnapshot( - parseIosDevicePerfTable(firstCapture.xml), + await parseIosDevicePerfTable(firstCapture.xml), processes, appBundleId, device, ); const secondSnapshot = summarizeIosDevicePerfSnapshot( - parseIosDevicePerfTable(secondCapture.xml), + await parseIosDevicePerfTable(secondCapture.xml), processes, appBundleId, device, @@ -297,7 +306,7 @@ async function sampleIosDevicePerfMetrics( return buildApplePerfSamples({ usagePercent, residentMemoryKb: secondSnapshot.residentMemoryBytes / 1024, - measuredAt, + measuredAt: new Date(secondCapture.capturedAtMs).toISOString(), matchedProcesses: secondSnapshot.matchedProcesses, cpuMethod: IOS_DEVICE_CPU_SAMPLE_METHOD, memoryMethod: IOS_DEVICE_MEMORY_SAMPLE_METHOD, @@ -617,120 +626,143 @@ function buildApplePerfSamples(args: { }; } -function splitTopLevelXmlElements(xml: string): string[] { - const children: string[] = []; - let cursor = 0; - while (cursor < xml.length) { - const start = xml.indexOf('<', cursor); - if (start < 0) break; - const openEnd = xml.indexOf('>', start); - if (openEnd < 0) break; - const openTag = xml.slice(start + 1, openEnd).trim(); - if (!openTag || openTag.startsWith('/') || openTag.startsWith('?') || openTag.startsWith('!')) { - cursor = openEnd + 1; - continue; +async function loadXmlParser(): Promise { + xmlParserPromise ??= import('fast-xml-parser').then( + ({ XMLParser }) => + new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + preserveOrder: true, + trimValues: true, + parseTagValue: false, + }), + ); + return await xmlParserPromise; +} + +async function parseXmlDocument(xml: string): Promise { + const parser = await loadXmlParser(); + return normalizeXmlNodes(parser.parse(xml)); +} + +function normalizeXmlNodes(value: unknown): XmlNode[] { + if (!Array.isArray(value)) return []; + const nodes: XmlNode[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as Record; + for (const [name, childValue] of Object.entries(record)) { + if (name === ':@' || name === '#text') continue; + nodes.push({ + name, + attributes: normalizeXmlAttributes(record[':@']), + text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), + children: normalizeXmlNodes(childValue), + }); } - const nameMatch = openTag.match(/^([^\s/>]+)/); - const name = nameMatch?.[1]; - if (!name) { - cursor = openEnd + 1; - continue; + } + return nodes; +} + +function normalizeXmlAttributes(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const attributes: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + attributes[key] = entry; } - if (openTag.endsWith('/')) { - children.push(xml.slice(start, openEnd + 1)); - cursor = openEnd + 1; - continue; + } + return attributes; +} + +function normalizeXmlText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeXmlNodeText(value: unknown): string | null { + if (!Array.isArray(value)) return null; + const text = value + .map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + return '#text' in entry + ? normalizeXmlText((entry as Record)['#text']) + : null; + }) + .filter((entry): entry is string => entry !== null) + .join('') + .trim(); + return text.length > 0 ? text : null; +} + +function findFirstXmlNode( + nodes: XmlNode[], + predicate: (node: XmlNode) => boolean, +): XmlNode | undefined { + for (const node of nodes) { + if (predicate(node)) { + return node; + } + const descendant = findFirstXmlNode(node.children, predicate); + if (descendant) { + return descendant; } + } + return undefined; +} - let depth = 1; - let position = openEnd + 1; - while (depth > 0) { - const nextStart = xml.indexOf('<', position); - if (nextStart < 0) break; - const nextEnd = xml.indexOf('>', nextStart); - if (nextEnd < 0) break; - const nextTag = xml.slice(nextStart + 1, nextEnd).trim(); - const nextNameMatch = nextTag.match(/^\/?([^\s/>]+)/); - const nextName = nextNameMatch?.[1]; - if (nextName === name) { - if (nextTag.startsWith('/')) { - depth -= 1; - } else if (!nextTag.endsWith('/')) { - depth += 1; - } - } - position = nextEnd + 1; +function findAllXmlNodes(nodes: XmlNode[], predicate: (node: XmlNode) => boolean): XmlNode[] { + const matches: XmlNode[] = []; + for (const node of nodes) { + if (predicate(node)) { + matches.push(node); } - children.push(xml.slice(start, position)); - cursor = position; + matches.push(...findAllXmlNodes(node.children, predicate)); } - return children; + return matches; } -function parseXmlElement(raw: string): ParsedXmlElement { - const openEnd = raw.indexOf('>'); - const openTag = openEnd >= 0 ? raw.slice(0, openEnd + 1) : raw; - const closeStart = raw.lastIndexOf('= 0 && closeStart > openEnd - ? raw - .slice(openEnd + 1, closeStart) - .replace(/<[^>]+>/g, '') - .trim() || null - : null; - return { - raw, - id: readXmlAttribute(openTag, 'id'), - ref: readXmlAttribute(openTag, 'ref'), - fmt: readXmlAttribute(openTag, 'fmt'), - text, - }; +function readFirstChildText(node: XmlNode, childName: string): string | null { + const child = node.children.find((candidate) => candidate.name === childName); + return child?.text ?? null; } -function parseDirectXmlNumber(element: ParsedXmlElement | undefined): number | null { - if (!element || element.raw.includes(' child.name === 'sentinel')) return null; if (!element.text) return null; const value = Number(element.text); return Number.isFinite(value) ? value : null; } function resolveXmlNumber( - element: ParsedXmlElement | undefined, + element: XmlNode | undefined, references: Map, ): number | null { if (!element) return null; - if (element.ref) { - return references.get(element.ref)?.numberValue ?? null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.numberValue ?? null; } return parseDirectXmlNumber(element); } -function readDirectProcessNameFromXml(element: ParsedXmlElement | undefined): string | null { - const fmt = element?.fmt?.trim() ?? ''; +function readDirectProcessNameFromXml(element: XmlNode | undefined): string | null { + const fmt = element?.attributes.fmt?.trim() ?? ''; if (!fmt) return null; return fmt.replace(/\s+\(\d+\)$/, '').trim(); } function resolveProcessName( - element: ParsedXmlElement | undefined, + element: XmlNode | undefined, references: Map, ): string | null { if (!element) return null; - if (element.ref) { - return references.get(element.ref)?.processName ?? null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.processName ?? null; } return readDirectProcessNameFromXml(element); } -function readXmlAttribute(openTag: string, attribute: string): string | undefined { - for (const match of openTag.matchAll(/\b([^\s=/>]+)="([^"]*)"/g)) { - if (match[1] === attribute) { - return match[2]; - } - } - return undefined; -} - function resolveIosDevicePerfHint(stdout: string, stderr: string): string { const devicectlHint = resolveIosDevicectlHint(stdout, stderr); if (devicectlHint) return devicectlHint; From df3bb30cf42b3a8f2b9c84c54b0b7d912009db88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 15:08:51 +0200 Subject: [PATCH 07/11] refactor: simplify iOS perf XML coverage --- src/platforms/ios/__tests__/perf.test.ts | 95 ++---------- src/platforms/ios/perf.ts | 182 ++++++++--------------- 2 files changed, 74 insertions(+), 203 deletions(-) diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index 991d7b88..1eacc601 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -9,7 +9,7 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => { return { ...actual, runCmd: vi.fn(actual.runCmd) }; }); -import { parseApplePsOutput, parseIosDevicePerfTable, sampleApplePerfMetrics } from '../perf.ts'; +import { parseApplePsOutput, sampleApplePerfMetrics } from '../perf.ts'; import { runCmd } from '../../../utils/exec.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; @@ -68,80 +68,6 @@ test('parseApplePsOutput reads pid cpu rss and command columns', () => { ]); }); -test('parseIosDevicePerfTable reads Activity Monitor cpu time and memory columns', async () => { - const rows = await parseIosDevicePerfTable( - [ - '', - '', - '', - '', - 'start', - 'process', - 'cpu-total', - 'memory-real', - 'pid', - '', - '', - '123', - '101', - '250000000', - '12582912', - '', - '', - '', - '456', - '202', - '125000000', - '4194304', - '202', - '', - '', - '789', - '303', - '125000000', - '2097152', - '303', - '', - '', - '1000', - '', - '', - '', - '', - '', - '', - '', - ].join(''), - ); - - assert.deepEqual(rows, [ - { - pid: 101, - processName: 'ExampleApp', - cpuTimeNs: 250000000, - residentMemoryBytes: 12582912, - }, - { - pid: 202, - processName: 'ExampleHelper', - cpuTimeNs: 125000000, - residentMemoryBytes: 4194304, - }, - { - pid: 303, - processName: 'ExampleRef', - cpuTimeNs: 125000000, - residentMemoryBytes: 2097152, - }, - { - pid: 303, - processName: 'ExampleRef', - cpuTimeNs: 125000000, - residentMemoryBytes: 2097152, - }, - ]); -}); - test('sampleApplePerfMetrics aggregates host ps metrics for macOS app bundle', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-macos-perf-')); const bundlePath = path.join(tmpDir, 'Example.app'); @@ -266,7 +192,16 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy const secondCaptureXml = firstCaptureXml .replace( '100000000', - '350000000', + '350000000', + ) + .replace( + '8388608', + '8388608', + ) + .replace('4001', '4001') + .replace( + '4001', + '4001', ) .replace( '124', @@ -274,10 +209,10 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy '', '', '123', - '4001', - '350000000', - '8388608', - '4001', + '', + '', + '', + '', '', '', '124', diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index 07476eff..b864e92b 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -55,13 +55,6 @@ type IosDevicePerfProcessSample = { residentMemoryBytes: number | null; }; -type XmlNode = { - name: string; - attributes: Record; - text: string | null; - children: XmlNode[]; -}; - type IosDevicePerfCapture = { capturedAtMs: number; xml: string; @@ -71,6 +64,8 @@ type XmlParserInstance = { parse(xml: string): unknown; }; +type XmlValue = Record; + let xmlParserPromise: Promise | null = null; export async function sampleApplePerfMetrics( @@ -159,21 +154,19 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] { return rows; } -export async function parseIosDevicePerfTable(xml: string): Promise { +async function parseIosDevicePerfTable(xml: string): Promise { const document = await parseXmlDocument(xml); - const schema = findFirstXmlNode( - document, - (node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live', - ); - if (!schema) { + const node = asXmlObject(document['trace-query-result'])?.node; + const schema = asXmlObject(asXmlObject(node)?.schema); + if (!schema || schema.name !== 'activity-monitor-process-live') { throw new AppError( 'COMMAND_FAILED', 'Failed to parse xctrace activity-monitor-process-live schema', ); } - const mnemonics = schema.children - .filter((child) => child.name === 'col') - .map((column) => readFirstChildText(column, 'mnemonic') ?? ''); + const mnemonics = asXmlArray(schema.col).map( + (column) => asXmlObject(column)?.mnemonic?.toString().trim() ?? '', + ); const pidIndex = mnemonics.indexOf('pid'); const processIndex = mnemonics.indexOf('process'); const cpuTimeIndex = mnemonics.indexOf('cpu-total'); @@ -185,7 +178,7 @@ export async function parseIosDevicePerfTable(xml: string): Promise node.name === 'row'); + const rows = asXmlArray(asXmlObject(node)?.row); const samples: IosDevicePerfProcessSample[] = []; const references = new Map< string, @@ -195,34 +188,31 @@ export async function parseIosDevicePerfTable(xml: string): Promise(); for (const row of rows) { - const elements = row.children; + const elements = readRowElements(row); if (elements.length === 0) continue; for (const element of elements) { - const nestedPid = findFirstXmlNode( - element.children, - (child) => child.name === 'pid' && typeof child.attributes.id === 'string', - ); - if (nestedPid?.attributes.id) { - const pidValue = Number(nestedPid.text); - references.set(nestedPid.attributes.id, { + const nestedPid = asXmlObject(element.value.pid); + if (typeof nestedPid?.id === 'string') { + const pidValue = readXmlNumber(nestedPid); + references.set(nestedPid.id, { numberValue: Number.isFinite(pidValue) ? pidValue : null, }); } - if (!element.attributes.id) continue; - references.set(element.attributes.id, { - numberValue: parseDirectXmlNumber(element), - processName: readDirectProcessNameFromXml(element), + if (typeof element.value.id !== 'string') continue; + references.set(element.value.id, { + numberValue: parseDirectXmlNumber(element.value), + processName: readDirectProcessNameFromXml(element.value), }); } - const pid = resolveXmlNumber(elements[pidIndex], references); - const processName = resolveProcessName(elements[processIndex], references); + const pid = resolveXmlNumber(elements[pidIndex]?.value, references); + const processName = resolveProcessName(elements[processIndex]?.value, references); if (pid === null || !Number.isFinite(pid) || !processName) continue; samples.push({ pid, processName, - cpuTimeNs: resolveXmlNumber(elements[cpuTimeIndex], references), - residentMemoryBytes: resolveXmlNumber(elements[residentMemoryIndex], references), + cpuTimeNs: resolveXmlNumber(elements[cpuTimeIndex]?.value, references), + residentMemoryBytes: resolveXmlNumber(elements[residentMemoryIndex]?.value, references), }); } return samples; @@ -632,133 +622,79 @@ async function loadXmlParser(): Promise { new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', - preserveOrder: true, trimValues: true, parseTagValue: false, + isArray: (name) => name === 'col' || name === 'row', }), ); return await xmlParserPromise; } -async function parseXmlDocument(xml: string): Promise { +async function parseXmlDocument(xml: string): Promise { const parser = await loadXmlParser(); - return normalizeXmlNodes(parser.parse(xml)); -} - -function normalizeXmlNodes(value: unknown): XmlNode[] { - if (!Array.isArray(value)) return []; - const nodes: XmlNode[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; - const record = entry as Record; - for (const [name, childValue] of Object.entries(record)) { - if (name === ':@' || name === '#text') continue; - nodes.push({ - name, - attributes: normalizeXmlAttributes(record[':@']), - text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), - children: normalizeXmlNodes(childValue), - }); - } - } - return nodes; + return asXmlObject(parser.parse(xml)) ?? {}; } -function normalizeXmlAttributes(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const attributes: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'string') { - attributes[key] = entry; - } - } - return attributes; +function asXmlObject(value: unknown): XmlValue | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as XmlValue; } -function normalizeXmlText(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; +function asXmlArray(value: unknown): XmlValue[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => asXmlObject(entry)) + .filter((entry): entry is XmlValue => entry !== null); } -function normalizeXmlNodeText(value: unknown): string | null { - if (!Array.isArray(value)) return null; - const text = value - .map((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - return '#text' in entry - ? normalizeXmlText((entry as Record)['#text']) - : null; +function readRowElements(row: unknown): Array<{ name: string; value: XmlValue }> { + const xmlRow = asXmlObject(row); + if (!xmlRow) return []; + return Object.entries(xmlRow) + .map(([name, value]) => { + const element = asXmlObject(value); + return element ? { name, value: element } : null; }) - .filter((entry): entry is string => entry !== null) - .join('') - .trim(); - return text.length > 0 ? text : null; + .filter((entry): entry is { name: string; value: XmlValue } => entry !== null); } -function findFirstXmlNode( - nodes: XmlNode[], - predicate: (node: XmlNode) => boolean, -): XmlNode | undefined { - for (const node of nodes) { - if (predicate(node)) { - return node; - } - const descendant = findFirstXmlNode(node.children, predicate); - if (descendant) { - return descendant; - } - } - return undefined; -} - -function findAllXmlNodes(nodes: XmlNode[], predicate: (node: XmlNode) => boolean): XmlNode[] { - const matches: XmlNode[] = []; - for (const node of nodes) { - if (predicate(node)) { - matches.push(node); - } - matches.push(...findAllXmlNodes(node.children, predicate)); - } - return matches; -} - -function readFirstChildText(node: XmlNode, childName: string): string | null { - const child = node.children.find((candidate) => candidate.name === childName); - return child?.text ?? null; +function readXmlNumber(element: XmlValue | undefined): number | null { + if (!element) return null; + const text = typeof element['#text'] === 'string' ? element['#text'].trim() : ''; + if (text.length === 0) return null; + const value = Number(text); + return Number.isFinite(value) ? value : null; } -function parseDirectXmlNumber(element: XmlNode | undefined): number | null { - if (!element || element.children.some((child) => child.name === 'sentinel')) return null; - if (!element.text) return null; - const value = Number(element.text); - return Number.isFinite(value) ? value : null; +function parseDirectXmlNumber(element: XmlValue | undefined): number | null { + if (!element || asXmlObject(element.sentinel)) return null; + return readXmlNumber(element); } function resolveXmlNumber( - element: XmlNode | undefined, + element: XmlValue | undefined, references: Map, ): number | null { if (!element) return null; - if (element.attributes.ref) { - return references.get(element.attributes.ref)?.numberValue ?? null; + if (typeof element.ref === 'string') { + return references.get(element.ref)?.numberValue ?? null; } return parseDirectXmlNumber(element); } -function readDirectProcessNameFromXml(element: XmlNode | undefined): string | null { - const fmt = element?.attributes.fmt?.trim() ?? ''; +function readDirectProcessNameFromXml(element: XmlValue | undefined): string | null { + const fmt = typeof element?.fmt === 'string' ? element.fmt.trim() : ''; if (!fmt) return null; return fmt.replace(/\s+\(\d+\)$/, '').trim(); } function resolveProcessName( - element: XmlNode | undefined, + element: XmlValue | undefined, references: Map, ): string | null { if (!element) return null; - if (element.attributes.ref) { - return references.get(element.attributes.ref)?.processName ?? null; + if (typeof element.ref === 'string') { + return references.get(element.ref)?.processName ?? null; } return readDirectProcessNameFromXml(element); } From 343d93e0157becb29ea715906df122382b1f5ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 15:22:12 +0200 Subject: [PATCH 08/11] test: align iOS perf parser follow-ups --- src/daemon/handlers/__tests__/session.test.ts | 2 +- src/platforms/ios/perf.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index df271fe5..e5bab7c7 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -2206,7 +2206,7 @@ test('perf samples Apple cpu and memory metrics on physical iOS devices', async if (args[0] === 'xctrace' && args[1] === 'export') { const outputIndex = args.indexOf('--output'); exportCount += 1; - fs.writeFileSync( + await fs.promises.writeFile( args[outputIndex + 1]!, [ '', diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index b864e92b..53f486bd 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -650,6 +650,8 @@ function asXmlArray(value: unknown): XmlValue[] { function readRowElements(row: unknown): Array<{ name: string; value: XmlValue }> { const xmlRow = asXmlObject(row); if (!xmlRow) return []; + // fast-xml-parser currently preserves child key insertion order, which keeps row elements aligned + // with the exported schema column order that we index into below. return Object.entries(xmlRow) .map(([name, value]) => { const element = asXmlObject(value); From 2cf94bf3a4021664a4bae00078a66c219e8a01f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 16:28:04 +0200 Subject: [PATCH 09/11] fix: preserve duplicate iOS perf export columns --- src/platforms/ios/__tests__/perf.test.ts | 2 + src/platforms/ios/perf.ts | 182 +++++++++++++++-------- 2 files changed, 124 insertions(+), 60 deletions(-) diff --git a/src/platforms/ios/__tests__/perf.test.ts b/src/platforms/ios/__tests__/perf.test.ts index 1eacc601..a0c175fe 100644 --- a/src/platforms/ios/__tests__/perf.test.ts +++ b/src/platforms/ios/__tests__/perf.test.ts @@ -178,6 +178,7 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy '100000000', '8388608', '4001', + '', '', '', '124', @@ -213,6 +214,7 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy '', '', '', + '', '', '', '124', diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index 53f486bd..7989a4b1 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -55,6 +55,13 @@ type IosDevicePerfProcessSample = { residentMemoryBytes: number | null; }; +type XmlNode = { + name: string; + attributes: Record; + text: string | null; + children: XmlNode[]; +}; + type IosDevicePerfCapture = { capturedAtMs: number; xml: string; @@ -64,8 +71,6 @@ type XmlParserInstance = { parse(xml: string): unknown; }; -type XmlValue = Record; - let xmlParserPromise: Promise | null = null; export async function sampleApplePerfMetrics( @@ -156,17 +161,19 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] { async function parseIosDevicePerfTable(xml: string): Promise { const document = await parseXmlDocument(xml); - const node = asXmlObject(document['trace-query-result'])?.node; - const schema = asXmlObject(asXmlObject(node)?.schema); - if (!schema || schema.name !== 'activity-monitor-process-live') { + const schema = findFirstXmlNode( + document, + (node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live', + ); + if (!schema) { throw new AppError( 'COMMAND_FAILED', 'Failed to parse xctrace activity-monitor-process-live schema', ); } - const mnemonics = asXmlArray(schema.col).map( - (column) => asXmlObject(column)?.mnemonic?.toString().trim() ?? '', - ); + const mnemonics = schema.children + .filter((child) => child.name === 'col') + .map((column) => readFirstChildText(column, 'mnemonic') ?? ''); const pidIndex = mnemonics.indexOf('pid'); const processIndex = mnemonics.indexOf('process'); const cpuTimeIndex = mnemonics.indexOf('cpu-total'); @@ -178,7 +185,7 @@ async function parseIosDevicePerfTable(xml: string): Promise node.name === 'row'); const samples: IosDevicePerfProcessSample[] = []; const references = new Map< string, @@ -188,31 +195,34 @@ async function parseIosDevicePerfTable(xml: string): Promise(); for (const row of rows) { - const elements = readRowElements(row); + const elements = row.children; if (elements.length === 0) continue; for (const element of elements) { - const nestedPid = asXmlObject(element.value.pid); - if (typeof nestedPid?.id === 'string') { - const pidValue = readXmlNumber(nestedPid); - references.set(nestedPid.id, { + const nestedPid = findFirstXmlNode( + element.children, + (child) => child.name === 'pid' && typeof child.attributes.id === 'string', + ); + if (nestedPid?.attributes.id) { + const pidValue = Number(nestedPid.text); + references.set(nestedPid.attributes.id, { numberValue: Number.isFinite(pidValue) ? pidValue : null, }); } - if (typeof element.value.id !== 'string') continue; - references.set(element.value.id, { - numberValue: parseDirectXmlNumber(element.value), - processName: readDirectProcessNameFromXml(element.value), + if (!element.attributes.id) continue; + references.set(element.attributes.id, { + numberValue: parseDirectXmlNumber(element), + processName: readDirectProcessNameFromXml(element), }); } - const pid = resolveXmlNumber(elements[pidIndex]?.value, references); - const processName = resolveProcessName(elements[processIndex]?.value, references); + const pid = resolveXmlNumber(elements[pidIndex], references); + const processName = resolveProcessName(elements[processIndex], references); if (pid === null || !Number.isFinite(pid) || !processName) continue; samples.push({ pid, processName, - cpuTimeNs: resolveXmlNumber(elements[cpuTimeIndex]?.value, references), - residentMemoryBytes: resolveXmlNumber(elements[residentMemoryIndex]?.value, references), + cpuTimeNs: resolveXmlNumber(elements[cpuTimeIndex], references), + residentMemoryBytes: resolveXmlNumber(elements[residentMemoryIndex], references), }); } return samples; @@ -622,81 +632,133 @@ async function loadXmlParser(): Promise { new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', + preserveOrder: true, trimValues: true, parseTagValue: false, - isArray: (name) => name === 'col' || name === 'row', }), ); return await xmlParserPromise; } -async function parseXmlDocument(xml: string): Promise { +async function parseXmlDocument(xml: string): Promise { const parser = await loadXmlParser(); - return asXmlObject(parser.parse(xml)) ?? {}; + return normalizeXmlNodes(parser.parse(xml)); } -function asXmlObject(value: unknown): XmlValue | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - return value as XmlValue; +function normalizeXmlNodes(value: unknown): XmlNode[] { + if (!Array.isArray(value)) return []; + const nodes: XmlNode[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as Record; + for (const [name, childValue] of Object.entries(record)) { + if (name === ':@' || name === '#text') continue; + nodes.push({ + name, + attributes: normalizeXmlAttributes(record[':@']), + text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), + children: normalizeXmlNodes(childValue), + }); + } + } + return nodes; } -function asXmlArray(value: unknown): XmlValue[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => asXmlObject(entry)) - .filter((entry): entry is XmlValue => entry !== null); +function normalizeXmlAttributes(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const attributes: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + attributes[key] = entry; + } + } + return attributes; } -function readRowElements(row: unknown): Array<{ name: string; value: XmlValue }> { - const xmlRow = asXmlObject(row); - if (!xmlRow) return []; - // fast-xml-parser currently preserves child key insertion order, which keeps row elements aligned - // with the exported schema column order that we index into below. - return Object.entries(xmlRow) - .map(([name, value]) => { - const element = asXmlObject(value); - return element ? { name, value: element } : null; +function normalizeXmlText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeXmlNodeText(value: unknown): string | null { + if (!Array.isArray(value)) return null; + const text = value + .map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + return '#text' in entry + ? normalizeXmlText((entry as Record)['#text']) + : null; }) - .filter((entry): entry is { name: string; value: XmlValue } => entry !== null); + .filter((entry): entry is string => entry !== null) + .join('') + .trim(); + return text.length > 0 ? text : null; } -function readXmlNumber(element: XmlValue | undefined): number | null { - if (!element) return null; - const text = typeof element['#text'] === 'string' ? element['#text'].trim() : ''; - if (text.length === 0) return null; - const value = Number(text); - return Number.isFinite(value) ? value : null; +function findFirstXmlNode( + nodes: XmlNode[], + predicate: (node: XmlNode) => boolean, +): XmlNode | undefined { + for (const node of nodes) { + if (predicate(node)) { + return node; + } + const descendant = findFirstXmlNode(node.children, predicate); + if (descendant) { + return descendant; + } + } + return undefined; } -function parseDirectXmlNumber(element: XmlValue | undefined): number | null { - if (!element || asXmlObject(element.sentinel)) return null; - return readXmlNumber(element); +function findAllXmlNodes(nodes: XmlNode[], predicate: (node: XmlNode) => boolean): XmlNode[] { + const matches: XmlNode[] = []; + for (const node of nodes) { + if (predicate(node)) { + matches.push(node); + } + matches.push(...findAllXmlNodes(node.children, predicate)); + } + return matches; +} + +function readFirstChildText(node: XmlNode, childName: string): string | null { + const child = node.children.find((candidate) => candidate.name === childName); + return child?.text ?? null; +} + +function parseDirectXmlNumber(element: XmlNode | undefined): number | null { + if (!element || element.children.some((child) => child.name === 'sentinel')) return null; + if (!element.text) return null; + const value = Number(element.text); + return Number.isFinite(value) ? value : null; } function resolveXmlNumber( - element: XmlValue | undefined, + element: XmlNode | undefined, references: Map, ): number | null { if (!element) return null; - if (typeof element.ref === 'string') { - return references.get(element.ref)?.numberValue ?? null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.numberValue ?? null; } return parseDirectXmlNumber(element); } -function readDirectProcessNameFromXml(element: XmlValue | undefined): string | null { - const fmt = typeof element?.fmt === 'string' ? element.fmt.trim() : ''; +function readDirectProcessNameFromXml(element: XmlNode | undefined): string | null { + const fmt = element?.attributes.fmt?.trim() ?? ''; if (!fmt) return null; return fmt.replace(/\s+\(\d+\)$/, '').trim(); } function resolveProcessName( - element: XmlValue | undefined, + element: XmlNode | undefined, references: Map, ): string | null { if (!element) return null; - if (typeof element.ref === 'string') { - return references.get(element.ref)?.processName ?? null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.processName ?? null; } return readDirectProcessNameFromXml(element); } From a5e20f47f139039446cbae126dbd1afacf7346da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 16:37:39 +0200 Subject: [PATCH 10/11] refactor: reuse XML parser for plist fallback --- src/platforms/ios/__tests__/plist.test.ts | 44 +++++++++ src/platforms/ios/plist.ts | 112 ++++++++++++++++++---- 2 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 src/platforms/ios/__tests__/plist.test.ts diff --git a/src/platforms/ios/__tests__/plist.test.ts b/src/platforms/ios/__tests__/plist.test.ts new file mode 100644 index 00000000..2b30225b --- /dev/null +++ b/src/platforms/ios/__tests__/plist.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(actual.runCmd) }; +}); + +import { runCmd } from '../../../utils/exec.ts'; +import { readInfoPlistString } from '../plist.ts'; + +const mockRunCmd = vi.mocked(runCmd); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('readInfoPlistString falls back to XML parsing when plutil is unavailable', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-plist-')); + const infoPlistPath = path.join(tmpDir, 'Info.plist'); + await fs.writeFile( + infoPlistPath, + [ + '', + '', + 'CFBundleExecutableExampleExec', + 'CFBundleDisplayNameExample & App', + '', + ].join(''), + 'utf8', + ); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: 'missing plutil', exitCode: 1 }); + + try { + assert.equal(await readInfoPlistString(infoPlistPath, 'CFBundleExecutable'), 'ExampleExec'); + assert.equal(await readInfoPlistString(infoPlistPath, 'CFBundleDisplayName'), 'Example & App'); + assert.equal(await readInfoPlistString(infoPlistPath, 'MissingKey'), undefined); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/src/platforms/ios/plist.ts b/src/platforms/ios/plist.ts index ba978852..e2f80ee1 100644 --- a/src/platforms/ios/plist.ts +++ b/src/platforms/ios/plist.ts @@ -1,6 +1,18 @@ import { promises as fs } from 'node:fs'; import { runCmd } from '../../utils/exec.ts'; +type XmlParserInstance = { + parse(xml: string): unknown; +}; + +type XmlNode = { + name: string; + text: string | null; + children: XmlNode[]; +}; + +let xmlParserPromise: Promise | null = null; + export async function readInfoPlistString( infoPlistPath: string, key: string, @@ -21,33 +33,97 @@ export async function readInfoPlistString( try { const plist = await fs.readFile(infoPlistPath, 'utf8'); - return readXmlPlistString(plist, key); + return await readXmlPlistString(plist, key); } catch { return undefined; } } -function readXmlPlistString(plist: string, key: string): string | undefined { - const escapedKey = escapeRegExp(key); - const match = plist.match( - new RegExp(`\\s*${escapedKey}\\s*<\\/key>\\s*([\\s\\S]*?)<\\/string>`, 'i'), +async function readXmlPlistString(plist: string, key: string): Promise { + const dictEntries = await parseXmlPlistEntries(plist); + for (let index = 0; index < dictEntries.length - 1; index += 1) { + const entry = dictEntries[index]; + const nextEntry = dictEntries[index + 1]; + if (entry?.name === 'key' && entry.text === key && nextEntry?.name === 'string') { + return nextEntry.text; + } + } + return undefined; +} + +async function parseXmlPlistEntries(plist: string): Promise> { + const document = await parseXmlDocument(plist); + const plistNode = document.find((node) => node.name === 'plist'); + const dictNode = plistNode?.children.find((node) => node.name === 'dict'); + if (!dictNode) { + return []; + } + return dictNode.children + .map((child) => { + const text = readXmlNodeText(child); + return text ? { name: child.name, text } : null; + }) + .filter((entry): entry is { name: string; text: string } => entry !== null); +} + +async function parseXmlDocument(xml: string): Promise { + const parser = await loadXmlParser(); + return normalizeXmlNodes(parser.parse(xml)); +} + +async function loadXmlParser(): Promise { + xmlParserPromise ??= import('fast-xml-parser').then( + ({ XMLParser }) => + new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + preserveOrder: true, + trimValues: true, + parseTagValue: false, + }), ); - if (!match?.[1]) { - return undefined; + return await xmlParserPromise; +} + +function normalizeXmlNodes(value: unknown): XmlNode[] { + if (!Array.isArray(value)) return []; + const nodes: XmlNode[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as Record; + for (const [name, childValue] of Object.entries(record)) { + if (name === ':@' || name === '#text') continue; + nodes.push({ + name, + text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), + children: normalizeXmlNodes(childValue), + }); + } } - const value = decodeXmlEntities(match[1].trim()); - return value.length > 0 ? value : undefined; + return nodes; +} + +function normalizeXmlText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } -function decodeXmlEntities(value: string): string { - return value - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); +function normalizeXmlNodeText(value: unknown): string | null { + if (!Array.isArray(value)) return null; + const text = value + .map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + return '#text' in entry + ? normalizeXmlText((entry as Record)['#text']) + : null; + }) + .filter((entry): entry is string => entry !== null) + .join('') + .trim(); + return text.length > 0 ? text : null; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function readXmlNodeText(node: XmlNode | undefined): string | null { + return node?.text ?? null; } From 15a284a8583d367f2d1c02dd12d6231b692a5094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 16:41:01 +0200 Subject: [PATCH 11/11] refactor: share iOS XML parsing helpers --- src/platforms/ios/perf.ts | 84 +------------------------------- src/platforms/ios/plist.ts | 99 +++----------------------------------- src/platforms/ios/xml.ts | 75 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 174 deletions(-) create mode 100644 src/platforms/ios/xml.ts diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index 7989a4b1..41487751 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -15,6 +15,7 @@ import { } from './devicectl.ts'; import { readInfoPlistString } from './plist.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; +import { parseXmlDocument, type XmlNode } from './xml.ts'; export const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot'; export const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot'; @@ -55,24 +56,11 @@ type IosDevicePerfProcessSample = { residentMemoryBytes: number | null; }; -type XmlNode = { - name: string; - attributes: Record; - text: string | null; - children: XmlNode[]; -}; - type IosDevicePerfCapture = { capturedAtMs: number; xml: string; }; -type XmlParserInstance = { - parse(xml: string): unknown; -}; - -let xmlParserPromise: Promise | null = null; - export async function sampleApplePerfMetrics( device: DeviceInfo, appBundleId: string, @@ -626,76 +614,6 @@ function buildApplePerfSamples(args: { }; } -async function loadXmlParser(): Promise { - xmlParserPromise ??= import('fast-xml-parser').then( - ({ XMLParser }) => - new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '', - preserveOrder: true, - trimValues: true, - parseTagValue: false, - }), - ); - return await xmlParserPromise; -} - -async function parseXmlDocument(xml: string): Promise { - const parser = await loadXmlParser(); - return normalizeXmlNodes(parser.parse(xml)); -} - -function normalizeXmlNodes(value: unknown): XmlNode[] { - if (!Array.isArray(value)) return []; - const nodes: XmlNode[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; - const record = entry as Record; - for (const [name, childValue] of Object.entries(record)) { - if (name === ':@' || name === '#text') continue; - nodes.push({ - name, - attributes: normalizeXmlAttributes(record[':@']), - text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), - children: normalizeXmlNodes(childValue), - }); - } - } - return nodes; -} - -function normalizeXmlAttributes(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const attributes: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'string') { - attributes[key] = entry; - } - } - return attributes; -} - -function normalizeXmlText(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeXmlNodeText(value: unknown): string | null { - if (!Array.isArray(value)) return null; - const text = value - .map((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - return '#text' in entry - ? normalizeXmlText((entry as Record)['#text']) - : null; - }) - .filter((entry): entry is string => entry !== null) - .join('') - .trim(); - return text.length > 0 ? text : null; -} - function findFirstXmlNode( nodes: XmlNode[], predicate: (node: XmlNode) => boolean, diff --git a/src/platforms/ios/plist.ts b/src/platforms/ios/plist.ts index e2f80ee1..4a01c0e1 100644 --- a/src/platforms/ios/plist.ts +++ b/src/platforms/ios/plist.ts @@ -1,17 +1,6 @@ import { promises as fs } from 'node:fs'; import { runCmd } from '../../utils/exec.ts'; - -type XmlParserInstance = { - parse(xml: string): unknown; -}; - -type XmlNode = { - name: string; - text: string | null; - children: XmlNode[]; -}; - -let xmlParserPromise: Promise | null = null; +import { parseXmlDocument } from './xml.ts'; export async function readInfoPlistString( infoPlistPath: string, @@ -40,90 +29,18 @@ export async function readInfoPlistString( } async function readXmlPlistString(plist: string, key: string): Promise { - const dictEntries = await parseXmlPlistEntries(plist); - for (let index = 0; index < dictEntries.length - 1; index += 1) { - const entry = dictEntries[index]; - const nextEntry = dictEntries[index + 1]; - if (entry?.name === 'key' && entry.text === key && nextEntry?.name === 'string') { - return nextEntry.text; - } - } - return undefined; -} - -async function parseXmlPlistEntries(plist: string): Promise> { const document = await parseXmlDocument(plist); const plistNode = document.find((node) => node.name === 'plist'); const dictNode = plistNode?.children.find((node) => node.name === 'dict'); if (!dictNode) { - return []; + return undefined; } - return dictNode.children - .map((child) => { - const text = readXmlNodeText(child); - return text ? { name: child.name, text } : null; - }) - .filter((entry): entry is { name: string; text: string } => entry !== null); -} - -async function parseXmlDocument(xml: string): Promise { - const parser = await loadXmlParser(); - return normalizeXmlNodes(parser.parse(xml)); -} - -async function loadXmlParser(): Promise { - xmlParserPromise ??= import('fast-xml-parser').then( - ({ XMLParser }) => - new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '', - preserveOrder: true, - trimValues: true, - parseTagValue: false, - }), - ); - return await xmlParserPromise; -} - -function normalizeXmlNodes(value: unknown): XmlNode[] { - if (!Array.isArray(value)) return []; - const nodes: XmlNode[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; - const record = entry as Record; - for (const [name, childValue] of Object.entries(record)) { - if (name === ':@' || name === '#text') continue; - nodes.push({ - name, - text: normalizeXmlNodeText(childValue) ?? normalizeXmlText(record['#text']), - children: normalizeXmlNodes(childValue), - }); + for (let index = 0; index < dictNode.children.length - 1; index += 1) { + const entry = dictNode.children[index]; + const nextEntry = dictNode.children[index + 1]; + if (entry?.name === 'key' && entry.text === key && nextEntry?.name === 'string') { + return nextEntry.text ?? undefined; } } - return nodes; -} - -function normalizeXmlText(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeXmlNodeText(value: unknown): string | null { - if (!Array.isArray(value)) return null; - const text = value - .map((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - return '#text' in entry - ? normalizeXmlText((entry as Record)['#text']) - : null; - }) - .filter((entry): entry is string => entry !== null) - .join('') - .trim(); - return text.length > 0 ? text : null; -} - -function readXmlNodeText(node: XmlNode | undefined): string | null { - return node?.text ?? null; + return undefined; } diff --git a/src/platforms/ios/xml.ts b/src/platforms/ios/xml.ts new file mode 100644 index 00000000..3d07a4ae --- /dev/null +++ b/src/platforms/ios/xml.ts @@ -0,0 +1,75 @@ +export type XmlNode = { + name: string; + attributes: Record; + text: string | null; + children: XmlNode[]; +}; + +let xmlParserPromise: Promise | null = null; + +export async function parseXmlDocument(xml: string): Promise { + const parser = await loadXmlParser(); + return normalizeXmlNodes(parser.parse(xml)); +} + +async function loadXmlParser(): Promise { + xmlParserPromise ??= import('fast-xml-parser').then( + ({ XMLParser }) => + new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + preserveOrder: true, + trimValues: true, + parseTagValue: false, + }), + ); + return await xmlParserPromise; +} + +function normalizeXmlNodes(value: unknown): XmlNode[] { + if (!Array.isArray(value)) return []; + const nodes: XmlNode[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as Record; + for (const [name, childValue] of Object.entries(record)) { + if (name === ':@' || name === '#text') continue; + nodes.push({ + name, + attributes: normalizeXmlAttributes(record[':@']), + text: readXmlText(childValue) ?? readXmlText(record['#text']), + children: normalizeXmlNodes(childValue), + }); + } + } + return nodes; +} + +function normalizeXmlAttributes(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const attributes: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + attributes[key] = entry; + } + } + return attributes; +} + +function readXmlText(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (!Array.isArray(value)) return null; + const text = value + .map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const textValue = (entry as Record)['#text']; + return typeof textValue === 'string' ? textValue.trim() : null; + }) + .filter((entry): entry is string => entry !== null && entry.length > 0) + .join('') + .trim(); + return text.length > 0 ? text : null; +}