Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/platform-apple/src/foldable/pose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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([
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-apple/src/foldable/pose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 23 additions & 2 deletions packages/platform-apple/src/foldable/simulator-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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'],
]);
});
8 changes: 5 additions & 3 deletions packages/platform-apple/src/foldable/simulator-hid.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions packages/platform-apple/src/snapshot-source/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -465,6 +469,7 @@ type AdapterFixture = {
omitViewport: boolean;
omitRecovery: boolean;
diagnostics: Record<string, unknown>[];
startedTargets: Array<Parameters<SnapshotSourceHost['start']>[0]>;
};

function targetForTest() {
Expand Down Expand Up @@ -492,6 +497,7 @@ function createAdapterHost(buildDelayMs = 0): AdapterFixture {
omitViewport: false,
omitRecovery: false,
diagnostics: [],
startedTargets: [],
};
const host: SnapshotSourceHost = {
...realHost,
Expand Down Expand Up @@ -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',
};
Expand Down
51 changes: 50 additions & 1 deletion packages/platform-apple/src/snapshot-source/host.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@agent-device/host-kit/command')>()),
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');
Expand All @@ -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<typeof runCmdBackground>['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',
],
]);
});
36 changes: 22 additions & 14 deletions packages/platform-apple/src/snapshot-source/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +60,7 @@ export function createSnapshotSourceHost(): SnapshotSourceHost {
}

function startSnapshotBridge(
udid: string,
target: Pick<SnapshotSourceTarget, 'udid' | 'simulatorSetPath'>,
bridgePath: string,
socketPath: string,
options: { signal?: AbortSignal } = {},
Expand All @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions packages/platform-apple/src/snapshot-source/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -368,6 +385,7 @@ type LifecycleFixture = {
sockets: FakeSocket[];
diagnostics: Array<Parameters<SnapshotSourceHost['emitDiagnostic']>[0]>;
socketPaths: string[];
startedTargets: Array<Parameters<SnapshotSourceHost['start']>[0]>;
};

function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-apple/src/snapshot-source/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion packages/platform-apple/src/snapshot-source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -82,7 +84,7 @@ export type SnapshotSourceHost = Readonly<{
homeDirectory(): string;
run(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
start(
udid: string,
target: Pick<SnapshotSourceTarget, 'udid' | 'simulatorSetPath'>,
bridgePath: string,
socketPath: string,
options?: { signal?: AbortSignal },
Expand Down
Loading
Loading