diff --git a/packages/platform-apple/src/foldable/pose.test.ts b/packages/platform-apple/src/foldable/pose.test.ts index 244f0b04e5..cfd1bf60ee 100644 --- a/packages/platform-apple/src/foldable/pose.test.ts +++ b/packages/platform-apple/src/foldable/pose.test.ts @@ -89,7 +89,7 @@ test('sends the simulator HID pose and reports the pose CoreDevice read back', a screen: { display: 'LCD-1', coordinateSpace: 'native-panel', widthPt: 669, heightPt: 951 }, }); - expect(mockSend).toHaveBeenCalledWith(duo.id, 'open', undefined); + expect(mockSend).toHaveBeenCalledWith(duo, 'open', undefined); expect(mockHinge).toHaveBeenCalledTimes(2); }); @@ -162,7 +162,7 @@ test('sends half-open and reports it only once the hinge has stopped', async () hingeAngleDegrees: 130, screen: { display: 'LCD-1', coordinateSpace: 'native-panel', widthPt: 669, heightPt: 951 }, }); - expect(mockSend).toHaveBeenCalledWith(duo.id, 'half-open', undefined); + expect(mockSend).toHaveBeenCalledWith(duo, 'half-open', undefined); expect(mockHinge).toHaveBeenCalledTimes(3); }); @@ -358,7 +358,7 @@ test('reports a custom final angle only after it reaches and holds that angle', pose: 'half-open', hingeAngleDegrees: 100, }); - expect(mockSend).toHaveBeenCalledWith(duo.id, keyframes, undefined); + expect(mockSend).toHaveBeenCalledWith(duo, keyframes, undefined); }); test.each([ diff --git a/packages/platform-apple/src/foldable/pose.ts b/packages/platform-apple/src/foldable/pose.ts index 03e8afe959..16b32a3f61 100644 --- a/packages/platform-apple/src/foldable/pose.ts +++ b/packages/platform-apple/src/foldable/pose.ts @@ -45,7 +45,7 @@ export async function setAppleFoldPose( const inventory = await queryAppleDisplayInventory(device, { signal: options.signal }); requireFoldableInventory(device, inventory); - await sendSimulatorFoldPose(device.id, intent.keyframes ?? pose, options.signal); + await sendSimulatorFoldPose(device, intent.keyframes ?? pose, options.signal); emitDiagnostic({ level: 'info', phase: 'apple_fold_pose_dispatched', diff --git a/packages/platform-apple/src/foldable/simulator-hid.test.ts b/packages/platform-apple/src/foldable/simulator-hid.test.ts index 56f5aaafce..3fc7c397c6 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.test.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.test.ts @@ -2,8 +2,11 @@ import { expect, test } from 'vitest'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { withAppleToolProvider, createLocalAppleToolProvider } from '../core/tool-provider.ts'; +import { IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; import { sendSimulatorFoldPose } from './simulator-hid.ts'; +const selectedDuo = { ...IOS_SIMULATOR, id: 'selected-duo' }; + test.each(['success', 'build', 'dispatch', 'cancel'] as const)( 'HID route targets the UDID, cleans temporary artifacts, and handles %s', async (failure) => { @@ -29,7 +32,7 @@ test.each(['success', 'build', 'dispatch', 'cancel'] as const)( }, }), async () => { - const operation = sendSimulatorFoldPose('selected-duo', 'half-open', controller.signal); + const operation = sendSimulatorFoldPose(selectedDuo, 'half-open', controller.signal); if (failure === 'success') await expect(operation).resolves.toBeUndefined(); else if (failure === 'cancel') await expect(operation).rejects.toThrow('cancelled'); else @@ -64,7 +67,25 @@ test('streams all keyframes in one process with a duration-derived timeout', asy return { stdout: '', stderr: '', exitCode: 0 }; }, }), - () => sendSimulatorFoldPose('duo', keyframes), + () => sendSimulatorFoldPose(selectedDuo, keyframes), ); expect(dispatches).toBe(1); }); + +test('HID dispatch addresses the UDID inside its scoped simulator set', async () => { + const dispatches: string[][] = []; + let binary = ''; + await withAppleToolProvider( + createLocalAppleToolProvider({ + runCommand: async (_command, args) => { + if (args.includes('clang')) binary = args.at(-1)!; + else dispatches.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + () => sendSimulatorFoldPose({ ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, 'closed'), + ); + expect(dispatches).toEqual([ + ['simctl', '--set', '/tmp/scoped-set', 'spawn', 'selected-duo', binary, 'closed'], + ]); +}); diff --git a/packages/platform-apple/src/foldable/simulator-hid.ts b/packages/platform-apple/src/foldable/simulator-hid.ts index ec5e14f179..b59c90cb92 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.ts @@ -1,14 +1,16 @@ import path from 'node:path'; import type { FoldKeyframe, FoldPose } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; import { makeHostTemporaryDirectory, removeHostDirectory } from '@agent-device/host-kit/host-file'; import { findProjectRoot } from '@agent-device/host-kit/version'; +import { runSimctlForDevice } from '../core/simctl.ts'; import { runXcrun } from '../core/tool-provider.ts'; /** Compiles for the selected Xcode and dispatches inside exactly the requested simulator. */ export async function sendSimulatorFoldPose( - udid: string, + device: DeviceInfo, pose: FoldPose | readonly FoldKeyframe[], signal?: AbortSignal, ): Promise { @@ -49,7 +51,7 @@ export async function sendSimulatorFoldPose( signal?.throwIfAborted(); const durationMs = typeof pose === 'string' ? 0 : pose.at(-1)!.atMs; const payload = typeof pose === 'string' ? pose : JSON.stringify(pose); - const sent = await runXcrun(['simctl', 'spawn', udid, binary, payload], { + const sent = await runSimctlForDevice(device, ['spawn', device.id, binary, payload], { signal, timeoutMs: durationMs + 10_000, // simctl must forward termination to the guest before the host kills it. @@ -60,7 +62,7 @@ export async function sendSimulatorFoldPose( throw new AppError( 'COMMAND_FAILED', 'Unable to send the simulator hinge pose', - execFailureDetails(sent, { reason: 'fold-hid-dispatch-failed', deviceId: udid }), + execFailureDetails(sent, { reason: 'fold-hid-dispatch-failed', deviceId: device.id }), ); } } finally { diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 5b9a6fed68..d419def596 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -47,9 +47,13 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp try { const result = await source.acquire({ - target: { ...sourceTarget, targetId: 'target-1' }, + target: { ...sourceTarget, targetId: 'target-1', simulatorSetPath: '/tmp/scoped-set' }, hint, }); + assert.deepEqual( + fixture.startedTargets.map((started) => started.simulatorSetPath), + ['/tmp/scoped-set'], + ); assert.equal(fixture.builds, 1); // Four identity probes and one clang build: the identity read execs one Xcode-owned binary. assert.equal(fixture.runs, 5); @@ -465,6 +469,7 @@ type AdapterFixture = { omitViewport: boolean; omitRecovery: boolean; diagnostics: Record[]; + startedTargets: Array[0]>; }; function targetForTest() { @@ -492,6 +497,7 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture { omitViewport: false, omitRecovery: false, diagnostics: [], + startedTargets: [], }; const host: SnapshotSourceHost = { ...realHost, @@ -520,7 +526,10 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture { exitCode: 0, }; }, - start: () => new AdapterProcess(), + start: (target) => { + fixture.startedTargets.push(target); + return new AdapterProcess(); + }, connect: async () => new AdapterSocket(fixture), readTargetProcessStartTime: async () => 'target-start', }; diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index f0e35b44f0..893677b787 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { test, vi } from 'vitest'; +import { runCmdBackground } from '@agent-device/host-kit/command'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; +vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ + ...(await importOriginal()), + runCmdBackground: vi.fn(), +})); + test('snapshot bridge socket paths stay within the AF_UNIX limit and are target-specific', () => { const host = createSnapshotSourceHost(); const first = snapshotSourceSocketPath(host, 'simulator-1', 'owner-1'); @@ -14,3 +21,45 @@ test('snapshot bridge socket paths stay within the AF_UNIX limit and are target- assert.notEqual(first, second); assert.notEqual(first, otherOwner); }); + +test.each([ + ['the default simulator set', undefined, []], + ['a scoped simulator set', '/tmp/scoped-set', ['--set', '/tmp/scoped-set']], +] as const)('the bridge spawns inside %s', (_label, simulatorSetPath, setArgs) => { + const spawn = vi.mocked(runCmdBackground); + spawn.mockReset(); + const child = Object.assign(new EventEmitter(), { + pid: 4242, + exitCode: null, + signalCode: null, + stderr: null, + }); + spawn.mockReturnValue({ + child: child as unknown as ReturnType['child'], + wait: new Promise(() => {}), + }); + + const started = createSnapshotSourceHost().start( + { udid: 'simulator-1', ...(simulatorSetPath ? { simulatorSetPath } : {}) }, + '/tmp/bridge', + '/tmp/bridge.sock', + ); + + assert.equal(started.pid, 4242); + assert.deepEqual(spawn.mock.calls[0]?.slice(0, 2), [ + 'xcrun', + [ + 'simctl', + ...setArgs, + 'spawn', + 'simulator-1', + '/tmp/bridge', + 'serve', + '/tmp/bridge.sock', + '--idle-timeout', + '60', + '--exit-on-disconnect', + 'false', + ], + ]); +}); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index d658ec3478..b80421d1fe 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -23,8 +23,14 @@ import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diag import { findProjectRoot } from '@agent-device/host-kit/version'; import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; import { remainingSnapshotSourceMs } from './deadline.ts'; -import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts'; +import type { + SnapshotSourceHost, + SnapshotSourceProcess, + SnapshotSourceSocket, + SnapshotSourceTarget, +} from './types.ts'; import { readSnapshotTargetProcessStartTime } from '../snapshot-process.ts'; +import { buildSimctlArgs } from '../core/simctl.ts'; const BRIDGE_IDLE_TIMEOUT_SECONDS = 60; const MAX_PROCESS_LOG_BYTES = 64 * 1024; @@ -54,7 +60,7 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { } function startSnapshotBridge( - udid: string, + target: Pick, bridgePath: string, socketPath: string, options: { signal?: AbortSignal } = {}, @@ -64,18 +70,20 @@ function startSnapshotBridge( } const started = runCmdBackground( 'xcrun', - [ - 'simctl', - 'spawn', - udid, - bridgePath, - 'serve', - socketPath, - '--idle-timeout', - String(BRIDGE_IDLE_TIMEOUT_SECONDS), - '--exit-on-disconnect', - 'false', - ], + buildSimctlArgs( + [ + 'spawn', + target.udid, + bridgePath, + 'serve', + socketPath, + '--idle-timeout', + String(BRIDGE_IDLE_TIMEOUT_SECONDS), + '--exit-on-disconnect', + 'false', + ], + { simulatorSetPath: target.simulatorSetPath }, + ), { allowFailure: true, captureOutput: false, diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index 7b16deae22..c2da49247f 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -53,6 +53,23 @@ test('the bridge manager reuses a healthy per-device helper and stops it exactly assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']); }); +test('the helper starts inside the simulator set that owns the target', async () => { + const fixture = createLifecycleFixture(); + const manager = new SnapshotBridgeManager(fixture.host); + const scopedTarget = { ...target, simulatorSetPath: '/tmp/scoped-set' }; + + await manager.request({ + target: scopedTarget, + bridge, + limits, + maxDepth: 10, + deadline: deadline(), + }); + + assert.deepEqual(fixture.startedTargets, [scopedTarget]); + await manager.close(); +}); + test('a new target generation reuses the healthy helper and carries generation per request', async () => { const fixture = createLifecycleFixture(); const manager = new SnapshotBridgeManager(fixture.host); @@ -368,6 +385,7 @@ type LifecycleFixture = { sockets: FakeSocket[]; diagnostics: Array[0]>; socketPaths: string[]; + startedTargets: Array[0]>; }; function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) { @@ -396,12 +414,14 @@ function createLifecycleFixture( const sockets: FakeSocket[] = []; const diagnostics: LifecycleFixture['diagnostics'] = []; const socketPaths: string[] = []; + const startedTargets: LifecycleFixture['startedTargets'] = []; const realHost = createSnapshotSourceHost(); const host: SnapshotSourceHost = { ...realHost, emitDiagnostic: (event) => diagnostics.push(event), readTargetProcessStartTime: async () => options.targetStartTimes?.shift() ?? 'target-start', - start: (_udid, _bridgePath, socketPath) => { + start: (startedTarget, _bridgePath, socketPath) => { + startedTargets.push(startedTarget); socketPaths.push(socketPath); const process = new FakeProcess(700 + processes.length); processes.push(process); @@ -434,7 +454,7 @@ function createLifecycleFixture( return socket; }, }; - return { host, processes, sockets, diagnostics, socketPaths }; + return { host, processes, sockets, diagnostics, socketPaths, startedTargets }; } class FakeProcess implements SnapshotSourceProcess { diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 12a02c43ed..d89abcc129 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -140,7 +140,7 @@ export class SnapshotBridgeManager { const socketPath = snapshotSourceSocketPath(this.host, input.target.udid, this.ownerId); await this.host.ensureDirectory(path.dirname(socketPath)); await this.host.remove(socketPath); - const bridgeProcess = this.host.start(input.target.udid, input.bridge.path, socketPath, { + const bridgeProcess = this.host.start(input.target, input.bridge.path, socketPath, { signal: deadline.signal, }); const session: BridgeSession = { diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index aaae313b98..2cec940121 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -22,6 +22,8 @@ export type SnapshotSourceTarget = Readonly<{ generation: string; targetId?: string; processStartTime?: string; + /** Device set that owns `udid`; absent for the default CoreSimulator set. */ + simulatorSetPath?: string; }>; export type SnapshotSourceRequest = Readonly<{ @@ -82,7 +84,7 @@ export type SnapshotSourceHost = Readonly<{ homeDirectory(): string; run(command: string, args: string[], options?: ExecOptions): Promise; start( - udid: string, + target: Pick, bridgePath: string, socketPath: string, options?: { signal?: AbortSignal }, diff --git a/packages/platform-apple/src/snapshot-target.test.ts b/packages/platform-apple/src/snapshot-target.test.ts index 5d50ac9ff2..08e830ca80 100644 --- a/packages/platform-apple/src/snapshot-target.test.ts +++ b/packages/platform-apple/src/snapshot-target.test.ts @@ -19,12 +19,11 @@ const signal = () => new AbortController().signal; function targetFixture() { const state = { pid: 42, launch: 'launch-a', start: 'start-a' as string | null }; const run = vi.fn(async (args: string[], _options?: { timeoutMs?: number }) => ({ - stdout: - args[0] === 'spawn' - ? `90\t0\tUIKitApplication:com.example.app.beta[wrong][rb-legacy]\n${state.pid}\t0\tUIKitApplication:${app}[${state.launch}][rb-legacy]` - : JSON.stringify({ - devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, - }), + stdout: args.includes('spawn') + ? `90\t0\tUIKitApplication:com.example.app.beta[wrong][rb-legacy]\n${state.pid}\t0\tUIKitApplication:${app}[${state.launch}][rb-legacy]` + : JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, + }), stderr: '', exitCode: 0, })); @@ -43,7 +42,7 @@ function targetFixture() { runCommand, provider, resolve, - discoveryCount: () => run.mock.calls.filter(([args]) => args[0] === 'spawn').length, + discoveryCount: () => run.mock.calls.filter(([args]) => args.includes('spawn')).length, }; } @@ -66,6 +65,22 @@ test('an unchanged OS process reuses its exact app target without another simctl }); }); +test('a target in a scoped simulator set carries that set to the bridge', async () => { + const fixture = targetFixture(); + await withAppleToolProvider(fixture.provider, async () => { + const target = await fixture.resolve( + { ...ios, simulatorSetPath: '/tmp/scoped-set' }, + app, + signal(), + ); + expect(target.simulatorSetPath).toBe('/tmp/scoped-set'); + expect(fixture.run.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ + ['--set', '/tmp/scoped-set'], + ['--set', '/tmp/scoped-set'], + ]); + }); +}); + test('PID reuse cannot reuse a target from a different OS process start', async () => { const fixture = targetFixture(); await withAppleToolProvider(fixture.provider, async () => { diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index 544dad5e21..18bce81b92 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -23,6 +23,7 @@ export type SimulatorSnapshotTarget = Readonly<{ generation: string; targetId: string; processStartTime: string; + simulatorSetPath?: string; }>; export type SimulatorSnapshotTargetResolver = ( @@ -106,6 +107,7 @@ async function resolveSimulatorSnapshotTarget( generation: `${job.pid}:${job.label}:${processStartTime}`, targetId: `${device.id}:${appBundleId}`, processStartTime, + ...(device.simulatorSetPath ? { simulatorSetPath: device.simulatorSetPath } : {}), }); }