diff --git a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts new file mode 100644 index 0000000000..8d97edbe5a --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -0,0 +1,143 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runCmdSync } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; +import { prepareIosInstallArtifact } from '../install-artifact.ts'; +import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; + +/** + * An IPA whose `Payload` holds more than one `.app` is legal on disk and ambiguous for the + * installer: `appIdentifierHint` is how the caller breaks the tie. The hint matches a bundle by + * name first and by bundle id only when it is dotted, so these cases run the real extraction over + * real multi-payload IPAs and pin name/id resolution plus the unmatched-hint and no-hint errors. + * (Two bundles whose names differ only by case, which would make the hint itself ambiguous, cannot + * be built on a case-insensitive host filesystem and stay unpinned.) + */ +test('prepareIosInstallArtifact resolves a multi-payload IPA by a bundle-name hint', async () => { + await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { + const prepared = await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { appIdentifierHint: 'beta' }, + ); + try { + assert.equal(path.basename(prepared.installablePath), 'Beta.app'); + assert.equal(prepared.bundleId, 'com.example.beta'); + assert.equal(prepared.appName, 'Beta'); + } finally { + await prepared.cleanup(); + } + }); +}); + +test('prepareIosInstallArtifact resolves a multi-payload IPA by a dotted bundle-id hint', async () => { + await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { + const prepared = await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { appIdentifierHint: 'COM.Example.Alpha' }, + ); + try { + assert.equal(path.basename(prepared.installablePath), 'Alpha.app'); + assert.equal(prepared.bundleId, 'com.example.alpha'); + } finally { + await prepared.cleanup(); + } + }); +}); + +test('prepareIosInstallArtifact lists the available bundles when a dotted hint matches none', async () => { + // A hint that names neither a payload directory nor a bundle id cannot resolve, and the error + // has to enumerate what *is* available so the caller can pick one. (The ambiguous-name throw + // above this branch needs two payloads whose names differ only by case, which a case-insensitive + // host filesystem cannot hold.) + await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { + await assert.rejects( + async () => + await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { appIdentifierHint: 'com.example.gamma' }, + ), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /none matched "com\.example\.gamma"/.test(error.message) && + error.message.includes('Alpha.app (com.example.alpha)') && + error.message.includes('Beta.app (com.example.beta)'), + ); + }); +}); + +test('prepareIosInstallArtifact lists the available bundles when a hint-free multi-payload IPA fails', async () => { + await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { + await assert.rejects( + async () => await prepareIosInstallArtifact({ kind: 'path', path: ipaPath }), + /Pass an app identifier or bundle name matching one of: Alpha\.app \(com\.example\.alpha\), Beta\.app \(com\.example\.beta\)/, + ); + }); +}); + +test('prepareIosInstallArtifact keeps a non-dotted hint a name-only match', async () => { + // The bundle-id rule is reserved for dotted hints, so a plain word never resolves a bundle whose + // id is an undotted slug; only its directory name can. Otherwise two bundles could silently + // collide on a fragment of one id. + await withMultiPayloadIpa( + [{ name: 'Alpha' }, { name: 'Beta', bundleId: 'beta-app' }], + async (ipaPath) => { + await assert.rejects( + async () => + await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { appIdentifierHint: 'beta-app' }, + ), + /none matched "beta-app"/, + ); + }, + ); +}); + +type PayloadBundleFixture = { name: string; bundleId?: string }; + +/** + * Builds a real zip-format IPA carrying the given `Payload/*.app` bundles and runs `run` against + * it. Info.plist reads go through a fake tool provider keyed by the bundle's own directory, so + * each payload reports an identity of its own without plist tooling on the host. + */ +async function withMultiPayloadIpa( + bundles: PayloadBundleFixture[], + run: (ipaPath: string) => Promise, +): Promise { + const root = await mkdtempForTest('agent-device-ios-multi-payload-'); + const staging = path.join(root, 'staging'); + for (const bundle of bundles) { + const appDir = path.join(staging, 'Payload', `${bundle.name}.app`); + await fs.mkdir(appDir, { recursive: true }); + await fs.writeFile( + path.join(appDir, 'Info.plist'), + '', + ); + } + const ipaPath = path.join(root, 'multi.ipa'); + runCmdSync('zip', ['-qr', ipaPath, '.'], { cwd: staging }); + + const provider = createLocalAppleToolProvider({ + plist: { + readJson: async (plistPath) => { + const appName = path.basename(path.dirname(plistPath)).replace(/\.app$/i, ''); + const bundle = bundles.find((candidate) => candidate.name === appName); + return bundle + ? { + CFBundleIdentifier: bundle.bundleId ?? `com.example.${appName.toLowerCase()}`, + CFBundleDisplayName: appName, + } + : null; + }, + }, + }); + try { + await withAppleToolProvider(provider, async () => await run(ipaPath)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/platform-apple/src/core/__tests__/launch-diagnostics.test.ts b/packages/platform-apple/src/core/__tests__/launch-diagnostics.test.ts index 12c5c59a80..db31d580b8 100644 --- a/packages/platform-apple/src/core/__tests__/launch-diagnostics.test.ts +++ b/packages/platform-apple/src/core/__tests__/launch-diagnostics.test.ts @@ -4,8 +4,11 @@ import { isSimulatorLaunchFBSError, classifyLaunchFailure, launchFailureHint, + probeSimulatorLaunchContext, } from '../launch-diagnostics.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { IOS_SIMULATOR } from '../../__tests__/device-fixtures.ts'; +import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fake-apple-tool.ts'; test('isSimulatorLaunchFBSError identifies FBS code=4 errors', () => { const error = new AppError('COMMAND_FAILED', 'xcrun exited with code 4', { @@ -88,3 +91,85 @@ test('launchFailureHint returns actionable string for UNKNOWN', () => { const hint = launchFailureHint('UNKNOWN'); assert.ok(hint.length > 0); }); + +/** + * `probeSimulatorLaunchContext` answers why an FBS launch refusal happened by asking three tools: + * `simctl get_app_container` for the installed bundle, `plutil` for its executable name, and + * `otool` for the platform marker the binary carries. Each refusal to answer narrows the diagnosis + * one step and stops there; a probe that inferred past a missing answer is what the tiered + * early-returns prevent, so every tier is pinned at its own boundary. + */ +test('probeSimulatorLaunchContext reports a missing container as not installed', async () => { + const probe = await probeLaunchContext(() => ({ exitCode: 4, stderr: 'container not found' })); + assert.deepEqual(probe, { installed: false }); +}); + +test('probeSimulatorLaunchContext reports a successful but empty container lookup as not installed', async () => { + const probe = await probeLaunchContext(() => ' '); + assert.deepEqual(probe, { installed: false }); +}); + +test('probeSimulatorLaunchContext reports installed when the Info.plist has no executable name', async () => { + // An unreadable Info.plist proves installation but says nothing about the platform, so the + // probe stops with `installed: true` and no compatibility claim. + for (const plistAnswer of [{ exitCode: 1 }, { exitCode: 0, stdout: '' }]) { + const probe = await probeLaunchContext((args) => { + if (args[0] === 'plutil') return plistAnswer; + return '/Bundle'; + }); + assert.deepEqual(probe, { installed: true }); + } +}); + +test('probeSimulatorLaunchContext reports installed when otool cannot read the binary', async () => { + const probe = await probeLaunchContext((args) => { + if (args[0] === 'otool') return { exitCode: 1 }; + return '/Bundle'; + }); + assert.deepEqual(probe, { installed: true }); +}); + +test('probeSimulatorLaunchContext reads the simulator platform marker from otool', async () => { + const probe = await probeLaunchContext(otoolScript('Platform iOSSimulator, iOS, Version 17.0')); + assert.deepEqual(probe, { installed: true, simulatorCompatible: true }); + + // LC_BUILD_VERSION spells the simulator platform both ways across toolchain versions. + assert.deepEqual(await probeLaunchContext(otoolScript('platform 7')), { + installed: true, + simulatorCompatible: true, + }); +}); + +test('probeSimulatorLaunchContext reports a device binary as not simulator compatible', async () => { + const probe = await probeLaunchContext(otoolScript('Platform iOS, Version 17.0')); + assert.deepEqual(probe, { installed: true, simulatorCompatible: false }); +}); + +test('probeSimulatorLaunchContext scopes the container lookup to the simulator', async () => { + await withFakeAppleTool( + () => '', + async ({ calls }) => { + await probeSimulatorLaunchContext(IOS_SIMULATOR, 'com.example.app'); + assert.deepEqual(calls[0], [ + 'simctl', + 'get_app_container', + IOS_SIMULATOR.id, + 'com.example.app', + ]); + }, + { device: IOS_SIMULATOR }, + ); +}); + +/** A script where everything up to `otool` answers with the bundle path and `otool` answers itself. */ +function otoolScript(otoolStdout: string): FakeAppleToolScript { + return (args) => (args[0] === 'otool' ? otoolStdout : '/Path/To/Demo.app'); +} + +async function probeLaunchContext(script: FakeAppleToolScript) { + return await withFakeAppleTool( + script, + async () => await probeSimulatorLaunchContext(IOS_SIMULATOR, 'com.example.app'), + { device: IOS_SIMULATOR }, + ); +} diff --git a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts index 9a44a95e34..b71aafaa33 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts @@ -5,12 +5,15 @@ import { test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { IOS_DEVICECTL_MIN_TIMEOUT_SECONDS } from '../config.ts'; import { + ensureCoreDeviceReady, launchCoreDeviceApp, parseIosDeviceDetailsPayload, readIosDeviceReadiness, resolveIosReadyHint, type IosDeviceReadiness, } from '../physical-device-coredevice.ts'; +import { IOS_DEVICE_READY_TIMEOUT_MS } from '../physical-device-constants.ts'; +import { AppError } from '@agent-device/kernel/errors'; import { IOS_DEVICE_DEVELOPER_DISK_IMAGE_HINT, IOS_DEVICE_DEVELOPER_MODE_OFF_HINT, @@ -564,6 +567,150 @@ async function readDeviceDetails( ); } +/** + * `ensureCoreDeviceReady` asks the same details command as a timed probe and turns every way it can + * fail into a typed COMMAND_FAILED with an actionable hint. The shapes below pin each normalizer + * branch at the boundary that reaches it: a pass-through verdict, a tool failure, a killed timeout, + * a successful run with unreadable JSON, and a failure that is not a probe at all. + */ +test('ensureCoreDeviceReady passes a connected device through', async () => { + await withCoreDeviceProbe( + () => readyPayload('connected'), + async () => { + await ensureCoreDeviceReady(IOS_DEVICE); + }, + ); +}); + +test('ensureCoreDeviceReady names the connecting tunnel state as the reason it refuses', async () => { + const error = await expectReadyFailure(() => readyPayload('connecting')); + assert.equal(error.details?.kind, 'not_ready'); + assert.equal(error.details?.tunnelState, 'connecting'); + assert.match(error.message, /not ready for automation/i); +}); + +test('ensureCoreDeviceReady reports a failed details command with the device hint', async () => { + const error = await expectReadyFailure(() => ({ + exitCode: 1, + stderr: 'ERROR: The device could not be contacted.', + })); + // The probe failed with a tool complaint, so the hint is the guidance for that text, not the + // did-not-respond guidance a killed probe gets. + assert.match(String(error.details?.hint), /trusted/i); + assert.doesNotMatch(String(error.details?.hint), /did not respond/); + assert.match(String(error.details?.stderr), /could not be contacted/); +}); + +test('ensureCoreDeviceReady names the developer mode hint a failing probe reports', async () => { + // The hint on a failed probe comes from the tool's own text, and the two device failure states + // keep separate wordings (#2683). + const error = await expectReadyFailure(() => ({ + exitCode: 1, + stderr: 'The operation failed because Developer Mode is disabled.', + })); + assert.match(String(error.details?.hint), /Enable Developer Mode/i); +}); + +test('ensureCoreDeviceReady turns a killed probe into the timeout hint', async () => { + // exec's timeout error carries its own stdout/stderr/timeoutMs; with no output to read the + // hint is the did-not-respond guidance naming the budget the probe ran under. + const error = await expectReadyFailure(() => { + throw new AppError('COMMAND_FAILED', 'xcrun timed out after 12345ms', { + timeoutMs: 12345, + stdout: '', + stderr: '', + }); + }); + assert.match(String(error.details?.hint), /did not respond within 12345ms/); + assert.equal(error.details?.timeoutMs, 12345); +}); + +test('ensureCoreDeviceReady defaults the timeout hint to the ready budget when the error reports none', async () => { + const error = await expectReadyFailure(() => { + throw new AppError('COMMAND_FAILED', 'xcrun was killed', { stdout: '', stderr: '' }); + }); + assert.match(String(error.details?.hint), new RegExp(`within ${IOS_DEVICE_READY_TIMEOUT_MS}ms`)); +}); + +test('ensureCoreDeviceReady reports a successful run without readable JSON as inconclusive', async () => { + // The inner probe error says the JSON was invalid, but the normalizer rebuilds the hint from the + // tool's own output, so what survives is the raw stdout and the guidance derived from it — not + // the did-not-respond guidance reserved for a probe that never answered. + const error = await expectReadyFailure(() => ({ stdout: 'not json at all' })); + assert.equal(error.message, 'iOS device readiness probe failed'); + assert.equal(error.details?.stdout, 'not json at all'); + assert.match(String(error.details?.hint), /Xcode/i); + assert.doesNotMatch(String(error.details?.hint), /did not respond/); +}); + +test('ensureCoreDeviceReady normalizes a failure that is not a command failure', async () => { + // A provider that cannot run at all is not a verdict about the device: the error keeps its + // cause but becomes the probe-failure shape with the reconnect hint. + const cause = new AppError('INVALID_ARGS', 'simulator-set scope is required'); + const error = await expectReadyFailure(() => { + throw cause; + }); + assert.equal(error.message, 'iOS device readiness probe failed'); + assert.match(String(error.details?.hint), /reconnect/i); + assert.equal(error.cause, cause); +}); + +test('ensureCoreDeviceReady keeps a plain error as the probe-failure cause', async () => { + const cause = new Error('boom'); + const error = await expectReadyFailure(() => { + throw cause; + }); + assert.equal(error.cause, cause); +}); + +async function expectReadyFailure(answer: ReadyProbeAnswer): Promise { + let failure: unknown; + try { + await withCoreDeviceProbe(answer, async () => { + await ensureCoreDeviceReady(IOS_DEVICE); + }); + } catch (error) { + failure = error; + } + assert.ok(failure instanceof AppError, 'expected ensureCoreDeviceReady to fail'); + assert.equal(failure.code, 'COMMAND_FAILED'); + return failure; +} + +function readyPayload(tunnelState: string): { exitCode: number; json: unknown } { + return { + exitCode: 0, + json: { + info: { outcome: 'success' }, + result: { connectionProperties: { tunnelState } }, + }, + }; +} + +type ReadyProbeAnswer = ( + args: string[], +) => { exitCode?: number; stdout?: string; stderr?: string; json?: unknown } | Error; + +/** + * One details-probe session with a scripted `xcrun devicectl` answer. A `json` key is written to + * the `--json-output` path the probe asked for, exactly as devicectl does. + */ +async function withCoreDeviceProbe(answer: ReadyProbeAnswer, run: () => Promise): Promise { + return await withAppleToolProvider( + createLocalAppleToolProvider({ + runCommand: async (_cmd, args) => { + const response = answer(args); + if (response instanceof Error) throw response; + const { exitCode = 0, stdout = '', stderr = '', json } = response; + const outputPath = jsonOutputPath(args); + if (json !== undefined && outputPath) fs.writeFileSync(outputPath, JSON.stringify(json)); + return { exitCode, stdout, stderr }; + }, + }), + run, + ); +} + function jsonOutputPath(args: string[]): string | undefined { const index = args.indexOf('--json-output'); return index >= 0 ? args[index + 1] : undefined; diff --git a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts new file mode 100644 index 0000000000..31e15463c0 --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts @@ -0,0 +1,116 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { copyCoreDeviceRunnerFile } from '../physical-device-files.ts'; +import { IOS_DEVICE } from '../../__tests__/device-fixtures.ts'; +import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fake-apple-tool.ts'; + +/** + * `copyCoreDeviceRunnerFile` copies one file out of the runner's app data container. The container + * is owned by whichever bundle id Xcode happened to install the runner under, so the copy tries the + * candidate bundle ids in order and reports only what the last one said. The shapes below pin the + * retry boundary (one success stops the loop) and each source of the reported message. + */ +test('copyCoreDeviceRunnerFile copies with the app data container domain', async () => { + const calls = await runCopy(() => ''); + assert.equal(calls.length, 1); + const args = calls[0]!; + assert.deepEqual(args.slice(0, 4), ['devicectl', 'device', 'copy', 'from']); + assert.equal(args[args.indexOf('--source') + 1], '/tmp/shot.png'); + assert.equal(args[args.indexOf('--destination') + 1], '/out/shot.png'); + assert.equal(args[args.indexOf('--domain-type') + 1], 'appDataContainer'); +}); + +test('copyCoreDeviceRunnerFile retries the next runner container and stops at the one that answers', async () => { + // The container is owned by whichever bundle id the runner was installed under, so a miss must + // move to the next candidate rather than fail the copy, and the hit must stop the loop there. + let attempts = 0; + const calls = await runCopy(() => { + attempts += 1; + return attempts === 1 ? { exitCode: 1, stderr: 'no such container' } : ''; + }); + assert.equal(calls.length, 2); + assert.notEqual(domainIdentifier(calls[0]!), domainIdentifier(calls[1]!)); +}); + +test('copyCoreDeviceRunnerFile reports the last container error', async () => { + // Distinct per-attempt errors: reporting an earlier candidate's failure instead of the final + // one is the regression this pins, so the message must carry the last attempt's text only. + const calls: string[][] = []; + await assert.rejects( + async () => + await withFakeAppleTool( + (args) => { + calls.push(args); + return { exitCode: 1, stderr: `container miss ${calls.length}` }; + }, + async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'), + { device: IOS_DEVICE }, + ), + (error: unknown) => { + const message = error instanceof Error ? error.message : ''; + // Every candidate container is tried, and the report names the last one alone. + assert.ok(calls.length >= 2, 'every candidate container must be tried'); + assert.ok(domainIdentifier(calls[0]!) !== domainIdentifier(calls[1]!)); + assert.ok(message.endsWith(`container miss ${calls.length}`), message); + return true; + }, + ); +}); + +test('copyCoreDeviceRunnerFile falls back to stdout when stderr is empty', async () => { + await assertCopyFailure( + () => ({ exitCode: 1, stdout: ' container unavailable ' }), + /Failed to copy runner file from iOS device: container unavailable/, + ); +}); + +test('copyCoreDeviceRunnerFile names the exit code when the copy said nothing', async () => { + await assertCopyFailure( + () => ({ exitCode: 3, stdout: '', stderr: '' }), + /Failed to copy runner file from iOS device: devicectl exited with code 3/, + ); +}); + +test('copyCoreDeviceRunnerFile refuses to start once the deadline is spent', async () => { + const calls: string[][] = []; + await assert.rejects( + async () => + await withFakeAppleTool( + (args) => { + calls.push(args); + return ''; + }, + async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', 0), + { device: IOS_DEVICE }, + ), + /iOS runner file copy timed out after 0ms/, + ); + assert.deepEqual(calls, []); +}); + +function domainIdentifier(args: string[]): string { + return args[args.indexOf('--domain-identifier') + 1] ?? ''; +} + +async function runCopy(script: FakeAppleToolScript): Promise { + return await withFakeAppleTool( + script, + async ({ calls }) => { + await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'); + return calls; + }, + { device: IOS_DEVICE }, + ); +} + +async function assertCopyFailure(script: FakeAppleToolScript, message: RegExp): Promise { + await assert.rejects( + async () => + await withFakeAppleTool( + script, + async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'), + { device: IOS_DEVICE }, + ), + message, + ); +}