@@ -5,9 +5,13 @@ import type { EncodedDurableDescriptor } from '@agent-device/contracts/durable-r
55import { deviceIdentity , type DeviceInfo } from '@agent-device/kernel/device' ;
66import {
77 cleanupAndroidPerfCaptureDescriptor ,
8+ cleanupApplePerfCaptureDescriptor ,
89 inspectAndroidPerfCaptureDescriptor ,
10+ inspectApplePerfCaptureDescriptor ,
911 startAndroidPerfCapture ,
12+ startApplePerfCapture ,
1013} from './platform-runtime-perf-capture-host.ts' ;
14+ import { IOS_SIMULATOR } from './__tests__/test-utils/device-fixtures.ts' ;
1115
1216const androidNative = vi . hoisted ( ( ) => ( {
1317 startAndroidSimpleperfProfile : vi . fn ( ) ,
@@ -17,7 +21,21 @@ const androidNative = vi.hoisted(() => ({
1721 cleanupAndroidNativePerfSession : vi . fn ( ) ,
1822} ) ) ;
1923
24+ const appleXctrace = vi . hoisted ( ( ) => ( {
25+ startAppleXctracePerfCapture : vi . fn ( ) ,
26+ stopAppleXctracePerfCapture : vi . fn ( ) ,
27+ cleanupAppleXctracePerfCapture : vi . fn ( ) ,
28+ } ) ) ;
29+
30+ const processHost = vi . hoisted ( ( ) => ( {
31+ resolveManagedProcessIdentity : vi . fn ( ) ,
32+ inspectManagedProcess : vi . fn ( ) ,
33+ terminateManagedProcessSet : vi . fn ( ) ,
34+ } ) ) ;
35+
2036vi . mock ( '@agent-device/platform-android/mechanics' , ( ) => androidNative ) ;
37+ vi . mock ( '@agent-device/platform-apple/perf' , ( ) => appleXctrace ) ;
38+ vi . mock ( './platform-runtime-screen-recording-process-host.ts' , ( ) => processHost ) ;
2139
2240const device : DeviceInfo = {
2341 platform : 'android' ,
@@ -28,9 +46,22 @@ const device: DeviceInfo = {
2846 booted : true ,
2947} ;
3048
49+ const appleDevice = IOS_SIMULATOR ;
50+
51+ const appleMarker = Object . freeze ( {
52+ pid : 4210 ,
53+ startTime : 'Fri Oct 02 09:00:00 2026' ,
54+ command : 'xcrun xctrace record --template Time Profiler' ,
55+ } ) ;
56+
3157beforeEach ( ( ) => {
3258 vi . clearAllMocks ( ) ;
3359 androidNative . cleanupAndroidNativePerfSession . mockResolvedValue ( undefined ) ;
60+ appleXctrace . cleanupAppleXctracePerfCapture . mockResolvedValue ( {
61+ code : 0 ,
62+ stdout : '' ,
63+ stderr : '' ,
64+ } ) ;
3465} ) ;
3566
3667test ( 'accepts only a fully decoded Android native capture descriptor' , async ( ) => {
@@ -153,6 +184,275 @@ test('starts and finishes an Android Perfetto trace', async () => {
153184 ) ;
154185} ) ;
155186
187+ test ( 'starts, retargets, stops, and reclaims an Apple xctrace capture through one live handle' , async ( ) => {
188+ const capture = appleCapture ( { mode : 'trace' , template : 'Animation Hitches' } ) ;
189+ appleXctrace . startAppleXctracePerfCapture . mockResolvedValue ( capture ) ;
190+ appleXctrace . stopAppleXctracePerfCapture . mockImplementation (
191+ async ( current : typeof capture , outPath : string ) => ( {
192+ ...stripAppleChild ( current ) ,
193+ outPath,
194+ endedAt : '2026-10-02T09:01:00.000Z' ,
195+ } ) ,
196+ ) ;
197+ processHost . resolveManagedProcessIdentity . mockResolvedValue ( appleMarker ) ;
198+ processHost . inspectManagedProcess . mockReturnValue ( 'owned-alive' ) ;
199+ processHost . terminateManagedProcessSet . mockResolvedValue ( 'terminated' ) ;
200+
201+ const started = await startApplePerfCapture (
202+ appleDevice ,
203+ localRuntimeOwner ( 'apple' ) ,
204+ captureInput ( { kind : 'xctrace' , mode : 'trace' , template : 'Animation Hitches' } ) ,
205+ ) ;
206+ expect ( appleXctrace . startAppleXctracePerfCapture ) . toHaveBeenCalledWith ( {
207+ device : appleDevice ,
208+ appBundleId : 'com.example.app' ,
209+ mode : 'trace' ,
210+ template : 'Animation Hitches' ,
211+ outPath : '/tmp/capture' ,
212+ } ) ;
213+ expect ( started . response ) . toMatchObject ( { perf : 'started' , kind : 'xctrace' , mode : 'trace' } ) ;
214+
215+ const handle = started . pendingHandle . transfer ( ) ;
216+ handle . setOutputPath ( '/tmp/retargeted.trace' ) ;
217+ await expect ( handle . finish ( ) ) . resolves . toMatchObject ( {
218+ status : 'completed' ,
219+ result : { perf : 'stopped' , outPath : '/tmp/retargeted.trace' , endedAt : expect . any ( String ) } ,
220+ } ) ;
221+
222+ await expect ( inspectApplePerfCaptureDescriptor ( started . envelope ) ) . resolves . toEqual ( {
223+ status : 'unreattachable' ,
224+ reason : 'transport-not-reattachable' ,
225+ } ) ;
226+ await expect ( cleanupApplePerfCaptureDescriptor ( started . envelope ) ) . resolves . toEqual ( {
227+ status : 'cleaned' ,
228+ } ) ;
229+ expect ( processHost . terminateManagedProcessSet ) . toHaveBeenCalledWith ( [ appleMarker ] ) ;
230+ } ) ;
231+
232+ test ( 'derives the xctrace template from the capture mode' , async ( ) => {
233+ appleXctrace . startAppleXctracePerfCapture . mockResolvedValue (
234+ appleCapture ( { mode : 'cpu-profile' , template : 'Time Profiler' } ) ,
235+ ) ;
236+ processHost . resolveManagedProcessIdentity . mockResolvedValue ( appleMarker ) ;
237+
238+ await startApplePerfCapture (
239+ appleDevice ,
240+ localRuntimeOwner ( 'apple' ) ,
241+ captureInput ( { kind : 'xctrace' , mode : 'cpu-profile' } ) ,
242+ ) ;
243+ expect ( appleXctrace . startAppleXctracePerfCapture ) . toHaveBeenLastCalledWith (
244+ expect . objectContaining ( { template : 'Time Profiler' } ) ,
245+ ) ;
246+
247+ await startApplePerfCapture (
248+ appleDevice ,
249+ localRuntimeOwner ( 'apple' ) ,
250+ captureInput ( { kind : 'xctrace' , mode : 'trace' } ) ,
251+ ) ;
252+ expect ( appleXctrace . startAppleXctracePerfCapture ) . toHaveBeenLastCalledWith (
253+ expect . objectContaining ( { template : 'Animation Hitches' } ) ,
254+ ) ;
255+ } ) ;
256+
257+ test ( 'rejects a non-xctrace native kind for an Apple capture' , async ( ) => {
258+ await expect (
259+ startApplePerfCapture (
260+ appleDevice ,
261+ localRuntimeOwner ( 'apple' ) ,
262+ captureInput ( { kind : 'perfetto' , mode : 'trace' } ) ,
263+ ) ,
264+ ) . rejects . toThrow ( 'Apple native perf requires --kind xctrace, not perfetto' ) ;
265+ expect ( appleXctrace . startAppleXctracePerfCapture ) . not . toHaveBeenCalled ( ) ;
266+ } ) ;
267+
268+ test ( 'reclaims the profiler when Apple exposes no exact process identity' , async ( ) => {
269+ const capture = appleCapture ( { mode : 'cpu-profile' , template : 'Time Profiler' } ) ;
270+ appleXctrace . startAppleXctracePerfCapture . mockResolvedValue ( {
271+ ...capture ,
272+ child : { pid : undefined } ,
273+ } ) ;
274+ processHost . resolveManagedProcessIdentity . mockResolvedValue ( undefined ) ;
275+
276+ await expect (
277+ startApplePerfCapture (
278+ appleDevice ,
279+ localRuntimeOwner ( 'apple' ) ,
280+ captureInput ( { kind : 'xctrace' , mode : 'cpu-profile' } ) ,
281+ ) ,
282+ ) . rejects . toThrow ( 'Apple xctrace exposed no exact process identity' ) ;
283+ expect ( processHost . resolveManagedProcessIdentity ) . toHaveBeenCalledWith ( undefined ) ;
284+ expect ( appleXctrace . cleanupAppleXctracePerfCapture ) . toHaveBeenCalledTimes ( 1 ) ;
285+ } ) ;
286+
287+ test ( 'maps each managed-process outcome through Apple descriptor cleanup' , async ( ) => {
288+ const envelope = appleEnvelope ( ) ;
289+ const cases : Array <
290+ Readonly < {
291+ ownership : 'ownership-lost' | 'missing' | 'owned-alive' ;
292+ terminated ?: 'terminated' | 'already-missing' | 'ownership-lost' ;
293+ expected : { status : string ; reason ?: string } ;
294+ } >
295+ > = [
296+ {
297+ ownership : 'ownership-lost' ,
298+ expected : { status : 'cleanup-pending' , reason : 'ownership-fence-lost' } ,
299+ } ,
300+ { ownership : 'missing' , expected : { status : 'already-missing' } } ,
301+ {
302+ ownership : 'owned-alive' ,
303+ terminated : 'terminated' ,
304+ expected : { status : 'cleaned' } ,
305+ } ,
306+ {
307+ ownership : 'owned-alive' ,
308+ terminated : 'already-missing' ,
309+ expected : { status : 'already-missing' } ,
310+ } ,
311+ {
312+ ownership : 'owned-alive' ,
313+ terminated : 'ownership-lost' ,
314+ expected : { status : 'cleanup-pending' , reason : 'ownership-fence-lost' } ,
315+ } ,
316+ ] ;
317+ for ( const testCase of cases ) {
318+ processHost . inspectManagedProcess . mockReturnValueOnce ( testCase . ownership ) ;
319+ if ( testCase . terminated !== undefined ) {
320+ processHost . terminateManagedProcessSet . mockResolvedValueOnce ( testCase . terminated ) ;
321+ }
322+ await expect ( cleanupApplePerfCaptureDescriptor ( envelope ) ) . resolves . toEqual ( testCase . expected ) ;
323+ }
324+ expect ( processHost . terminateManagedProcessSet ) . toHaveBeenCalledTimes ( 3 ) ;
325+ } ) ;
326+
327+ test ( 'maps each managed-process outcome through Apple descriptor reattach' , async ( ) => {
328+ const envelope = appleEnvelope ( ) ;
329+ processHost . inspectManagedProcess
330+ . mockReturnValueOnce ( 'missing' )
331+ . mockReturnValueOnce ( 'owned-alive' )
332+ . mockReturnValueOnce ( 'ownership-lost' ) ;
333+ await expect ( inspectApplePerfCaptureDescriptor ( envelope ) ) . resolves . toEqual ( {
334+ status : 'missing' ,
335+ } ) ;
336+ await expect ( inspectApplePerfCaptureDescriptor ( envelope ) ) . resolves . toEqual ( {
337+ status : 'unreattachable' ,
338+ reason : 'transport-not-reattachable' ,
339+ } ) ;
340+ await expect ( inspectApplePerfCaptureDescriptor ( envelope ) ) . resolves . toEqual ( {
341+ status : 'unreattachable' ,
342+ reason : 'ownership-fence-lost' ,
343+ } ) ;
344+ } ) ;
345+
346+ test ( 'refuses Apple descriptor recovery for an undecodable or foreign body' , async ( ) => {
347+ const malformed = [
348+ { ...appleBody ( ) , capture : 'not-a-capture' } ,
349+ { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , kind : 'perfetto' } } ,
350+ { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , mode : 'time' } } ,
351+ {
352+ ...appleBody ( ) ,
353+ capture : ( ( ) => {
354+ const { template : _template , ...capture } = appleBody ( ) . capture ;
355+ return capture ;
356+ } ) ( ) ,
357+ } ,
358+ { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , platform : 'apple' } } ,
359+ { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , targetProcesses : [ 42 ] } } ,
360+ { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , targetPids : [ '12' ] } } ,
361+ { ...appleBody ( ) , marker : { pid : 1 } } ,
362+ ] ;
363+ for ( const body of malformed ) {
364+ await expect ( inspectApplePerfCaptureDescriptor ( envelope ( body ) ) ) . resolves . toMatchObject ( {
365+ status : 'unreattachable' ,
366+ reason : 'descriptor-invalid' ,
367+ } ) ;
368+ await expect ( cleanupApplePerfCaptureDescriptor ( envelope ( body ) ) ) . resolves . toEqual ( {
369+ status : 'cleanup-pending' ,
370+ reason : 'manual-recovery-required' ,
371+ } ) ;
372+ }
373+
374+ const androidBody = {
375+ family : 'android' ,
376+ capture : {
377+ type : 'trace' ,
378+ kind : 'perfetto' ,
379+ packageName : 'com.example.app' ,
380+ appPid : '101' ,
381+ profilerPid : '202' ,
382+ remotePath : '/data/misc/perfetto-traces/app.trace' ,
383+ outPath : '/tmp/app.trace' ,
384+ startedAt : 1 ,
385+ state : 'running' ,
386+ } ,
387+ } ;
388+ await expect ( inspectApplePerfCaptureDescriptor ( envelope ( androidBody ) ) ) . resolves . toEqual ( {
389+ status : 'unreattachable' ,
390+ reason : 'descriptor-invalid' ,
391+ } ) ;
392+ await expect ( cleanupApplePerfCaptureDescriptor ( envelope ( androidBody ) ) ) . resolves . toEqual ( {
393+ status : 'cleanup-pending' ,
394+ reason : 'manual-recovery-required' ,
395+ } ) ;
396+
397+ processHost . inspectManagedProcess . mockReturnValue ( 'missing' ) ;
398+ await expect (
399+ inspectApplePerfCaptureDescriptor (
400+ envelope ( { ...appleBody ( ) , capture : { ...appleBody ( ) . capture , mode : 'trace' } } ) ,
401+ ) ,
402+ ) . resolves . toEqual ( { status : 'missing' } ) ;
403+ } ) ;
404+
405+ function appleCapture ( input : {
406+ mode : 'cpu-profile' | 'trace' ;
407+ template : string ;
408+ } ) : import ( '@agent-device/platform-apple/perf' ) . AppleXctracePerfCapture {
409+ return {
410+ kind : 'xctrace' ,
411+ ...input ,
412+ outPath : '/tmp/capture' ,
413+ appBundleId : 'com.example.app' ,
414+ deviceId : appleDevice . id ,
415+ platform : 'ios' ,
416+ targetPids : [ 42 ] ,
417+ targetProcesses : [ 'com.example.app' ] ,
418+ startedAt : '2026-10-02T09:00:00.000Z' ,
419+ child : { pid : appleMarker . pid } as unknown as AppleChild ,
420+ wait : Promise . resolve ( { code : 0 , stdout : '' , stderr : '' } ) ,
421+ } as unknown as import ( '@agent-device/platform-apple/perf' ) . AppleXctracePerfCapture ;
422+ }
423+
424+ type AppleChild = import ( '@agent-device/platform-apple/perf' ) . AppleXctracePerfCapture [ 'child' ] ;
425+
426+ function stripAppleChild (
427+ capture : import ( '@agent-device/platform-apple/perf' ) . AppleXctracePerfCapture ,
428+ ) {
429+ const { child : _child , wait : _wait , ...rest } = capture ;
430+ return rest ;
431+ }
432+
433+ function appleBody ( ) {
434+ return {
435+ family : 'apple' ,
436+ marker : { ...appleMarker } ,
437+ capture : {
438+ kind : 'xctrace' ,
439+ mode : 'cpu-profile' ,
440+ template : 'Time Profiler' ,
441+ outPath : '/tmp/capture.trace' ,
442+ appBundleId : 'com.example.app' ,
443+ deviceId : appleDevice . id ,
444+ platform : 'ios' ,
445+ targetPids : [ 42 ] ,
446+ targetProcesses : [ 'com.example.app' ] ,
447+ startedAt : '2026-10-02T09:00:00.000Z' ,
448+ } ,
449+ } ;
450+ }
451+
452+ function appleEnvelope ( ) {
453+ return envelope ( appleBody ( ) ) ;
454+ }
455+
156456function androidCapture ( input : { kind : 'simpleperf' | 'perfetto' ; type : 'cpu-profile' | 'trace' } ) {
157457 return {
158458 action : 'start' as const ,
@@ -169,7 +469,11 @@ function androidCapture(input: { kind: 'simpleperf' | 'perfetto'; type: 'cpu-pro
169469 } ;
170470}
171471
172- function captureInput ( input : { kind : 'simpleperf' | 'perfetto' ; mode : 'cpu-profile' | 'trace' } ) {
472+ function captureInput ( input : {
473+ kind : 'simpleperf' | 'perfetto' | 'xctrace' ;
474+ mode : 'cpu-profile' | 'trace' ;
475+ template ?: string ;
476+ } ) {
173477 return {
174478 sessionId : 'one' ,
175479 appId : 'com.example.app' ,
0 commit comments