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/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/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 35e4d340..e5bab7c7 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; + await fs.promises.writeFile( + 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..a0c175fe 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, 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', () => { @@ -156,9 +157,145 @@ 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', + ) + .replace( + '8388608', + '8388608', + ) + .replace('4001', '4001') + .replace( + '4001', + '4001', + ) + .replace( + '124', + [ + '', + '', + '123', + '', + '', + '', + '', + '', + '', + '', + '124', + ].join(''), + ); + 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']); + 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/__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/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..41487751 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -1,29 +1,44 @@ +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'; +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'; -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 +49,24 @@ type AppleProcessSample = { command: string; }; +type IosDevicePerfProcessSample = { + pid: number; + processName: string; + cpuTimeNs: number | null; + residentMemoryBytes: number | 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 +82,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 +147,75 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] { return rows; } +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) { + 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 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 = findAllXmlNodes(document, (node) => node.name === 'row'); + const samples: IosDevicePerfProcessSample[] = []; + const references = new Map< + string, + { + numberValue?: number | null; + processName?: string | null; + } + >(); + for (const row of rows) { + const elements = row.children; + 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, { + numberValue: Number.isFinite(pidValue) ? pidValue : null, + }); + } + if (!element.attributes.id) continue; + references.set(element.attributes.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 +245,243 @@ async function resolveAppleExecutable( }; } +async function sampleIosDevicePerfMetrics( + device: DeviceInfo, + appBundleId: string, +): Promise<{ cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample }> { + const processes = await resolveIosDevicePerfTarget(device, appBundleId); + const firstCapture = await captureIosDevicePerfTable(device, appBundleId); + const secondCapture = await captureIosDevicePerfTable(device, appBundleId); + const firstSnapshot = summarizeIosDevicePerfSnapshot( + await parseIosDevicePerfTable(firstCapture.xml), + processes, + appBundleId, + device, + ); + const secondSnapshot = summarizeIosDevicePerfSnapshot( + await 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: new Date(secondCapture.capturedAtMs).toISOString(), + 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 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 = latestSamples + .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(latestSamples.map((sample) => sample.processName)), + }; +} + async function resolveMacOsBundlePath(appBundleId: string): Promise { const query = `kMDItemCFBundleIdentifier == "${appBundleId.replaceAll('"', '\\"')}"`; const result = await runCmd('mdfind', [query], { @@ -268,6 +590,116 @@ 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 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 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: XmlNode | undefined, + references: Map, +): number | null { + if (!element) return null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.numberValue ?? null; + } + return parseDirectXmlNumber(element); +} + +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: XmlNode | undefined, + references: Map, +): string | null { + if (!element) return null; + if (element.attributes.ref) { + return references.get(element.attributes.ref)?.processName ?? null; + } + return readDirectProcessNameFromXml(element); +} + +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; } + +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); +} diff --git a/src/platforms/ios/plist.ts b/src/platforms/ios/plist.ts index ba978852..4a01c0e1 100644 --- a/src/platforms/ios/plist.ts +++ b/src/platforms/ios/plist.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'node:fs'; import { runCmd } from '../../utils/exec.ts'; +import { parseXmlDocument } from './xml.ts'; export async function readInfoPlistString( infoPlistPath: string, @@ -21,33 +22,25 @@ 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'), - ); - if (!match?.[1]) { +async function readXmlPlistString(plist: string, key: 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 undefined; } - const value = decodeXmlEntities(match[1].trim()); - return value.length > 0 ? value : undefined; -} - -function decodeXmlEntities(value: string): string { - return value - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + 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 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; +} 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`).