Skip to content

Commit e387e98

Browse files
authored
test(platform-runtime): cover Apple perf capture and runner transport branches (#3432)
Drive the five 0%-coverage CRAP findings from issue #3416 through the perf-capture host and the Apple runner screen-recording transport entry points: xctrace start/template-derivation/no-identity reclaim, Apple descriptor cleanup and reattach across every managed-process outcome, each malformed apple descriptor body at the decode boundary, and the live-session / stale-lease branches of local runner inspection.
1 parent d0f5d49 commit e387e98

2 files changed

Lines changed: 366 additions & 1 deletion

File tree

‎src/platform-runtime-perf-capture-host.test.ts‎

Lines changed: 305 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import type { EncodedDurableDescriptor } from '@agent-device/contracts/durable-r
55
import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device';
66
import {
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

1216
const 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+
2036
vi.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

2240
const 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+
3157
beforeEach(() => {
3258
vi.clearAllMocks();
3359
androidNative.cleanupAndroidNativePerfSession.mockResolvedValue(undefined);
60+
appleXctrace.cleanupAppleXctracePerfCapture.mockResolvedValue({
61+
code: 0,
62+
stdout: '',
63+
stderr: '',
64+
});
3465
});
3566

3667
test('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+
156456
function 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

Comments
 (0)