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/__tests__/fake-apple-tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import type { AppleToolProvider } from '../core/tool-provider.ts';
import { withAppleToolProvider } from '../core/tool-provider.ts';
import { withAppleToolProvider, createLocalAppleToolProvider } from '../core/tool-provider.ts';
import { execFailureDetails, type ExecResult } from '@agent-device/host-kit/command';
import { IOS_DEVICE } from './device-fixtures.ts';

Expand Down Expand Up @@ -38,7 +38,7 @@ export async function withFakeAppleTool<T>(
return result;
};

const provider: AppleToolProvider = {
const provider = createLocalAppleToolProvider({
...options.provider,
whichCommand: options.provider?.whichCommand ?? (async () => true),
runCommand: async (cmd, args, execOptions) =>
Expand All @@ -51,7 +51,7 @@ export async function withFakeAppleTool<T>(
run: async (args, execOptions) =>
await respond(['devicectl', ...args], 'xcrun', execOptions?.allowFailure),
},
};
});

return await withAppleToolProvider(provider, async () => await run({ calls, device }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { test } from 'vitest';
import { IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts';
import type { AppleRunnerProvider } from '@agent-device/apple-runner';
import { createAppleInteractor } from '../interactor.ts';
import { createAppleRunnerRuntime } from '../runner-runtime.ts';
import {
recordingRunnerProvider,
runnerResultFor,
Expand Down Expand Up @@ -119,19 +120,44 @@ test('the runner/local partition covers the full provider-backed interactor surf
assert.deepEqual(Object.keys(interactor).sort(), classified);
});

test('provider-backed interactor routes runner-command methods through the injected transport', async () => {
const calls: RecordedRunnerCall[] = [];
const interactor = createAppleInteractor(
test('provider runtime binding preserves every shared runner interaction command and result', async () => {
const localCalls: RecordedRunnerCall[] = [];
const remoteCommands: Record<string, unknown>[] = [];
const context = { appBundleId: 'com.example.app', requestId: 'http-parity' };
const host = createLiveTestHostEnvironment();
const local = createAppleInteractor(
IOS_SIMULATOR,
{ appBundleId: 'com.example.app' },
createLiveTestHostEnvironment(),
recordingRunnerProvider(calls),
context,
host,
recordingRunnerProvider(localCalls),
);
const http = createAppleRunnerRuntime({
device: IOS_SIMULATOR,
host,
provider: {
hasLiveSession: () => true,
runCommand: async (_device, command) => {
remoteCommands.push(JSON.parse(JSON.stringify(command)));
return runnerResultFor(command);
},
},
});
const remote = http.createInteractor(context);
for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) {
calls.length = 0;
await invoke(interactor);
assert.ok(calls.length >= 1, `${method} never reached the injected runner transport`);
assert.equal(calls[0]!.command.command, runnerCommand, `${method} sent a different command`);
localCalls.length = 0;
remoteCommands.length = 0;
const expected = await invoke(local);
const actual = await invoke(remote);
assert.ok(localCalls.length >= 1, `${method} never reached the runner`);
assert.equal(localCalls[0]!.command.command, runnerCommand);
assert.deepEqual(
remoteCommands.map(({ commandId: _id, ...command }) => command),
JSON.parse(
JSON.stringify(localCalls.map(({ command: { commandId: _id, ...command } }) => command)),
),
method,
);
assert.deepEqual(actual, expected, method);
}
});

Expand Down Expand Up @@ -492,3 +518,27 @@ test('snapshot forwards either forceable preferredBackend into the emitted runne
assert.equal(snapshots[0]?.command.preferredBackend, 'tree');
assert.equal(snapshots[1]?.command.preferredBackend, undefined);
});

test('app-scoped reads inherit the bound target and preserve explicit overrides', async () => {
const calls: RecordedRunnerCall[] = [];
const interactor = createAppleInteractor(
IOS_SIMULATOR,
{ appBundleId: 'com.example.bound' },
createLiveTestHostEnvironment(),
recordingRunnerProvider(calls),
);
const reads = [
(appBundleId?: string) => interactor.findText!('Ready', { appBundleId }),
(appBundleId?: string) => interactor.readTextAtPoint!({ x: 10, y: 20 }, { appBundleId }),
(appBundleId?: string) => interactor.readAlert!({ appBundleId }),
(appBundleId?: string) => interactor.awaitAlert!({ appBundleId }),
(appBundleId?: string) => interactor.acceptAlert!({ appBundleId }),
(appBundleId?: string) => interactor.dismissAlert!({ appBundleId }),
];
for (const read of reads) {
await read();
assert.equal(calls.at(-1)?.command.appBundleId, 'com.example.bound');
await read('com.example.override');
assert.equal(calls.at(-1)?.command.appBundleId, 'com.example.override');
}
});
4 changes: 3 additions & 1 deletion packages/platform-apple/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
* the simulator host in the runner-demand table, since it demands no runner of its own.
*/
export function bindAppleAppStateRuntime(
host: Pick<PlatformRuntimeHost, 'appleApplications'>,
host: {
appleApplications: Pick<PlatformRuntimeHost['appleApplications'], 'hasLiveRunnerSession'>;
},
params: Readonly<{
device: DeviceInfo;
signal: AbortSignal;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,17 @@ const SCHEME_PROBES = [

function createSchemeTool(
fixture: SchemeFixture,
options: { failFirstProbes?: number } = {},
options: { failFirstAppAProbe?: boolean } = {},
): { handle: (args: string[]) => FakeAppleToolResponse; probeCount: () => number } {
let probeCount = 0;
let failedAppA = false;

const respondToPlutil = (plistPath: string): FakeAppleToolResponse => {
const probe = SCHEME_PROBES.find((candidate) => plistPath.endsWith(candidate.suffix));
if (!probe) return '{}';
probeCount += 1;
if (options.failFirstProbes !== undefined && probeCount <= options.failFirstProbes) {
if (options.failFirstAppAProbe && plistPath === fixture.plistA && !failedAppA) {
failedAppA = true;
return { stdout: '', exitCode: 1 };
}
return probe.schemesJson;
Expand Down Expand Up @@ -97,7 +99,7 @@ test('resolveIosSimulatorDeepLinkBundleId memoizes per-app url-scheme probes', a

test('a failed url-scheme probe is retried on the next lookup instead of cached', async () => {
const fixture = await makeSchemeFixture();
const tool = createSchemeTool(fixture, { failFirstProbes: 1 });
const tool = createSchemeTool(fixture, { failFirstAppAProbe: true });

await withFakeAppleTool(tool.handle, async () => {
const duringFailure = await resolveIosSimulatorDeepLinkBundleId(
Expand Down Expand Up @@ -140,3 +142,37 @@ test('rewriting the Info.plist invalidates the cached scheme set', async () => {
assert.equal(plutilProbes(), probesBeforeRewrite + 1);
});
});

test('deep-link ownership reads app metadata serially and still detects ambiguous owners', async () => {
const { createLocalAppleToolProvider, withAppleToolProvider } =
await import('../tool-provider.ts');
let active = 0;
let peak = 0;
let reads = 0;
const apps = Array.from({ length: 20 }, (_, index) => ({
bundleId: `com.example.app${index}`,
name: `App ${index}`,
applicationType: 'User',
}));
const provider = createLocalAppleToolProvider({
simulatorApps: {
list: async () => ({ type: 'listed', apps }),
readInfoPlist: async () => {
active += 1;
peak = Math.max(peak, active);
reads += 1;
await new Promise<void>((resolve) => setImmediate(resolve));
active -= 1;
return { CFBundleURLTypes: [{ CFBundleURLSchemes: ['shared'] }] };
},
},
});
await withAppleToolProvider(provider, async () => {
assert.equal(
await resolveIosSimulatorDeepLinkBundleId(IOS_TEST_SIMULATOR, 'shared://home'),
undefined,
);
});
assert.equal(reads, 20);
assert.equal(peak, 1);
});
38 changes: 26 additions & 12 deletions packages/platform-apple/src/core/__tests__/app-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import assert from 'node:assert/strict';
import { beforeEach, test, vi } from 'vitest';
import { afterEach, beforeEach, test, vi } from 'vitest';

const { mockRunSimctl } = vi.hoisted(() => ({ mockRunSimctl: vi.fn() }));
const { mockRunSimctl, commandShapes } = vi.hoisted(() => ({
mockRunSimctl: vi.fn(),
commandShapes: vi.fn(),
}));

vi.mock('../simctl.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../simctl.ts')>()),
runSimctlForDevice: mockRunSimctl,
vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({
...(await importOriginal<typeof import('@agent-device/host-kit/command')>()),
runCmd: async (cmd: string, args: string[], options: unknown) => {
commandShapes(cmd, args);
return { exitCode: 0, stderr: '', ...(await mockRunSimctl(args.slice(1), options)) };
},
}));

import {
Expand All @@ -24,7 +30,15 @@ const bootedSimulator: DeviceInfo = {
booted: true,
};

afterEach(() => {
for (const [cmd, args] of commandShapes.mock.calls) {
assert.equal(cmd, 'xcrun');
assert.equal(args[0], 'simctl');
}
});

beforeEach(() => {
commandShapes.mockClear();
mockRunSimctl.mockReset();
mockRunSimctl.mockResolvedValue({
stdout: JSON.stringify({
Expand Down Expand Up @@ -127,7 +141,7 @@ test('findIosSimulatorInstalledApp does not probe a stopped simulator', async ()
});

function mockLaunchctlAndListapps(runningBundleIds: string[]): void {
mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => {
mockRunSimctl.mockImplementation(async (args: string[]) => {
if (args[0] === 'spawn') {
const lines = runningBundleIds.map(
(bundleId, index) => `${1000 + index}\t0\tUIKitApplication:${bundleId}[abcd][rb-legacy]`,
Expand Down Expand Up @@ -173,7 +187,7 @@ test('detectSoleRunningIosSimulatorApp returns undefined when nothing is running
});

test('detectSoleRunningIosSimulatorApp treats a failed probe as inconclusive', async () => {
mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => {
mockRunSimctl.mockImplementation(async (args: string[]) => {
if (args[0] === 'spawn') return { exitCode: 1, stdout: '' };
return {
stdout: JSON.stringify({ 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' } }),
Expand All @@ -184,7 +198,7 @@ test('detectSoleRunningIosSimulatorApp treats a failed probe as inconclusive', a
});

test('detectSoleRunningIosSimulatorApp catches a rejecting launchctl probe (timeout/spawn failure)', async () => {
mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => {
mockRunSimctl.mockImplementation(async (args: string[]) => {
if (args[0] === 'spawn') throw new Error('xcrun timed out after 3000ms');
return {
stdout: JSON.stringify({ 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' } }),
Expand All @@ -195,7 +209,7 @@ test('detectSoleRunningIosSimulatorApp catches a rejecting launchctl probe (time
});

test('detectSoleRunningIosSimulatorApp catches a rejecting listapps probe (timeout/spawn failure)', async () => {
mockRunSimctl.mockImplementation(async (_device: DeviceInfo, args: string[]) => {
mockRunSimctl.mockImplementation(async (args: string[]) => {
if (args[0] === 'spawn') {
return {
exitCode: 0,
Expand All @@ -214,9 +228,9 @@ test('detectSoleRunningIosSimulatorApp bounds the listapps probe with a timeout'

await detectSoleRunningIosSimulatorApp(bootedSimulator);

const listappsCall = mockRunSimctl.mock.calls.find(([, args]) => args[0] === 'listapps');
assert.equal(typeof listappsCall?.[2]?.timeoutMs, 'number');
assert.ok((listappsCall?.[2]?.timeoutMs ?? 0) > 0);
const listappsCall = mockRunSimctl.mock.calls.find(([args]) => args[0] === 'listapps');
assert.equal(typeof listappsCall?.[1]?.timeoutMs, 'number');
assert.ok((listappsCall?.[1]?.timeoutMs ?? 0) > 0);
});

test('detectSoleRunningIosSimulatorApp does not probe a stopped simulator', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { beforeEach, describe, test, vi } from 'vitest';
import assert from 'node:assert/strict';

vi.mock('../simctl.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../simctl.ts')>();
vi.mock('../simctl-execution.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../simctl-execution.ts')>();
return {
...actual,
runSimctlForDevice: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })),
Expand Down Expand Up @@ -49,7 +49,7 @@ import {
import { AppError } from '@agent-device/kernel/errors';
import { runXcrun } from '../tool-provider.ts';
import { readHostTextFile } from '@agent-device/host-kit/host-file';
import { runSimctlForDevice } from '../simctl.ts';
import { runSimctlForDevice } from '../simctl-execution.ts';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { ensureFoldHelperBinary } from '../../foldable/fold-helper-cache.ts';
import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts';
Expand Down
48 changes: 29 additions & 19 deletions packages/platform-apple/src/core/__tests__/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
captureSimulatorScreenshotWithRetry,
captureScreenshotViaRunner,
screenshotIos,
resolveSimulatorRunnerScreenshotCandidatePaths,
resolveRunnerScreenshotFilePaths,
shouldRetryIosSimulatorScreenshot,
} from '../screenshot.ts';
import { ensureBootedSimulator, openIosSimulatorApp } from '../simulator.ts';
Expand All @@ -64,6 +64,7 @@ import { runCmd } from '@agent-device/host-kit/command';
import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics';
import { retryWithPolicy } from '@agent-device/host-kit/retry';
import { AppError } from '@agent-device/kernel/errors';
import { resolveAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts';

import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts';

Expand Down Expand Up @@ -491,28 +492,18 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d
}
});

test('resolveSimulatorRunnerScreenshotCandidatePaths includes tmp-based and basename fallbacks', () => {
const containerPath = '/tmp/container';
const candidates = resolveSimulatorRunnerScreenshotCandidatePaths(
containerPath,
'/var/mobile/Containers/Data/Application/abc/tmp/screenshot-1.png',
);
assert.equal(candidates.includes(path.join(containerPath, 'tmp', 'screenshot-1.png')), true);
assert.equal(
candidates.includes('/var/mobile/Containers/Data/Application/abc/tmp/screenshot-1.png'),
true,
);
test('resolveRunnerScreenshotFilePaths preserves the runner path and handles legacy absolute paths', () => {
assert.deepEqual(resolveRunnerScreenshotFilePaths('/private/var/mobile/tmp/fallback.png'), [
'/private/var/mobile/tmp/fallback.png',
'tmp/fallback.png',
]);
assert.deepEqual(resolveRunnerScreenshotFilePaths('tmp/current.png'), ['tmp/current.png']);
});

test('resolveSimulatorRunnerScreenshotCandidatePaths handles empty runner path', () => {
assert.deepEqual(resolveSimulatorRunnerScreenshotCandidatePaths('/tmp/container', ' '), []);
test('resolveRunnerScreenshotFilePaths handles an empty runner path', () => {
assert.deepEqual(resolveRunnerScreenshotFilePaths(' '), []);
});

// Production `simctl` answers an installed app that owns no data container with exit 0 and the
// literal `(null)` on stdout. The runner container loop reads that as the tool's "no container"
// answer — the same rule clear-app-state refuses on — and never hands the sentinel to the host
// filesystem as a path. Without the shared classifier, the thrown wrap below carries a copy ENOENT
// from a candidate path built on `(null)` instead.
test('captureScreenshotViaRunner treats a (null) data-container answer as no container', async () => {
const tmpDir = await mkdtempForTest('agent-device-runner-null-container-');
const device = { ...IOS_TEST_SIMULATOR, id: 'sim-runner-null-container' };
Expand Down Expand Up @@ -897,3 +888,22 @@ test.each([MACOS_TEST_DEVICE, { ...IOS_TEST_SIMULATOR, kind: 'device' as const }
assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0);
},
);

test('screenshotIos rejects a screen mask before a provider-owned runner capture', async () => {
mockRunCmd.mockRejectedValue(new Error('unexpected native capture'));
mockRunAppleRunnerCommand.mockRejectedValue(new Error('unexpected runner capture'));
await withAppleToolProvider(
{ ...resolveAppleToolProvider(), simulatorScreenshotBackend: 'runner' },
async () => {
await assert.rejects(
screenshotIos(IOS_TEST_SIMULATOR, '/tmp/masked.png', { env: {}, screenMask: 'black' }),
{
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'screenshot-screen-mask-unsupported' },
},
);
},
);
assert.equal(mockRunCmd.mock.calls.length, 0);
assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0);
});
2 changes: 1 addition & 1 deletion packages/platform-apple/src/core/app-device-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isMacOs, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/dev
import { requireExecSuccess } from '@agent-device/host-kit/command';
import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts';
import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts';
import { runSimctlForDevice } from './simctl.ts';
import { runSimctlForDevice } from './simctl-execution.ts';
import { runAppleRunnerCommand } from './runner-client.ts';
import type { AppleRunnerCommandOptions } from '@agent-device/apple-runner';

Expand Down
Loading
Loading