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
143 changes: 143 additions & 0 deletions packages/platform-apple/src/core/__tests__/install-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
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'),
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></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 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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 },
);
}
Loading
Loading