mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
test(platform): extract Apple perf and Android runtime fixtures (#2253)
* test(platform): extract shared fixtures from the Apple perf and Android runtime suites Move the duplicated host-command routing, app-bundle writers, and ps handlers behind packages/platform-apple/src/core/__tests__/perf.fixtures.ts, and the Android runtime host, device, and ordinary-bind builders behind packages/platform-android/src/runtime.fixtures.ts. Every test keeps its title and its own assertions; test and assertion counts are unchanged. * chore(gates): exclude .fixtures.ts modules from changed-line coverage runtime.fixtures.ts (packages/platform-android/src) sits outside any __tests__ dir, so vitest's coverage.include picked it up as production source and the changed-line gate scored its 20 unreachable construction lines directly, failing at 39.39% against the 70% threshold. Add '**/*.fixtures.ts' to coverage.exclude (repo-wide convention: 40+ fixture modules, all test support, never production). The file disappears from lcov and scripts/coverage-changed/model.ts's existing excluded-path fallback reports it non-gating instead, with no duplicate classifier needed there. Planted red (targeted run against the real diff/model, not committed): BEFORE (file present in lcov, all added lines uncovered): totalLines=110 coveredLines=0 pct=0 passed=false. AFTER (file absent from lcov post-fix): totalLines=0 excludedTotal=99 excludedReason=excluded-path passed=true. Confirmed against a real `vitest run --coverage` + `check:coverage-changed` pass: runtime.fixtures.ts absent from coverage/lcov.info, gate PASS, 99 lines reported under excluded/excluded-path.
This commit is contained in:
committed by
GitHub
parent
9941330dcc
commit
3726f027ce
@@ -0,0 +1,110 @@
|
||||
import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support';
|
||||
import type { DeviceBinding } from '@agent-device/contracts/platform-runtime';
|
||||
import type {
|
||||
PlatformRuntimeHost,
|
||||
PlatformRuntimeOperations,
|
||||
PlatformRuntimeOwner,
|
||||
} from '@agent-device/contracts/platform-runtime-operations';
|
||||
import type { DeviceInfo } from '@agent-device/kernel/device';
|
||||
|
||||
export const ANDROID_EMULATOR: DeviceInfo = {
|
||||
platform: 'android',
|
||||
id: 'emulator-5554',
|
||||
name: 'Android',
|
||||
kind: 'emulator',
|
||||
target: 'mobile',
|
||||
booted: true,
|
||||
};
|
||||
|
||||
export const UNKNOWN_KIND_DEVICE = {
|
||||
...ANDROID_EMULATOR,
|
||||
kind: 'unknown',
|
||||
} as unknown as DeviceInfo;
|
||||
|
||||
const audioProbeHost: PlatformRuntimeHost['audioProbe'] = {
|
||||
hostCapture: {
|
||||
info: {
|
||||
source: 'system-audio',
|
||||
backend: 'fixture',
|
||||
sourceCount: 0,
|
||||
notes: () => [],
|
||||
},
|
||||
start: async () => {
|
||||
throw new Error('Audio probe is outside this runtime fixture.');
|
||||
},
|
||||
inspectProcess: async () => 'missing',
|
||||
terminateProcess: async () => 'already-missing',
|
||||
},
|
||||
web: { resolve: async () => undefined },
|
||||
ownedProcesses: { replace: () => {}, clear: () => {} },
|
||||
};
|
||||
|
||||
export const emptyAppInventory = {
|
||||
apple: { listApps: async () => [] },
|
||||
android: { listApps: async () => [] },
|
||||
harmonyos: { listApps: async () => [] },
|
||||
};
|
||||
|
||||
const emptyAppState = {
|
||||
android: { run: async () => ({ stdout: '' }) },
|
||||
harmonyos: { run: async () => ({ stdout: '' }) },
|
||||
};
|
||||
|
||||
function localAndroidScreenRecording() {
|
||||
return {
|
||||
mode: 'local' as const,
|
||||
start: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
signal: async () => true,
|
||||
isRunning: async () => false,
|
||||
exists: async () => false,
|
||||
pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
remove: async () => true,
|
||||
readManifest: async () => undefined,
|
||||
writeManifest: async () => {},
|
||||
removeManifest: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/** The smallest host the Android runtime binds against; `overrides` replace whole facets. */
|
||||
export function androidRuntimeHost(overrides: Record<string, unknown> = {}): PlatformRuntimeHost {
|
||||
return {
|
||||
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
appInventory: emptyAppInventory,
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
screenRecording: { android: { resolve: async () => localAndroidScreenRecording() } },
|
||||
...overrides,
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
}
|
||||
|
||||
/** A host with app state and device readiness, so navigation and clipboard cells can bind. */
|
||||
export function androidNavigationHost(
|
||||
probeClipboardShellSupport: () => Promise<AndroidClipboardShellSupport> = async () => 'supported',
|
||||
): PlatformRuntimeHost {
|
||||
return androidRuntimeHost({
|
||||
androidTools: {
|
||||
probeClipboardShellSupport,
|
||||
runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
},
|
||||
appState: emptyAppState,
|
||||
deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function bindOrdinary(
|
||||
runtime: PlatformRuntimeOwner,
|
||||
device: DeviceInfo,
|
||||
): Promise<DeviceBinding<PlatformRuntimeOperations>> {
|
||||
return await runtime.bind({
|
||||
device,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,62 +7,38 @@ import type {
|
||||
} from '@agent-device/contracts/platform-runtime-operations';
|
||||
import type { DeviceInfo } from '@agent-device/kernel/device';
|
||||
import { createAndroidPlatformRuntime } from './runtime.ts';
|
||||
import {
|
||||
ANDROID_EMULATOR,
|
||||
UNKNOWN_KIND_DEVICE,
|
||||
androidNavigationHost,
|
||||
androidRuntimeHost,
|
||||
bindOrdinary,
|
||||
emptyAppInventory,
|
||||
} from './runtime.fixtures.ts';
|
||||
|
||||
const device: DeviceInfo = {
|
||||
platform: 'android',
|
||||
id: 'emulator-5554',
|
||||
name: 'Android',
|
||||
kind: 'emulator',
|
||||
target: 'mobile',
|
||||
booted: true,
|
||||
};
|
||||
const appStateUnavailable = {
|
||||
available: false,
|
||||
reason: 'unsupported-device-kind',
|
||||
hint: 'Android appstate is supported only for Android emulators and devices.',
|
||||
} as const;
|
||||
const unknownKindDevice = { ...device, kind: 'unknown' } as unknown as DeviceInfo;
|
||||
const audioProbeHost: PlatformRuntimeHost['audioProbe'] = {
|
||||
hostCapture: {
|
||||
info: {
|
||||
source: 'system-audio',
|
||||
backend: 'fixture',
|
||||
sourceCount: 0,
|
||||
notes: () => [],
|
||||
},
|
||||
start: async () => {
|
||||
throw new Error('Audio probe is outside this runtime fixture.');
|
||||
},
|
||||
inspectProcess: async () => 'missing',
|
||||
terminateProcess: async () => 'already-missing',
|
||||
},
|
||||
web: { resolve: async () => undefined },
|
||||
ownedProcesses: { replace: () => {}, clear: () => {} },
|
||||
};
|
||||
|
||||
test.each([
|
||||
['emulator', device],
|
||||
['device', { ...device, kind: 'device' as const }],
|
||||
['unknown', unknownKindDevice],
|
||||
['emulator', ANDROID_EMULATOR],
|
||||
['device', { ...ANDROID_EMULATOR, kind: 'device' as const }],
|
||||
['unknown', UNKNOWN_KIND_DEVICE],
|
||||
])('classifies the Android %s runtime denominator', async (_name, runtimeDevice) => {
|
||||
const listApps = vi.fn(async () => [{ id: 'com.example.app', name: 'Example' }]);
|
||||
const appState = vi.fn(async () => ({
|
||||
stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}',
|
||||
}));
|
||||
const host = {
|
||||
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
|
||||
const host = androidRuntimeHost({
|
||||
commands: {
|
||||
which: async () => 'tool',
|
||||
run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }),
|
||||
},
|
||||
toolchains: { prepare: async () => {} },
|
||||
clock: { now: () => 1, sleep: async () => {} },
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
appInventory: {
|
||||
apple: { listApps: async () => [] },
|
||||
android: { listApps },
|
||||
harmonyos: { listApps: async () => [] },
|
||||
},
|
||||
appInventory: { ...emptyAppInventory, android: { listApps } },
|
||||
appState: {
|
||||
android: { run: appState },
|
||||
harmonyos: { run: async () => ({ stdout: '' }) },
|
||||
@@ -76,36 +52,8 @@ test.each([
|
||||
},
|
||||
androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} },
|
||||
},
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
screenRecording: {
|
||||
android: {
|
||||
resolve: async () => ({
|
||||
mode: 'local' as const,
|
||||
start: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
signal: async () => true,
|
||||
isRunning: async () => false,
|
||||
exists: async () => false,
|
||||
pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
remove: async () => true,
|
||||
readManifest: async () => undefined,
|
||||
writeManifest: async () => {},
|
||||
removeManifest: async () => {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
const binding = await createAndroidPlatformRuntime(host).bind({
|
||||
device: runtimeDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice);
|
||||
const { facts } = binding;
|
||||
expect(facts.device.providerMode).toBe('local');
|
||||
expect(facts.operations.networkDump).toEqual({ available: true });
|
||||
@@ -174,108 +122,31 @@ test.each([
|
||||
});
|
||||
|
||||
test('rejects the non-discovered Android simulator cell for appstate', async () => {
|
||||
const runtimeDevice = { ...device, kind: 'simulator' as const };
|
||||
const host = {
|
||||
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
const runtimeDevice = { ...ANDROID_EMULATOR, kind: 'simulator' as const };
|
||||
const host = androidRuntimeHost({
|
||||
appState: {
|
||||
android: { run: async () => ({ stdout: '' }) },
|
||||
harmonyos: { run: async () => ({ stdout: '' }) },
|
||||
},
|
||||
deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } },
|
||||
screenRecording: {
|
||||
android: {
|
||||
resolve: async () => ({
|
||||
mode: 'local' as const,
|
||||
start: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
signal: async () => true,
|
||||
isRunning: async () => false,
|
||||
exists: async () => false,
|
||||
pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
remove: async () => true,
|
||||
readManifest: async () => undefined,
|
||||
writeManifest: async () => {},
|
||||
removeManifest: async () => {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
const binding = await createAndroidPlatformRuntime(host).bind({
|
||||
device: runtimeDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice);
|
||||
|
||||
expect(binding.facts.operations.appState).toEqual(appStateUnavailable);
|
||||
expect(binding.operations.appState).toBeUndefined();
|
||||
});
|
||||
|
||||
function androidNavigationHostFixture(
|
||||
probeClipboardShellSupport: () => Promise<AndroidClipboardShellSupport> = async () => 'supported',
|
||||
) {
|
||||
return {
|
||||
androidTools: {
|
||||
probeClipboardShellSupport,
|
||||
runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
},
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
appInventory: {
|
||||
apple: { listApps: async () => [] },
|
||||
android: { listApps: async () => [] },
|
||||
harmonyos: { listApps: async () => [] },
|
||||
},
|
||||
appState: {
|
||||
android: { run: async () => ({ stdout: '' }) },
|
||||
harmonyos: { run: async () => ({ stdout: '' }) },
|
||||
},
|
||||
deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } },
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
screenRecording: {
|
||||
android: {
|
||||
resolve: async () => ({
|
||||
mode: 'local' as const,
|
||||
start: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
signal: async () => true,
|
||||
isRunning: async () => false,
|
||||
exists: async () => false,
|
||||
pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
remove: async () => true,
|
||||
readManifest: async () => undefined,
|
||||
writeManifest: async () => {},
|
||||
removeManifest: async () => {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
}
|
||||
|
||||
test.each([
|
||||
['emulator', device],
|
||||
['device', { ...device, kind: 'device' as const }],
|
||||
['unknown', unknownKindDevice],
|
||||
['emulator', ANDROID_EMULATOR],
|
||||
['device', { ...ANDROID_EMULATOR, kind: 'device' as const }],
|
||||
['unknown', UNKNOWN_KIND_DEVICE],
|
||||
])(
|
||||
'classifies Android %s back/home/orientation/keyboard facts through the shared touch gate',
|
||||
async (_name, runtimeDevice) => {
|
||||
const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({
|
||||
device: runtimeDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidNavigationHost()),
|
||||
runtimeDevice,
|
||||
);
|
||||
const { facts } = binding;
|
||||
|
||||
// back/home/orientation/keyboard status+dismiss+enter all ride the same adb-driven touch
|
||||
@@ -303,16 +174,11 @@ test.each([
|
||||
);
|
||||
|
||||
test('admits Android tv-remote only for a real TV target', async () => {
|
||||
const tvDevice = { ...device, kind: 'emulator' as const, target: 'tv' as const };
|
||||
const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({
|
||||
device: tvDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const tvDevice = { ...ANDROID_EMULATOR, kind: 'emulator' as const, target: 'tv' as const };
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidNavigationHost()),
|
||||
tvDevice,
|
||||
);
|
||||
|
||||
expect(binding.facts.operations.tvRemote).toEqual({ available: true });
|
||||
expect(binding.operations.tvRemote).toBeTypeOf('function');
|
||||
@@ -324,14 +190,10 @@ test('admits Android tv-remote only for a real TV target', async () => {
|
||||
// bucket's name for a device with no declared kind, which `DeviceKind` cannot express.)
|
||||
test('admits both clipboard halves and the app switcher on every real Android kind', async () => {
|
||||
for (const kind of ['emulator', 'device'] as const) {
|
||||
const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({
|
||||
device: { ...device, id: `android-${kind}`, kind },
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
const binding = await bindOrdinary(createAndroidPlatformRuntime(androidNavigationHost()), {
|
||||
...ANDROID_EMULATOR,
|
||||
id: `android-${kind}`,
|
||||
kind,
|
||||
});
|
||||
expect(binding.facts.operations.readClipboard).toEqual({ available: true });
|
||||
expect(binding.facts.operations.writeClipboard).toEqual({ available: true });
|
||||
@@ -355,16 +217,15 @@ test('admits both clipboard halves and the app switcher on every real Android ki
|
||||
});
|
||||
|
||||
test('the synthetic Android simulator cell refuses back/home/orientation/keyboard like every other touch operation', async () => {
|
||||
const simulatorDevice = { ...device, id: 'android-simulator', kind: 'simulator' as const };
|
||||
const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({
|
||||
device: simulatorDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const simulatorDevice = {
|
||||
...ANDROID_EMULATOR,
|
||||
id: 'android-simulator',
|
||||
kind: 'simulator' as const,
|
||||
};
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidNavigationHost()),
|
||||
simulatorDevice,
|
||||
);
|
||||
const { facts } = binding;
|
||||
|
||||
for (const operation of [
|
||||
@@ -401,7 +262,7 @@ type LegacyLifecycleCell = Readonly<{
|
||||
test.each([
|
||||
[
|
||||
'emulator',
|
||||
device,
|
||||
ANDROID_EMULATOR,
|
||||
{
|
||||
openTarget: true,
|
||||
prepareAppleRunner: false,
|
||||
@@ -412,7 +273,7 @@ test.each([
|
||||
],
|
||||
[
|
||||
'device',
|
||||
{ ...device, kind: 'device' as const },
|
||||
{ ...ANDROID_EMULATOR, kind: 'device' as const },
|
||||
{
|
||||
openTarget: true,
|
||||
prepareAppleRunner: false,
|
||||
@@ -423,7 +284,7 @@ test.each([
|
||||
],
|
||||
[
|
||||
'synthetic simulator',
|
||||
{ ...device, id: 'android-simulator', kind: 'simulator' as const },
|
||||
{ ...ANDROID_EMULATOR, id: 'android-simulator', kind: 'simulator' as const },
|
||||
{
|
||||
openTarget: false,
|
||||
prepareAppleRunner: false,
|
||||
@@ -435,44 +296,10 @@ test.each([
|
||||
] satisfies ReadonlyArray<readonly [string, DeviceInfo, LegacyLifecycleCell]>)(
|
||||
'classifies the Android %s lifecycle denominator against the legacy dispatch cell',
|
||||
async (_name, runtimeDevice, legacy) => {
|
||||
const host = {
|
||||
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
appInventory: {
|
||||
apple: { listApps: async () => [] },
|
||||
android: { listApps: async () => [] },
|
||||
harmonyos: { listApps: async () => [] },
|
||||
},
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
screenRecording: {
|
||||
android: {
|
||||
resolve: async () => ({
|
||||
mode: 'local' as const,
|
||||
start: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
signal: async () => true,
|
||||
isRunning: async () => false,
|
||||
exists: async () => false,
|
||||
pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
remove: async () => true,
|
||||
readManifest: async () => undefined,
|
||||
writeManifest: async () => {},
|
||||
removeManifest: async () => {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
const binding = await createAndroidPlatformRuntime(host).bind({
|
||||
device: runtimeDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidRuntimeHost()),
|
||||
runtimeDevice,
|
||||
);
|
||||
const { facts } = binding;
|
||||
expect(facts.device.providerMode).toBe('local');
|
||||
expect(facts.operations.networkDump).toEqual({ available: true });
|
||||
@@ -525,13 +352,13 @@ function expectLifecycleFacts(
|
||||
// synthesis and to target-authored drag but never to a plain one-contact fling or pan.
|
||||
test.each([
|
||||
// name, device, plan, multiTouch, drag, scroll
|
||||
['emulator', device, true, true, true, true],
|
||||
['physical device', { ...device, kind: 'device' as const }, true, true, true, true],
|
||||
['unknown kind', unknownKindDevice, true, true, true, true],
|
||||
['TV target', { ...device, target: 'tv' as const }, true, false, false, true],
|
||||
['emulator', ANDROID_EMULATOR, true, true, true, true],
|
||||
['physical device', { ...ANDROID_EMULATOR, kind: 'device' as const }, true, true, true, true],
|
||||
['unknown kind', UNKNOWN_KIND_DEVICE, true, true, true, true],
|
||||
['TV target', { ...ANDROID_EMULATOR, target: 'tv' as const }, true, false, false, true],
|
||||
[
|
||||
'synthetic simulator row',
|
||||
{ ...device, kind: 'simulator' as const },
|
||||
{ ...ANDROID_EMULATOR, kind: 'simulator' as const },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -553,7 +380,7 @@ test.each([
|
||||
|
||||
test('carries the retired Android TV hints verbatim', async () => {
|
||||
const facts = await createAndroidPlatformRuntime(gestureHost()).inspectFacts({
|
||||
...device,
|
||||
...ANDROID_EMULATOR,
|
||||
target: 'tv',
|
||||
});
|
||||
expect(facts.operations.performMultiTouchGesturePlan).toEqual({
|
||||
@@ -569,37 +396,20 @@ test('carries the retired Android TV hints verbatim', async () => {
|
||||
|
||||
test('binds only the Android gesture tiers the target admitted', async () => {
|
||||
const bind = async (runtimeDevice: DeviceInfo) =>
|
||||
await createAndroidPlatformRuntime(gestureHost()).bind({
|
||||
device: runtimeDevice,
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const phone = await bind(device);
|
||||
await bindOrdinary(createAndroidPlatformRuntime(gestureHost()), runtimeDevice);
|
||||
const phone = await bind(ANDROID_EMULATOR);
|
||||
expect(phone.operations.performMultiTouchGesturePlan).toBeTypeOf('function');
|
||||
expect(phone.operations.scrollDirection).toBeTypeOf('function');
|
||||
const tv = await bind({ ...device, target: 'tv' });
|
||||
const tv = await bind({ ...ANDROID_EMULATOR, target: 'tv' });
|
||||
expect(tv.operations.performGesturePlan).toBeTypeOf('function');
|
||||
expect(tv.operations.performMultiTouchGesturePlan).toBeUndefined();
|
||||
expect(tv.operations.performTargetAuthoredDrag).toBeUndefined();
|
||||
});
|
||||
|
||||
function gestureHost(): PlatformRuntimeHost {
|
||||
return {
|
||||
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
|
||||
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
|
||||
appInventory: {
|
||||
apple: { listApps: async () => [] },
|
||||
android: { listApps: async () => [] },
|
||||
harmonyos: { listApps: async () => [] },
|
||||
},
|
||||
localInteractors: { resolve: async () => ({}) },
|
||||
audioProbe: audioProbeHost,
|
||||
return androidRuntimeHost({
|
||||
screenRecording: { android: { resolve: async () => ({ mode: 'local' as const }) } },
|
||||
} as unknown as PlatformRuntimeHost;
|
||||
});
|
||||
}
|
||||
|
||||
// R55 defect, found on a Pixel 9 Pro XL / Android 36 emulator: the retired bucket admitted both
|
||||
@@ -607,17 +417,10 @@ function gestureHost(): PlatformRuntimeHost {
|
||||
// `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes
|
||||
// the same condition the leaf checks, so a build with no clipboard shell command refuses up front.
|
||||
test('refuses both clipboard halves when the build reports no clipboard shell', async () => {
|
||||
const binding = await createAndroidPlatformRuntime(
|
||||
androidNavigationHostFixture(async () => 'unsupported'),
|
||||
).bind({
|
||||
device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' },
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidNavigationHost(async () => 'unsupported')),
|
||||
{ ...ANDROID_EMULATOR, id: 'android-no-clipboard-shell', kind: 'device' },
|
||||
);
|
||||
|
||||
for (const key of ['readClipboard', 'writeClipboard'] as const) {
|
||||
expect(binding.facts.operations[key]).toMatchObject({
|
||||
@@ -635,8 +438,12 @@ test.each([['supported'], ['unsupported']] as const)(
|
||||
'caches a definitive %s verdict instead of re-probing per inspection',
|
||||
async (verdict) => {
|
||||
const probe = vi.fn(async () => verdict);
|
||||
const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe));
|
||||
const target = { ...device, id: `android-probe-cache-${verdict}`, kind: 'device' as const };
|
||||
const runtime = createAndroidPlatformRuntime(androidNavigationHost(probe));
|
||||
const target = {
|
||||
...ANDROID_EMULATOR,
|
||||
id: `android-probe-cache-${verdict}`,
|
||||
kind: 'device' as const,
|
||||
};
|
||||
|
||||
await runtime.inspectFacts(target);
|
||||
await runtime.inspectFacts(target);
|
||||
@@ -650,17 +457,10 @@ test.each([['supported'], ['unsupported']] as const)(
|
||||
// `capabilities` advertised — and must not be remembered, or one transport blip decides the
|
||||
// question for the owner's whole life.
|
||||
test('a failed probe refuses rather than fabricating availability', async () => {
|
||||
const binding = await createAndroidPlatformRuntime(
|
||||
androidNavigationHostFixture(async () => 'probe-failed'),
|
||||
).bind({
|
||||
device: { ...device, id: 'android-probe-failed', kind: 'device' },
|
||||
intent: { kind: 'ordinary' },
|
||||
scope: {
|
||||
signal: new AbortController().signal,
|
||||
diagnostics: { emit: () => {} },
|
||||
progress: { report: () => {} },
|
||||
},
|
||||
});
|
||||
const binding = await bindOrdinary(
|
||||
createAndroidPlatformRuntime(androidNavigationHost(async () => 'probe-failed')),
|
||||
{ ...ANDROID_EMULATOR, id: 'android-probe-failed', kind: 'device' },
|
||||
);
|
||||
|
||||
for (const key of ['readClipboard', 'writeClipboard'] as const) {
|
||||
expect(binding.facts.operations[key]).toMatchObject({ available: false });
|
||||
@@ -676,8 +476,8 @@ test('a failed probe is not cached, so the next inspection asks again', async ()
|
||||
.fn<() => Promise<AndroidClipboardShellSupport>>()
|
||||
.mockResolvedValueOnce('probe-failed')
|
||||
.mockResolvedValue('supported');
|
||||
const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe));
|
||||
const target = { ...device, id: 'android-probe-retry', kind: 'device' as const };
|
||||
const runtime = createAndroidPlatformRuntime(androidNavigationHost(probe));
|
||||
const target = { ...ANDROID_EMULATOR, id: 'android-probe-retry', kind: 'device' as const };
|
||||
|
||||
const first = await runtime.inspectFacts(target);
|
||||
const second = await runtime.inspectFacts(target);
|
||||
@@ -688,11 +488,11 @@ test('a failed probe is not cached, so the next inspection asks again', async ()
|
||||
});
|
||||
|
||||
test('a host with no clipboard probe refuses rather than assuming support', async () => {
|
||||
const host = androidNavigationHostFixture();
|
||||
const host = androidNavigationHost();
|
||||
const withoutProbe = { ...host, androidTools: {} } as unknown as PlatformRuntimeHost;
|
||||
|
||||
const facts = await createAndroidPlatformRuntime(withoutProbe).inspectFacts({
|
||||
...device,
|
||||
...ANDROID_EMULATOR,
|
||||
id: 'android-no-probe',
|
||||
kind: 'device',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { vi } from 'vitest';
|
||||
import { runCmd } from '@agent-device/host-kit/command';
|
||||
|
||||
export type MockRunCmdResult = Awaited<ReturnType<typeof runCmd>>;
|
||||
export type HostCommandHandler = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof runCmd>[2],
|
||||
) => Promise<MockRunCmdResult | null>;
|
||||
|
||||
/** Routes every `runCmd` call through `handlers` in order; the first non-null result wins. */
|
||||
export function mockHostCommands(handlers: HostCommandHandler[]): void {
|
||||
vi.mocked(runCmd).mockImplementation(async (cmd, args, options) => {
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(cmd, args, options);
|
||||
if (result) return result;
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes `Example.app/Contents/Info.plist` under `tmpDir` and returns the bundle path. */
|
||||
export async function writeMacosAppBundle(tmpDir: string, executable: string): Promise<string> {
|
||||
const bundlePath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, 'Contents', 'Info.plist'),
|
||||
infoPlistXml(executable),
|
||||
'utf8',
|
||||
);
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
/** Writes a flat `Example.app/Info.plist` under `tmpDir` and returns the app path. */
|
||||
export async function writeIosSimulatorApp(tmpDir: string, executable: string): Promise<string> {
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(path.join(appPath, 'Info.plist'), infoPlistXml(executable), 'utf8');
|
||||
return appPath;
|
||||
}
|
||||
|
||||
/** `mdfind` locates the bundle; `plutil` fails so the executable is read from Info.plist. */
|
||||
export function macosBundleLookup(bundlePath: string): HostCommandHandler {
|
||||
return async (cmd) => {
|
||||
if (cmd === 'mdfind') return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 };
|
||||
if (cmd === 'plutil') return plutilFallback();
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** `simctl get_app_container` locates the app; `plutil` fails like `macosBundleLookup`. */
|
||||
export function iosSimulatorAppContainer(appPath: string): HostCommandHandler {
|
||||
return async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') return plutilFallback();
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function hostPs(rows: string[]): HostCommandHandler {
|
||||
return async (cmd) => (cmd === 'ps' ? psOutput(rows) : null);
|
||||
}
|
||||
|
||||
export function simulatorPs(rows: string[]): HostCommandHandler {
|
||||
return async (cmd, args) => (isSimulatorPs(cmd, args) ? psOutput(rows) : null);
|
||||
}
|
||||
|
||||
export function simulatorPsUnavailable(): HostCommandHandler {
|
||||
return async (cmd, args) =>
|
||||
isSimulatorPs(cmd, args)
|
||||
? { stdout: '', stderr: 'No such file or directory', exitCode: 2 }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isSimulatorPs(cmd: string, args: string[]): boolean {
|
||||
return cmd === 'xcrun' && args.includes('spawn') && args.includes('ps');
|
||||
}
|
||||
|
||||
function infoPlistXml(executable: string): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
`<key>CFBundleExecutable</key><string>${executable}</string>`,
|
||||
'</dict></plist>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function plutilFallback(): MockRunCmdResult {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
|
||||
function psOutput(rows: string[]): MockRunCmdResult {
|
||||
return { stdout: rows.join('\n'), stderr: '', exitCode: 0 };
|
||||
}
|
||||
@@ -30,10 +30,21 @@ import { parseAppleFramePerfSample } from '../perf-frame.ts';
|
||||
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
|
||||
import type { DeviceInfo } from '@agent-device/kernel/device';
|
||||
import { AppError } from '@agent-device/kernel/errors';
|
||||
import {
|
||||
hostPs,
|
||||
iosSimulatorAppContainer,
|
||||
isSimulatorPs,
|
||||
macosBundleLookup,
|
||||
mockHostCommands,
|
||||
simulatorPs,
|
||||
simulatorPsUnavailable,
|
||||
writeIosSimulatorApp,
|
||||
writeMacosAppBundle,
|
||||
type MockRunCmdResult,
|
||||
} from './perf.fixtures.ts';
|
||||
|
||||
const mockRunCmd = vi.mocked(runCmd);
|
||||
const mockRunCmdBackground = vi.mocked(runCmdBackground);
|
||||
type MockRunCmdResult = Awaited<ReturnType<typeof runCmd>>;
|
||||
type XcrunMockHandler = (args: string[]) => Promise<MockRunCmdResult | null>;
|
||||
|
||||
const IOS_SIMULATOR: DeviceInfo = {
|
||||
@@ -160,39 +171,15 @@ test('parseAppleFramePerfSample summarizes app hitches and worst windows', () =>
|
||||
|
||||
test('sampleAppleMemoryPerf aggregates host ps memory for macOS app bundle', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-macos-perf-');
|
||||
const bundlePath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, 'Contents', 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'mdfind') {
|
||||
return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'ps') {
|
||||
return {
|
||||
stdout: [
|
||||
`111 8.5 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`,
|
||||
`222 1.5 5000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')} --helper`,
|
||||
'333 9.0 9999 /Applications/Other.app/Contents/MacOS/Other',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
const bundlePath = await writeMacosAppBundle(tmpDir, 'ExampleExec');
|
||||
mockHostCommands([
|
||||
macosBundleLookup(bundlePath),
|
||||
hostPs([
|
||||
`111 8.5 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`,
|
||||
`222 1.5 5000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')} --helper`,
|
||||
'333 9.0 9999 /Applications/Other.app/Contents/MacOS/Other',
|
||||
]),
|
||||
]);
|
||||
|
||||
try {
|
||||
const memory = await sampleAppleMemoryPerf(MACOS_DEVICE, 'com.example.app');
|
||||
@@ -205,38 +192,14 @@ test('sampleAppleMemoryPerf aggregates host ps memory for macOS app bundle', asy
|
||||
|
||||
test('sampleAppleMemoryPerf uses simctl spawn ps for iOS simulators', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-sim-perf-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>Example Sim Exec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) {
|
||||
return {
|
||||
stdout: [
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec');
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPs([
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
]),
|
||||
]);
|
||||
|
||||
try {
|
||||
const memory = await sampleAppleMemoryPerf(IOS_SIMULATOR, 'com.example.sim');
|
||||
@@ -249,38 +212,16 @@ test('sampleAppleMemoryPerf uses simctl spawn ps for iOS simulators', async () =
|
||||
|
||||
test('captureAppleMemorySnapshot records memgraph for iOS simulator processes', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-sim-memgraph-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec');
|
||||
const outPath = path.join(tmpDir, 'app.memgraph');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleSimExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args, options) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('ps')) {
|
||||
return {
|
||||
stdout: [
|
||||
`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`,
|
||||
`222 1.0 16384 ${path.join(appPath, 'ExampleSimExec')} --helper`,
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('leaks')) {
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPs([
|
||||
`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`,
|
||||
`222 1.0 16384 ${path.join(appPath, 'ExampleSimExec')} --helper`,
|
||||
]),
|
||||
async (cmd, args, options) => {
|
||||
if (cmd !== 'xcrun' || !args.includes('leaks')) return null;
|
||||
assert.equal(options?.timeoutMs, 120_000);
|
||||
assert.deepEqual(args, [
|
||||
'simctl',
|
||||
@@ -291,10 +232,9 @@ test('captureAppleMemorySnapshot records memgraph for iOS simulator processes',
|
||||
'222',
|
||||
]);
|
||||
await fs.writeFile(outPath, 'memgraph-bytes', 'utf8');
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
return emptyRunResult();
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const snapshot = await captureAppleMemorySnapshot(IOS_SIMULATOR, 'com.example.sim', outPath);
|
||||
@@ -311,41 +251,18 @@ test('captureAppleMemorySnapshot records memgraph for iOS simulator processes',
|
||||
|
||||
test('captureAppleMemorySnapshot records memgraph for macOS app processes', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-macos-memgraph-');
|
||||
const bundlePath = path.join(tmpDir, 'Example.app');
|
||||
const bundlePath = await writeMacosAppBundle(tmpDir, 'ExampleExec');
|
||||
const outPath = path.join(tmpDir, 'app.memgraph');
|
||||
await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, 'Contents', 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'mdfind') {
|
||||
return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'ps') {
|
||||
return {
|
||||
stdout: `111 1.0 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (cmd === 'leaks') {
|
||||
mockHostCommands([
|
||||
macosBundleLookup(bundlePath),
|
||||
hostPs([`111 1.0 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`]),
|
||||
async (cmd, args) => {
|
||||
if (cmd !== 'leaks') return null;
|
||||
assert.deepEqual(args, [`--outputGraph=${outPath}`, '111']);
|
||||
await fs.writeFile(outPath, 'mac-memgraph-bytes', 'utf8');
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
return emptyRunResult();
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const snapshot = await captureAppleMemorySnapshot(MACOS_DEVICE, 'com.example.app', outPath);
|
||||
@@ -361,40 +278,17 @@ test('captureAppleMemorySnapshot records memgraph for macOS app processes', asyn
|
||||
|
||||
test('captureAppleMemorySnapshot removes partial memgraph when leaks exits nonzero', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-memgraph-fail-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec');
|
||||
const outPath = path.join(tmpDir, 'app.memgraph');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleSimExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('ps')) {
|
||||
return {
|
||||
stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('leaks')) {
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPs([`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`]),
|
||||
async (cmd, args) => {
|
||||
if (cmd !== 'xcrun' || !args.includes('leaks')) return null;
|
||||
await fs.writeFile(outPath, 'partial-memgraph', 'utf8');
|
||||
return { stdout: '', stderr: 'permission denied', exitCode: 1 };
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
@@ -409,35 +303,13 @@ test('captureAppleMemorySnapshot removes partial memgraph when leaks exits nonze
|
||||
|
||||
test('captureAppleMemorySnapshot removes partial memgraph and hints when leaks times out', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-memgraph-timeout-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec');
|
||||
const outPath = path.join(tmpDir, 'app.memgraph');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleSimExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('ps')) {
|
||||
return {
|
||||
stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('leaks')) {
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPs([`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`]),
|
||||
async (cmd, args) => {
|
||||
if (cmd !== 'xcrun' || !args.includes('leaks')) return null;
|
||||
await fs.writeFile(outPath, 'partial-memgraph', 'utf8');
|
||||
throw new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms', {
|
||||
cmd,
|
||||
@@ -447,9 +319,8 @@ test('captureAppleMemorySnapshot removes partial memgraph and hints when leaks t
|
||||
exitCode: -1,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
@@ -484,27 +355,11 @@ test('captureAppleMemorySnapshot reports physical iOS as unavailable', async ()
|
||||
|
||||
test('captureAppleMemorySnapshot reports iOS simulator without process tools as unavailable', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-sim-no-ps-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleSimExec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('ps')) {
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec');
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
async (cmd, args) => {
|
||||
if (!isSimulatorPs(cmd, args)) return null;
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
'The operation couldn’t be completed. No such file or directory',
|
||||
@@ -517,9 +372,8 @@ test('captureAppleMemorySnapshot reports iOS simulator without process tools as
|
||||
processExitError: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const snapshot = await captureAppleMemorySnapshot(
|
||||
@@ -538,41 +392,12 @@ test('captureAppleMemorySnapshot reports iOS simulator without process tools as
|
||||
|
||||
test('sampleAppleMemoryPerf falls back to host ps when simulator ps is unavailable', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-ios-sim-perf-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>Example Sim Exec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) {
|
||||
return { stdout: '', stderr: 'No such file or directory', exitCode: 2 };
|
||||
}
|
||||
if (cmd === 'ps') {
|
||||
return {
|
||||
stdout: [
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec');
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPsUnavailable(),
|
||||
hostPs([`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, '222 4.0 1024 SpringBoard']),
|
||||
]);
|
||||
|
||||
try {
|
||||
const memory = await sampleAppleMemoryPerf(IOS_SIMULATOR, 'com.example.sim');
|
||||
@@ -659,39 +484,15 @@ test('sampleAppleFramePerf retries transient kperf lock failures', async () => {
|
||||
|
||||
test('startAppleXctracePerfCapture attaches to an active iOS simulator app process', async () => {
|
||||
const tmpDir = await mkdtempForTest('agent-device-xctrace-sim-');
|
||||
const appPath = path.join(tmpDir, 'Example.app');
|
||||
const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec');
|
||||
const tracePath = path.join(tmpDir, 'app.trace');
|
||||
await fs.mkdir(appPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(appPath, 'Info.plist'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>Example Sim Exec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'xcrun' && args.includes('get_app_container')) {
|
||||
return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === 'plutil') {
|
||||
return { stdout: '', stderr: 'mock fallback', exitCode: 1 };
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) {
|
||||
return {
|
||||
stdout: [
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
});
|
||||
mockHostCommands([
|
||||
iosSimulatorAppContainer(appPath),
|
||||
simulatorPs([
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
]),
|
||||
]);
|
||||
|
||||
try {
|
||||
const capture = await startAppleXctracePerfCapture({
|
||||
|
||||
@@ -242,6 +242,7 @@ export default defineConfig({
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/__tests__/**',
|
||||
'**/*.fixtures.ts',
|
||||
'src/**/*-types.ts',
|
||||
'src/**/types.ts',
|
||||
'src/sdk/**',
|
||||
|
||||
Reference in New Issue
Block a user