diff --git a/.fallowrc.json b/.fallowrc.json
index 7c3e938e4d..3463937209 100644
--- a/.fallowrc.json
+++ b/.fallowrc.json
@@ -19,6 +19,8 @@
"src/daemon.ts",
"packages/capture-kit/src/png-worker.ts",
"scripts/patch-xcuitest-runner-icon.ts",
+ "scripts/write-xcuitest-cache-metadata.ts",
+ "scripts/xcuitest-build-settings.ts",
"packages/capture-kit/src/ios-snapshot-engine/replay.ts",
// #1596 regression fixture: runs as a real `node --experimental-strip-types`
// subprocess (test/integration/daemon-replace-exit-flush.test.ts), so
@@ -41,7 +43,6 @@
"scripts/di-seams/**",
"scripts/maestro-conformance/corpus/**",
"apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan",
- "scripts/write-xcuitest-cache-metadata.mjs",
"scripts/help-conformance-sample-outputs.d.mts",
"scripts/help-conformance-runner-output.d.mts"
],
diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml
index fedd064796..e637ad3bc2 100644
--- a/.github/actions/setup-apple-runner-build/action.yml
+++ b/.github/actions/setup-apple-runner-build/action.yml
@@ -16,13 +16,11 @@ inputs:
description: 'Registered check id built through `pnpm gate`'
required: true
xcuitest-platform:
- description: 'Optional AGENT_DEVICE_XCUITEST_PLATFORM value'
- required: false
- default: ''
+ description: 'AGENT_DEVICE_XCUITEST_PLATFORM value the runner build and its cache metadata are keyed by'
+ required: true
xcuitest-destination:
- description: 'Optional AGENT_DEVICE_XCUITEST_DESTINATION value'
- required: false
- default: ''
+ description: 'AGENT_DEVICE_XCUITEST_DESTINATION value, which must carry a platform= token'
+ required: true
outputs:
cache-hit:
description: 'Whether an exact Apple runner build cache was restored'
@@ -108,6 +106,17 @@ runs:
shell: bash
run: node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "${{ inputs.derived-path }}"
+ # The icon patch rewrites product bytes after the cache was saved unpatched, so the
+ # content manifest on this machine must certify the tree that will actually run.
+ - name: Re-publish Apple runner cache metadata
+ shell: bash
+ run: |
+ node --experimental-strip-types scripts/write-xcuitest-cache-metadata.ts \
+ "${{ inputs.xcuitest-platform }}" \
+ "${{ inputs.derived-path }}" \
+ "${{ inputs.xcuitest-destination }}" \
+ "${{ inputs.derived-path }}/Logs/agent-device-build-for-testing.log"
+
- name: Report Apple runner build cache
env:
CACHE_HIT: ${{ steps.restore-runner-build.outputs.cache-hit }}
diff --git a/packages/contracts/src/prepare.ts b/packages/contracts/src/prepare.ts
index 0749af9936..64d37b1e99 100644
--- a/packages/contracts/src/prepare.ts
+++ b/packages/contracts/src/prepare.ts
@@ -1,7 +1,7 @@
import type { DeviceKind, PublicPlatform } from '@agent-device/kernel/device';
import type { JsonObject } from './json.ts';
-export type PrepareIosRunnerCacheKind = 'exact' | 'restore-key' | 'miss' | 'external';
+export type PrepareIosRunnerCacheKind = 'exact' | 'miss' | 'external';
export type PrepareIosRunnerArtifactState = 'valid' | 'rebuilt';
export type PrepareIosRunnerTiming = {
diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts
index f084648476..5aff792e8d 100644
--- a/packages/platform-apple/src/core/runner-client.ts
+++ b/packages/platform-apple/src/core/runner-client.ts
@@ -38,3 +38,22 @@ export {
hasCachedAppleRunnerArtifact,
resolveRunnerAppBundleId,
} from '../runner/runner-xctestrun.ts';
+export { findXctestrun as findRunnerXctestrun } from '../runner/runner-artifact.ts';
+export { resolveExistingXctestrunProductPaths as resolveExistingRunnerProductPaths } from '../runner/runner-xctestrun-products.ts';
+export {
+ requireRunnerBuildSettingsMatchBuildLog,
+ resolveExpectedRunnerCacheMetadata,
+ resolveRunnerArchBuildSettings,
+ resolveRunnerBundleBuildSettings,
+ resolveRunnerPerformanceBuildSettings,
+ resolveRunnerSandboxBuildArgs,
+ resolveRunnerSigningBuildSettings,
+} from '../runner/runner-cache-metadata.ts';
+export {
+ isRunnerXcuitestScriptPlatform,
+ resolveRunnerScriptDevice,
+} from '../runner/apple-runner-platform.ts';
+export {
+ requireCertifiedRunnerCacheArtifacts,
+ writeRunnerCacheMetadataForArtifacts,
+} from '../runner/runner-cache.ts';
diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts
index 3cfb64fc2a..eb5d1f999f 100644
--- a/packages/platform-apple/src/runner-operations-facade.ts
+++ b/packages/platform-apple/src/runner-operations-facade.ts
@@ -1,6 +1,7 @@
export {
applyXctestRunnerAppIconFromDerivedPath,
detachIosRunnerSessionsForShutdown,
+ findRunnerXctestrun,
hasLiveIosRunnerSession,
notifyIosRunnerAppRelaunched,
prepareIosRunner,
@@ -10,11 +11,23 @@ export {
readStaleRunnerLease,
releaseIosRunnerOnClose,
releaseSpeculativeIosRunnerSessionFor,
+ resolveExistingRunnerProductPaths,
+ resolveExpectedRunnerCacheMetadata,
resolveRunnerAppBundleId,
+ resolveRunnerArchBuildSettings,
+ resolveRunnerBundleBuildSettings,
+ resolveRunnerPerformanceBuildSettings,
+ resolveRunnerSandboxBuildArgs,
+ isRunnerXcuitestScriptPlatform,
+ resolveRunnerScriptDevice,
+ resolveRunnerSigningBuildSettings,
+ requireCertifiedRunnerCacheArtifacts,
+ requireRunnerBuildSettingsMatchBuildLog,
runAppleRunnerCommand,
stopAllIosRunnerSessions,
stopIosRunnerSession,
verifyLeaseRunnerPidIdentity,
+ writeRunnerCacheMetadataForArtifacts,
} from './core/runner-client.ts';
export { queryAppleRunnerSelector } from './core/runner-selector-query.ts';
export { restoreLegacyXctestDeviceSetRedirect } from './runner/runner-device-set.ts';
diff --git a/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts b/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts
index 537f300afa..de95f834c1 100644
--- a/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts
@@ -1,7 +1,9 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
+ isRunnerXcuitestScriptPlatform,
resolveRunnerDestination,
+ resolveRunnerScriptDevice,
resolveRunnerHandoffTarget,
resolveRunnerPlatformName,
resolveRunnerSdkName,
@@ -168,6 +170,47 @@ test('the usbmux-only xctest backend is refused while coredevice is named explic
});
});
+test('resolveRunnerScriptDevice reads the simulator kind from the destination platform token', () => {
+ const udid = '5AF10197-87C1-4799-835E-3C6CBF9F3163';
+
+ assert.equal(
+ resolveRunnerScriptDevice('ios', `platform=iOS Simulator,id=${udid}`).kind,
+ 'simulator',
+ );
+ // xcodebuild matches the platform token case-insensitively, so a lowercase spelling still
+ // builds the simulator SDK and must not be certified as a physical-device runner.
+ assert.equal(
+ resolveRunnerScriptDevice('ios', `platform=iOS simulator,id=${udid}`).kind,
+ 'simulator',
+ );
+ assert.equal(
+ resolveRunnerScriptDevice('ios', 'generic/platform=iOS Simulator').kind,
+ 'simulator',
+ );
+ assert.equal(resolveRunnerScriptDevice('ios', 'generic/platform=iOS').kind, 'device');
+ assert.equal(resolveRunnerScriptDevice('tvos', 'platform=tvOS Simulator,id=x').kind, 'simulator');
+});
+
+test('resolveRunnerScriptDevice records a macOS build as the host device', () => {
+ assert.equal(resolveRunnerScriptDevice('macos', 'platform=macOS,arch=arm64').kind, 'device');
+ assert.equal(resolveRunnerScriptDevice('macos', 'platform=macOS,arch=arm64').target, 'desktop');
+});
+
+test('resolveRunnerScriptDevice refuses a destination that leaves the SDK to the scheme', () => {
+ assert.throws(
+ () => resolveRunnerScriptDevice('ios', 'id=5AF10197-87C1-4799-835E-3C6CBF9F3163'),
+ /must name its platform/,
+ );
+});
+
+test('isRunnerXcuitestScriptPlatform accepts only the platforms the build script knows', () => {
+ assert.equal(isRunnerXcuitestScriptPlatform('ios'), true);
+ assert.equal(isRunnerXcuitestScriptPlatform('visionos'), true);
+ assert.equal(isRunnerXcuitestScriptPlatform('watchos'), false);
+ assert.equal(isRunnerXcuitestScriptPlatform('iOS'), false);
+ assert.equal(isRunnerXcuitestScriptPlatform(''), false);
+});
+
test('a non-Apple target is refused instead of defaulting into the physical lane', () => {
assert.deepEqual(
resolveRunnerHandoffTarget({
diff --git a/packages/platform-apple/src/runner/__tests__/digest-file.ts b/packages/platform-apple/src/runner/__tests__/digest-file.ts
new file mode 100644
index 0000000000..853a603404
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/digest-file.ts
@@ -0,0 +1,7 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+
+/** sha256 of a file's bytes, the same digest a cache manifest records. */
+export function digestFile(filePath: string): string {
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
+}
diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts
new file mode 100644
index 0000000000..421538be97
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts
@@ -0,0 +1,500 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { onTestFinished, test } from 'vitest';
+import {
+ evaluateExistingXctestrun,
+ resolveRunnerCacheMetadataPath,
+ writeRunnerCacheMetadata,
+ writeRunnerCacheMetadataForArtifacts,
+} from '../runner-cache.ts';
+import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
+import { IOS_SIMULATOR } from './device-fixtures.ts';
+import { mkdtempForTestSync } from './tmp-dir.ts';
+import {
+ EXECUTABLE_BYTES,
+ makeCachedRunnerBuild,
+ mismatchOf,
+ publishedXctestrun,
+} from './runner-cache.fixtures.ts';
+import { digestFile } from './digest-file.ts';
+
+test('a manifest-certified build is reused', async () => {
+ const { derived, xctestrunPath, executablePath, expected } = await makeCachedRunnerBuild();
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'reuse_ready');
+ assert.equal(state.reason === 'reuse_ready' ? state.xctestrunPath : null, xctestrunPath);
+ assert.deepEqual(state.reason === 'reuse_ready' ? state.productPaths : null, [
+ path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app'),
+ ]);
+ assert.ok(fs.existsSync(executablePath));
+});
+
+test('executable bytes rewritten with equal size and preserved stats break reuse', async () => {
+ const { derived, executablePath, expected } = await makeCachedRunnerBuild();
+ const stat = fs.statSync(executablePath);
+
+ fs.writeFileSync(executablePath, Buffer.alloc(EXECUTABLE_BYTES.length, 9), { mode: 0o755 });
+ fs.utimesSync(executablePath, stat.atime, stat.mtime);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.deepEqual(state.mismatch, {
+ path: path.relative(derived, executablePath),
+ reason: 'digest_mismatch',
+ });
+});
+
+test('a size change breaks reuse', async () => {
+ const { derived, executablePath, expected } = await makeCachedRunnerBuild();
+
+ fs.appendFileSync(executablePath, 'x');
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'size_changed');
+});
+
+test('a lost permission bit breaks reuse', async () => {
+ const { derived, executablePath, expected } = await makeCachedRunnerBuild();
+
+ fs.chmodSync(executablePath, 0o644);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'mode_changed');
+});
+
+test('a missing executable breaks reuse', async () => {
+ const { derived, executablePath, expected } = await makeCachedRunnerBuild();
+
+ fs.rmSync(executablePath);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'missing');
+});
+
+test('a certified executable replaced by a directory breaks reuse', async () => {
+ const { derived, executablePath, expected } = await makeCachedRunnerBuild();
+
+ fs.rmSync(executablePath);
+ fs.mkdirSync(executablePath);
+
+ // The manifest names a file at that path; the tree now holds no file there at all.
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'missing');
+});
+
+test('a file added under a certified product breaks reuse', async () => {
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+
+ fs.writeFileSync(path.join(runnerAppPath, 'injected.dylib'), 'injected');
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'undeclared_entry');
+});
+
+test('a certified symlink that starts escaping the cache root breaks reuse', async () => {
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true }));
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const linkPath = path.join(runnerAppPath, 'Frameworks');
+ fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true });
+ fs.symlinkSync('FrameworksInside', linkPath);
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [
+ runnerAppPath,
+ ]);
+
+ fs.rmSync(linkPath);
+ fs.symlinkSync(path.join(outside, 'evil'), linkPath);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'symlink_escapes_cache');
+ assert.equal(state.mismatch.target, path.join(outside, 'evil'));
+});
+
+test('a symlink that changes target but stays inside the cache is a target change', async () => {
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const linkPath = path.join(runnerAppPath, 'Frameworks');
+ fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true });
+ fs.mkdirSync(path.join(runnerAppPath, 'FrameworksMoved'), { recursive: true });
+ fs.symlinkSync('FrameworksInside', linkPath);
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [
+ runnerAppPath,
+ ]);
+
+ fs.rmSync(linkPath);
+ fs.symlinkSync('FrameworksMoved', linkPath);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'symlink_target_changed');
+});
+
+test('a product root that is a symlink out of the cache is never certified', async () => {
+ const derived = mkdtempForTestSync('agent-device-runner-cache-symlink-root-');
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => {
+ fs.rmSync(derived, { recursive: true, force: true });
+ fs.rmSync(outside, { recursive: true, force: true });
+ });
+ const realProduct = path.join(outside, 'Runner-Runner.app');
+ fs.mkdirSync(realProduct, { recursive: true });
+ fs.writeFileSync(path.join(realProduct, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 });
+ const productsPath = path.join(derived, 'Build', 'Products');
+ const linkedProduct = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app');
+ fs.mkdirSync(path.dirname(linkedProduct), { recursive: true });
+ fs.symlinkSync(realProduct, linkedProduct);
+ const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun');
+ fs.writeFileSync(xctestrunPath, 'xctestrun');
+ const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]);
+
+ const published = JSON.parse(
+ fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'),
+ ) as { artifacts?: unknown };
+ assert.equal(published.artifacts, undefined);
+});
+
+test('a symlinked product root that stays inside the cache is certified', async () => {
+ const derived = mkdtempForTestSync('agent-device-runner-cache-symlink-inside-');
+ onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true }));
+ const productsPath = path.join(derived, 'Build', 'Products');
+ const stagedProduct = path.join(derived, 'staged', 'Runner-Runner.app');
+ fs.mkdirSync(stagedProduct, { recursive: true });
+ fs.writeFileSync(path.join(stagedProduct, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 });
+ const linkedProduct = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app');
+ fs.mkdirSync(path.dirname(linkedProduct), { recursive: true });
+ fs.symlinkSync(path.join(derived, 'staged', 'Runner-Runner.app'), linkedProduct);
+ const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun');
+ fs.writeFileSync(xctestrunPath, 'xctestrun');
+ const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]);
+
+ // Names describe where the bytes actually live, not the link that reached them, so re-pointing
+ // the in-cache symlink at another bundle cannot make reuse read an uncertified tree.
+ const published = JSON.parse(
+ fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'),
+ ) as { artifacts?: { entries: Array<{ path: string }> } };
+ assert.deepEqual(
+ published.artifacts?.entries.map((entry) => entry.path),
+ [path.join('staged', 'Runner-Runner.app', 'Runner')],
+ );
+
+ fs.rmSync(linkedProduct);
+ const swapped = path.join(derived, 'staged', 'Other.app');
+ fs.mkdirSync(swapped, { recursive: true });
+ fs.writeFileSync(path.join(swapped, 'Runner'), Buffer.alloc(4096, 9), { mode: 0o755 });
+ fs.symlinkSync(swapped, linkedProduct);
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ // The walk follows the re-pointed root, and the bundle it now reaches was never certified.
+ assert.equal(state.mismatch.reason, 'undeclared_entry');
+});
+
+test('a direct product path beside a symlink alias of it certifies one walk', async () => {
+ // Xcode hands the .xctestrun a product per relative path; two spellings of one bundle (a
+ // direct path plus an in-cache alias) must collapse to one walked root, or the leaves get
+ // collected twice and the reader's walk calls the second root's copies undeclared.
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const aliasPath = path.join(path.dirname(runnerAppPath), 'Runner.alias.app');
+ fs.symlinkSync(path.basename(runnerAppPath), aliasPath);
+
+ assert.equal(
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [
+ runnerAppPath,
+ aliasPath,
+ ]),
+ null,
+ );
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'reuse_ready');
+});
+
+test('roots keep one walk whatever order their paths are spelled in', async () => {
+ // Descendant-first spelling must not survive as two roots: sorted-then-filtered is what makes
+ // the kept set identical to the alias-first spelling above, not the caller's ordering.
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const executablePath = path.join(runnerAppPath, 'Runner');
+ const productsPath = path.join(derived, 'Build', 'Products');
+
+ assert.equal(
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [
+ executablePath,
+ runnerAppPath,
+ path.join(productsPath, 'Debug-iphonesimulator'),
+ runnerAppPath,
+ ]),
+ null,
+ );
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'reuse_ready');
+});
+
+test('a manifest that certifies an escaping symlink refuses reuse', async () => {
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true }));
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const escapingTarget = path.join(outside, 'Frameworks');
+ fs.symlinkSync(escapingTarget, path.join(runnerAppPath, 'Frameworks'));
+ writeRunnerCacheMetadata(derived, {
+ ...expected,
+ artifacts: {
+ xctestrunPath: publishedXctestrun(derived),
+ xctestrunSize: fs.statSync(publishedXctestrun(derived)).size,
+ xctestrunDigest: digestFile(publishedXctestrun(derived)),
+ productPaths: [runnerAppPath],
+ entries: [
+ {
+ path: path
+ .relative(derived, path.join(runnerAppPath, 'Frameworks'))
+ .replaceAll(path.sep, '/'),
+ symlink: escapingTarget,
+ },
+ {
+ path: path
+ .relative(derived, path.join(runnerAppPath, 'Runner'))
+ .replaceAll(path.sep, '/'),
+ size: EXECUTABLE_BYTES.length,
+ mode: 0o755,
+ digest: digestFile(path.join(runnerAppPath, 'Runner')),
+ },
+ ],
+ },
+ });
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ assert.equal(state.mismatch.reason, 'symlink_escapes_cache');
+});
+
+test('a build holding an escaping symlink publishes no manifest at all', async () => {
+ const { derived, runnerAppPath, xctestrunPath, expected } = await makeCachedRunnerBuild();
+ fs.symlinkSync('../../../../../outside', path.join(runnerAppPath, 'Frameworks'));
+
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]);
+
+ const published = JSON.parse(
+ fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'),
+ ) as { artifacts?: unknown };
+ assert.equal(published.artifacts, undefined);
+});
+
+test('an unreadable product subtree is refused and reported, not silently uncertified', async () => {
+ const { derived, runnerAppPath, expected, xctestrunPath } = await makeCachedRunnerBuild();
+ const sealed = path.join(runnerAppPath, 'Sealed.framework');
+ fs.mkdirSync(sealed, { recursive: true });
+ fs.writeFileSync(path.join(sealed, 'Binary'), 'binary');
+ fs.chmodSync(sealed, 0o000);
+ onTestFinished(() => {
+ fs.chmodSync(sealed, 0o755);
+ });
+
+ const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [
+ runnerAppPath,
+ ]);
+
+ assert.deepEqual(refusal, {
+ reason: 'root_unusable',
+ path: path.relative(derived, sealed),
+ });
+ const published = JSON.parse(
+ fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'),
+ ) as { artifacts?: unknown };
+ assert.equal(published.artifacts, undefined);
+});
+
+test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', async () => {
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true }));
+ const { derived, expected, runnerAppPath } = await makeCachedRunnerBuild();
+ const realXctestrun = path.join(outside, 'real.xctestrun');
+ fs.writeFileSync(realXctestrun, fs.readFileSync(publishedXctestrun(derived)));
+ const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun');
+ fs.rmSync(publishedXctestrun(derived));
+ fs.symlinkSync(realXctestrun, linkedXctestrun);
+
+ writeRunnerCacheMetadata(derived, {
+ ...expected,
+ artifacts: {
+ xctestrunPath: linkedXctestrun,
+ xctestrunSize: fs.statSync(realXctestrun).size,
+ xctestrunDigest: digestFile(realXctestrun),
+ productPaths: [runnerAppPath],
+ entries: [
+ {
+ path: path
+ .relative(derived, path.join(runnerAppPath, 'Runner'))
+ .replaceAll(path.sep, '/'),
+ size: EXECUTABLE_BYTES.length,
+ mode: 0o755,
+ digest: digestFile(path.join(runnerAppPath, 'Runner')),
+ },
+ ],
+ },
+ });
+
+ const state = mismatchOf(
+ await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }),
+ );
+
+ // The manifest names a regular file; a symlink is not one, so its bytes are never digested
+ // from wherever the link happens to point.
+ assert.equal(state.mismatch.reason, 'kind_changed');
+ assert.equal(state.mismatch.path, linkedXctestrun);
+});
+
+test('a build whose .xctestrun is a symlink out of the cache publishes no manifest', async () => {
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true }));
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const staged = path.join(outside, 'real.xctestrun');
+ fs.writeFileSync(staged, 'xctestrun');
+ const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun');
+ fs.rmSync(publishedXctestrun(derived));
+ fs.symlinkSync(staged, linkedXctestrun);
+
+ const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [
+ runnerAppPath,
+ ]);
+
+ assert.equal(refusal?.reason, 'root_escapes_cache');
+ const published = JSON.parse(
+ fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'),
+ ) as { artifacts?: unknown };
+ assert.equal(published.artifacts, undefined);
+});
+
+test('a build whose .xctestrun is an in-cache symlink publishes no manifest', async () => {
+ // The reader lstats the named path and calls a symlink a kind change, so a manifest written
+ // for a link would be refused on sight: a rebuild on every launch. The writer must decline to
+ // publish that pairing rather than hand every cache entry to the miss path.
+ const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild();
+ const productsPath = path.join(derived, 'Build', 'Products');
+ const realXctestrun = path.join(productsPath, 'staged.xctestrun');
+ fs.writeFileSync(realXctestrun, 'xctestrun');
+ const linkedXctestrun = path.join(productsPath, 'linked.xctestrun');
+ fs.symlinkSync('staged.xctestrun', linkedXctestrun);
+
+ const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [
+ runnerAppPath,
+ ]);
+
+ assert.equal(refusal?.reason, 'root_unusable');
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'artifact_manifest_missing');
+});
+
+test('products without a content manifest are a miss, never a reuse', async () => {
+ const { derived, expected } = await makeCachedRunnerBuild();
+
+ writeRunnerCacheMetadata(derived, expected);
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'artifact_manifest_missing');
+});
+
+test('a manifest naming paths outside the cache root is a miss', async () => {
+ const outside = mkdtempForTestSync('agent-device-runner-cache-outside-');
+ onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true }));
+ const { derived, expected } = await makeCachedRunnerBuild();
+ const foreignXctestrun = path.join(outside, 'foreign.xctestrun');
+ fs.writeFileSync(foreignXctestrun, 'xctestrun');
+ writeRunnerCacheMetadata(derived, {
+ ...expected,
+ artifacts: {
+ xctestrunPath: foreignXctestrun,
+ xctestrunSize: 1,
+ xctestrunDigest: '0'.repeat(64),
+ productPaths: [path.join(outside, 'Runner-Runner.app')],
+ entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '0'.repeat(64) }],
+ },
+ });
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'artifact_manifest_missing');
+});
+
+test('a writer handed an .xctestrun path that does not exist certifies nothing', async () => {
+ // The reader-side crossing-root defense is 'a manifest naming paths outside the cache root is
+ // a miss'; this is the writer half: a named path with no bytes behind it cannot be digested,
+ // so the identity half is published and the tree stays a miss.
+ const { derived, expected } = await makeCachedRunnerBuild();
+ const refusal = await writeRunnerCacheMetadataForArtifacts(
+ derived,
+ expected,
+ path.join(derived, 'Build', 'Products', 'other.xctestrun'),
+ [path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app')],
+ );
+
+ assert.equal(refusal?.reason, 'root_unusable');
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'artifact_manifest_missing');
+});
+
+test('a cache root reached through a symlinked ancestor is certified and reused', async () => {
+ // macOS reports TMPDIR under /var, which is a symlink to /private/var. A cache whose root is
+ // named through such a link must still certify, or every daemon on such a host rebuilds.
+ const root = mkdtempForTestSync('agent-device-runner-cache-ancestor-link-');
+ onTestFinished(() => fs.rmSync(root, { recursive: true, force: true }));
+ const realRoot = path.join(root, 'real');
+ const linkedRoot = path.join(root, 'link');
+ fs.mkdirSync(realRoot, { recursive: true });
+ fs.symlinkSync(realRoot, linkedRoot);
+ const derived = path.join(linkedRoot, 'derived');
+ const productsPath = path.join(derived, 'Build', 'Products');
+ const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app');
+ fs.mkdirSync(runnerAppPath, { recursive: true });
+ fs.writeFileSync(path.join(runnerAppPath, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 });
+ const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun');
+ fs.writeFileSync(xctestrunPath, 'xctestrun');
+ const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+
+ assert.equal(
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]),
+ null,
+ );
+
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
+ assert.equal(state.reason, 'reuse_ready');
+});
diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts
new file mode 100644
index 0000000000..fb737f09ad
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts
@@ -0,0 +1,688 @@
+import {
+ AppError,
+ createRequestCanceledError,
+ isRequestCanceledError,
+} from '@agent-device/kernel/errors';
+import type { RequestProgressEvent } from '@agent-device/contracts/progress';
+import { beforeEach, onTestFinished, test, vi } from 'vitest';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { appleRunnerTestHost } from '../test-host.ts';
+import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts';
+import { ensureXctestrunArtifact } from '../runner-artifact.ts';
+import {
+ createRunnerPhaseBudget,
+ markRunnerXctestrunArtifactBadForRun,
+ resolveExpectedRunnerCacheMetadata,
+ resolveRunnerDerivedPath,
+} from '../runner-xctestrun.ts';
+import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts';
+import { digestFile } from './digest-file.ts';
+import {
+ REPO_ROOT_FOR_TEST as repoRoot,
+ makeCachedRunnerXctestrun,
+ makeProjectScratchDir,
+ makeScratchDir,
+ RUNNER_FIXTURE_EXECUTABLE_BYTES,
+ seedRunnerProductBundle,
+ stripRunnerCacheArtifacts,
+ withRunnerDerivedPathEnv,
+ withoutRunnerDerivedPathEnv,
+ writeRunnerCacheMetadataWithArtifacts,
+ writeXctestrunFixture,
+} from './runner-xctestrun.fixtures.ts';
+
+/** Certifies a fixture tree the way the production writer would, and proves it worked. */
+async function writeCertifiedRunnerMetadata(params: {
+ derivedPath: string;
+ device: DeviceInfo;
+ xctestrunPath: string;
+ productPaths: string[];
+}): Promise {
+ assert.equal(await writeRunnerCacheMetadataWithArtifacts(params), null);
+}
+
+const mockRunCmdStreaming = vi.fn();
+const { mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({
+ mockRepairMacOsRunnerProductsIfNeeded: vi.fn(),
+}));
+
+vi.mock('../runner-macos-products.ts', async () => {
+ const actual = await vi.importActual(
+ '../runner-macos-products.ts',
+ );
+ return {
+ ...actual,
+ repairMacOsRunnerProductsIfNeeded: mockRepairMacOsRunnerProductsIfNeeded,
+ };
+});
+
+import type { DeviceInfo } from '@agent-device/kernel/device';
+
+const iosSimulator: DeviceInfo = {
+ platform: 'apple',
+ id: 'sim-1',
+ name: 'iPhone Simulator',
+ kind: 'simulator',
+ booted: true,
+};
+
+const macOsDevice: DeviceInfo = {
+ platform: 'apple',
+ appleOs: 'macos',
+ id: 'host-macos-local',
+ name: 'Host Mac',
+ kind: 'device',
+ target: 'desktop',
+ booted: true,
+};
+
+/**
+ * `ensureXctestrunArtifact` decides between reusing a certified DerivedData tree and rebuilding
+ * one. The cases below are that decision table: what a manifest does and does not certify, what
+ * survives repair, and what gets discarded before a rebuild can bless it.
+ */
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ appleRunnerTestHost.update({
+ runCmdStreaming: mockRunCmdStreaming,
+ runCmdSync: appleToolchainProbeResult,
+ readProcessStartTime: () => 'test-process-start',
+ });
+ mockRunCmdStreaming.mockResolvedValue(undefined);
+ mockRepairMacOsRunnerProductsIfNeeded.mockResolvedValue(undefined);
+});
+
+test('ensureXctestrunArtifact reuses matching manifest artifacts from another project root', async () => {
+ const tmpDir = await makeScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const productPath = path.join(derivedPath, 'Runner.app');
+ const xctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
+ await seedRunnerProductBundle(productPath);
+ writeXctestrunFixture(xctestrunPath, {
+ projectRoot: '/tmp/other-agent-device-worktree',
+ productRelativePaths: ['Runner.app'],
+ });
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath,
+ productPaths: [productPath],
+ });
+ withRunnerDerivedPathEnv(derivedPath);
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, xctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+ assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [productPath]);
+});
+
+test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not match', async () => {
+ const projectRoot = repoRoot;
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const productPath = path.join(derivedPath, 'Runner.app');
+ const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun');
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+ await seedRunnerProductBundle(productPath);
+ writeXctestrunFixture(foreignXctestrunPath, {
+ projectRoot: '/tmp/other-agent-device-worktree',
+ productRelativePaths: ['Runner.app'],
+ });
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath: foreignXctestrunPath,
+ productPaths: [productPath],
+ });
+ const metadataPath = resolveRunnerCacheMetadataPath(derivedPath);
+ const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
+ staleMetadata.runnerSandboxBuildArgs = staleMetadata.runnerSandboxBuildArgs.map((arg: string) =>
+ arg.startsWith('OTHER_SWIFT_FLAGS=')
+ ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'
+ : arg,
+ );
+ fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2));
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementation(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, rebuiltXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ assert.equal(fs.existsSync(foreignXctestrunPath), false);
+});
+
+test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root', async () => {
+ const projectRoot = repoRoot;
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const externalDir = path.join(tmpDir, 'external');
+ const externalProductPath = path.join(externalDir, 'Runner.app');
+ const externalXctestrunPath = path.join(externalDir, 'external.xctestrun');
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+ await seedRunnerProductBundle(externalProductPath);
+ writeXctestrunFixture(externalXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ await fs.promises.mkdir(derivedPath, { recursive: true });
+ // A manifest naming paths outside its own cache root cannot have been written for this tree,
+ // so the reader declines it. The production writer refuses to publish one, hence the hand-off.
+ // Every byte the manifest describes is hashed from where it really sits, so nothing but the
+ // crossing paths can make this manifest fail: a regression that dropped the containment check
+ // would certify or content-mismatch, not the pinned manifest miss below.
+ const externalExecutablePath = path.join(externalProductPath, 'Runner');
+ fs.writeFileSync(
+ resolveRunnerCacheMetadataPath(derivedPath),
+ JSON.stringify({
+ ...resolveExpectedRunnerCacheMetadata(macOsDevice, projectRoot),
+ artifacts: {
+ xctestrunPath: externalXctestrunPath,
+ xctestrunSize: fs.statSync(externalXctestrunPath).size,
+ xctestrunDigest: digestFile(externalXctestrunPath),
+ productPaths: [externalProductPath],
+ entries: [
+ {
+ path: 'Runner',
+ size: fs.statSync(externalExecutablePath).size,
+ mode: 0o755,
+ digest: digestFile(externalExecutablePath),
+ },
+ ],
+ },
+ }),
+ );
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementation(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = await ensureXctestrunArtifact(macOsDevice, {});
+
+ assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
+ assert.equal(result.reason, 'artifact_manifest_missing');
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+});
+
+test('ensureXctestrunArtifact aborts only the disconnected request build and preserves concurrent unrelated builds', async () => {
+ // The request AbortSignal must reach the xctestrun build (killProcessTree via
+ // runCmdStreaming); removing global abort must not orphan a disconnected prep.
+ // Request-scoped: aborting one request's build leaves an unrelated concurrent
+ // build (different device -> different derived, different signal) untouched.
+ withoutRunnerDerivedPathEnv();
+ const canceledDevice = iosSimulator;
+ const survivorDevice = macOsDevice;
+ for (const device of [canceledDevice, survivorDevice]) {
+ const derived = resolveRunnerDerivedPath(
+ device,
+ resolveExpectedRunnerCacheMetadata(device, repoRoot),
+ );
+ onTestFinished(async () => {
+ await fs.promises.rm(derived, { recursive: true, force: true });
+ });
+ }
+
+ const canceledController = new AbortController();
+ const survivorController = new AbortController();
+ const deferred = (): { promise: Promise; resolve: (value: T) => void } => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+ };
+ const waitForAbort = (signal: AbortSignal): Promise =>
+ signal.aborted
+ ? Promise.resolve()
+ : new Promise((resolve) =>
+ signal.addEventListener('abort', () => resolve(), {
+ once: true,
+ }),
+ );
+ const canceledBuildStarted = deferred();
+ const survivorBuildStarted = deferred();
+ const releaseSurvivor = deferred();
+ const cancellationError = createRequestCanceledError();
+
+ mockRunCmdStreaming.mockImplementation(async (_cmd, args, options) => {
+ const derived = args[args.indexOf('-derivedDataPath') + 1];
+ if (options?.signal === canceledController.signal) {
+ canceledBuildStarted.resolve();
+ await waitForAbort(options.signal);
+ throw cancellationError;
+ }
+ survivorBuildStarted.resolve();
+ await releaseSurvivor.promise;
+ await seedRunnerProductBundle(path.join(derived, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(path.join(derived, 'rebuilt', 'rebuilt.xctestrun'), {
+ projectRoot: repoRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const canceledPromise = ensureXctestrunArtifact(canceledDevice, {
+ budget: createRunnerPhaseBudget(undefined, canceledController.signal),
+ });
+ const survivorPromise = ensureXctestrunArtifact(survivorDevice, {
+ budget: createRunnerPhaseBudget(undefined, survivorController.signal),
+ });
+
+ await Promise.all([canceledBuildStarted.promise, survivorBuildStarted.promise]);
+
+ canceledController.abort();
+ await assert.rejects(canceledPromise, (error: unknown) => {
+ assert.equal(error, cancellationError);
+ assert.ok(isRequestCanceledError(error));
+ return true;
+ });
+ // The unrelated concurrent build's signal was never aborted.
+ assert.equal(survivorController.signal.aborted, false);
+
+ releaseSurvivor.resolve();
+ const survivorResult = await survivorPromise;
+ assert.ok(survivorResult.xctestrunPath.endsWith('rebuilt.xctestrun'));
+
+ const canceledCall = mockRunCmdStreaming.mock.calls.find(
+ (call) => call[2]?.signal === canceledController.signal,
+ );
+ const survivorCall = mockRunCmdStreaming.mock.calls.find(
+ (call) => call[2]?.signal === survivorController.signal,
+ );
+ assert.ok(canceledCall, 'canceled build received its request signal');
+ assert.ok(survivorCall, 'survivor build received its request signal');
+});
+
+test('ensureXctestrunArtifact never reports reuse of a tree that fails certification', async () => {
+ // A repair step that leaves an escaping symlink behind poisons the tree AFTER evaluation
+ // called it reusable. Certification catches it and throws, but only the ORDER of the
+ // `reuse_ready` decision proves the gate is real: emitted before it, diagnostics would record
+ // a reuse that never happened.
+ const { derivedPath } = await makeCachedRunnerXctestrun(macOsDevice);
+ const outside = await makeProjectScratchDir();
+ withRunnerDerivedPathEnv(derivedPath);
+ const decisions: Array> = [];
+ appleRunnerTestHost.update({
+ emitDiagnostic: (event) => {
+ if (event.phase === 'runner_xctestrun_cache') {
+ decisions.push(event.data ?? {});
+ }
+ },
+ });
+ mockRepairMacOsRunnerProductsIfNeeded.mockImplementation(async () => {
+ fs.symlinkSync(path.join(outside, 'evil'), path.join(derivedPath, 'Runner.app', 'Leaked'));
+ });
+
+ await assert.rejects(() => ensureXctestrunArtifact(macOsDevice, {}), {
+ code: 'COMMAND_FAILED',
+ message: 'The Apple runner products cannot be certified for cache reuse',
+ });
+
+ assert.equal(
+ decisions.some((data) => data.reason === 'reuse_ready'),
+ false,
+ );
+ assert.ok(
+ decisions.some((data) => data.reason === 'uncertifiable_products'),
+ 'the refusal is what the diagnostics record instead',
+ );
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+});
+
+test('ensureXctestrunArtifact rebuilds after cached macOS runner repair failure', async () => {
+ // Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once,
+ // rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state.
+ const projectRoot = repoRoot;
+ const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice);
+ const projectPath = path.join(
+ projectRoot,
+ 'apple',
+ 'runner',
+ 'AgentDeviceRunner',
+ 'AgentDeviceRunner.xcodeproj',
+ );
+
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+
+ withRunnerDerivedPathEnv(derivedPath);
+
+ const repairedPaths: string[] = [];
+
+ mockRepairMacOsRunnerProductsIfNeeded.mockImplementation(
+ async (_device, _productPaths, xctestrunPath) => {
+ repairedPaths.push(xctestrunPath);
+ if (xctestrunPath === existingXctestrunPath) {
+ throw new AppError('COMMAND_FAILED', 'cached runner is damaged', {
+ reason: 'RUNNER_PRODUCT_REPAIR_FAILED',
+ });
+ }
+ },
+ );
+ mockRunCmdStreaming.mockImplementation(async (command, args) => {
+ assert.equal(command, 'xcodebuild');
+ assert.ok(Array.isArray(args));
+ assert.equal(args[args.indexOf('-project') + 1], projectPath);
+ assert.equal(args[args.indexOf('-derivedDataPath') + 1], derivedPath);
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, rebuiltXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ assert.equal(fs.existsSync(existingXctestrunPath), false);
+ assert.deepEqual(repairedPaths, [existingXctestrunPath, rebuiltXctestrunPath]);
+});
+
+test('ensureXctestrunArtifact prefers validated cache manifest over recursive scan', async () => {
+ const tmpDir = await makeScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app');
+ const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
+ const newerProductPath = path.join(derivedPath, 'NewerRunner.app');
+ const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun');
+ await seedRunnerProductBundle(manifestProductPath);
+ await seedRunnerProductBundle(newerProductPath);
+ writeXctestrunFixture(manifestXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['ManifestRunner.app'],
+ });
+ writeXctestrunFixture(newerXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['NewerRunner.app'],
+ });
+ const now = new Date();
+ fs.utimesSync(manifestXctestrunPath, now, now);
+ fs.utimesSync(
+ newerXctestrunPath,
+ new Date(now.getTime() + 5_000),
+ new Date(now.getTime() + 5_000),
+ );
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath: manifestXctestrunPath,
+ productPaths: [manifestProductPath],
+ });
+ withRunnerDerivedPathEnv(derivedPath);
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, manifestXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+ assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]);
+});
+
+test('ensureXctestrunArtifact ignores a newer foreign xctestrun beside a certified build', async () => {
+ // The product-discovery scan is gone: a certified manifest is the only reuse authority, so
+ // a newer .xctestrun that nothing certifies cannot be blessed over it.
+ const tmpDir = await makeScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app');
+ const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
+ const newerProductPath = path.join(derivedPath, 'NewerRunner.app');
+ const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun');
+ await seedRunnerProductBundle(manifestProductPath);
+ await seedRunnerProductBundle(newerProductPath);
+ writeXctestrunFixture(manifestXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['ManifestRunner.app'],
+ });
+ writeXctestrunFixture(newerXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['NewerRunner.app'],
+ });
+ const now = new Date();
+ fs.utimesSync(manifestXctestrunPath, now, now);
+ fs.utimesSync(
+ newerXctestrunPath,
+ new Date(now.getTime() + 5_000),
+ new Date(now.getTime() + 5_000),
+ );
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath: manifestXctestrunPath,
+ productPaths: [manifestProductPath],
+ });
+ withRunnerDerivedPathEnv(derivedPath);
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, manifestXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+ assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]);
+});
+
+test('ensureXctestrunArtifact discards and rebuilds a manifest whose bytes no longer match', async () => {
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const productPath = path.join(derivedPath, 'Runner.app');
+ const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun');
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+ await seedRunnerProductBundle(productPath);
+ writeXctestrunFixture(cachedXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath: cachedXctestrunPath,
+ productPaths: [productPath],
+ });
+ // The failure the old stat signature could not see: same length, same mtime, new bytes, so
+ // only a digest can tell. A shorter replacement would trip `size_changed` first.
+ const executablePath = path.join(productPath, 'Runner');
+ const tampered = Buffer.from(RUNNER_FIXTURE_EXECUTABLE_BYTES);
+ tampered[tampered.length - 2] = 'x'.charCodeAt(0);
+ assert.equal(tampered.length, RUNNER_FIXTURE_EXECUTABLE_BYTES.length);
+ const stat = fs.statSync(executablePath);
+ fs.writeFileSync(executablePath, tampered, { mode: 0o755 });
+ fs.utimesSync(executablePath, stat.atime, stat.mtime);
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementation(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot: repoRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = await ensureXctestrunArtifact(macOsDevice, {});
+
+ assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
+ assert.equal(result.reason, 'artifact_content_mismatch');
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ assert.equal(fs.existsSync(cachedXctestrunPath), false);
+});
+
+test('ensureXctestrunArtifact rebuilds cached runner when Swift build flags mismatch', async () => {
+ const projectRoot = repoRoot;
+ const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice);
+ const metadataPath = resolveRunnerCacheMetadataPath(derivedPath);
+ const expectedMetadata = resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot);
+ const staleMetadata = {
+ ...expectedMetadata,
+ runnerSandboxBuildArgs: expectedMetadata.runnerSandboxBuildArgs.map((arg) =>
+ arg.startsWith('OTHER_SWIFT_FLAGS=')
+ ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'
+ : arg,
+ ),
+ };
+ fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2));
+
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementation(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
+
+ assert.equal(result, rebuiltXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ assert.equal(fs.existsSync(existingXctestrunPath), false);
+ const rebuiltMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
+ assert.deepEqual(
+ stripRunnerCacheArtifacts(rebuiltMetadata),
+ resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot),
+ );
+ assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath);
+});
+
+test('ensureXctestrunArtifact passes sandbox-disabling settings to xcodebuild', async () => {
+ const projectRoot = repoRoot;
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun');
+
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementationOnce(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'Build', 'Products', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const result = await ensureXctestrunArtifact(iosSimulator, {
+ forceRunnerXctestrunRebuild: true,
+ });
+
+ assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ const args = mockRunCmdStreaming.mock.calls[0]?.[1] ?? [];
+ assert.equal(args.includes('-IDEPackageSupportDisableManifestSandbox=1'), true);
+ assert.equal(args.includes('-IDEPackageSupportDisablePluginExecutionSandbox=1'), true);
+ assert.equal(args.includes('ENABLE_USER_SCRIPT_SANDBOXING=NO'), true);
+ assert.equal(
+ args.includes(
+ 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY',
+ ),
+ true,
+ );
+});
+
+test('ensureXctestrunArtifact emits build progress on cache miss', async () => {
+ const projectRoot = repoRoot;
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun');
+ const events: RequestProgressEvent[] = [];
+
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRunCmdStreaming.mockImplementationOnce(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'Build', 'Products', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ appleRunnerTestHost.update({ emitRequestProgress: (event) => events.push(event) });
+
+ const result = await ensureXctestrunArtifact(iosSimulator, {
+ forceRunnerXctestrunRebuild: true,
+ });
+
+ assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
+ assert.deepEqual(events, [
+ {
+ type: 'command',
+ status: 'progress',
+ message: 'Building Apple runner...',
+ },
+ ]);
+});
+
+test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => {
+ const projectRoot = repoRoot;
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const productPath = path.join(derivedPath, 'Runner.app');
+ const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun');
+ await seedRunnerProductBundle(productPath);
+ writeXctestrunFixture(cachedXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ await writeCertifiedRunnerMetadata({
+ derivedPath,
+ device: macOsDevice,
+ xctestrunPath: cachedXctestrunPath,
+ productPaths: [productPath],
+ });
+ withRunnerDerivedPathEnv(derivedPath);
+
+ const hit = await ensureXctestrunArtifact(macOsDevice, {});
+
+ assert.equal(hit.xctestrunPath, cachedXctestrunPath);
+ assert.equal(hit.cache, 'exact');
+ assert.equal(hit.artifact, 'valid');
+ assert.equal(hit.buildMs, 0);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+
+ await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed');
+ assert.equal(fs.existsSync(cachedXctestrunPath), false);
+
+ const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
+ mockRunCmdStreaming.mockImplementationOnce(async () => {
+ await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app'));
+ writeXctestrunFixture(rebuiltXctestrunPath, {
+ projectRoot,
+ productRelativePaths: ['Runner.app'],
+ });
+ });
+
+ const rebuilt = await ensureXctestrunArtifact(macOsDevice, {
+ budget: createRunnerPhaseBudget(300_000, undefined),
+ });
+
+ assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath);
+ assert.equal(rebuilt.cache, 'miss');
+ assert.equal(rebuilt.artifact, 'rebuilt');
+ assert.equal(rebuilt.reason, 'cache_metadata_missing');
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
+ assert.equal(Math.ceil(Number(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs) / 1e3), 300); // phase remainder (#2422)
+});
+
+test('ensureXctestrunArtifact rethrows unexpected cached macOS runner repair errors', async () => {
+ const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice);
+
+ withRunnerDerivedPathEnv(derivedPath);
+
+ mockRepairMacOsRunnerProductsIfNeeded.mockRejectedValue(new Error('permission denied'));
+
+ await assert.rejects(ensureXctestrunArtifact(macOsDevice, {}), /permission denied/);
+ assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
+ assert.equal(fs.existsSync(existingXctestrunPath), true);
+});
diff --git a/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts
new file mode 100644
index 0000000000..3f1b9136a2
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts
@@ -0,0 +1,24 @@
+/**
+ * `xcodebuild`'s own echo of the recipe it was handed: build settings under their own header, and
+ * the arguments it does not treat as settings — the `-I` package-sandbox flags — on the invocation
+ * line. Built from the arguments the cache identity records, so a test that renders a log and a
+ * test that renders a drifted one differ only in the arguments they leave out.
+ */
+export function xcodebuildLogWithBuildArguments(args: readonly string[]): string {
+ const isSetting = (arg: string) => /^[A-Z][A-Z0-9_]*=/.test(arg);
+ const buildSettings = args.filter(isSetting).map((arg) => {
+ const index = arg.indexOf('=');
+ return ` ${arg.slice(0, index)} = ${arg.slice(index + 1)}`;
+ });
+ const flags = args.filter((arg) => !isSetting(arg));
+ return [
+ 'Command line invocation:',
+ ` /usr/bin/xcodebuild build-for-testing ${flags.join(' ')}`.trimEnd(),
+ '',
+ 'Build settings from command line:',
+ ...buildSettings,
+ '',
+ 'Resolve Package Graph',
+ '',
+ ].join('\n');
+}
diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts
index 64ebcba9df..03f06dca58 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts
@@ -5,8 +5,11 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts';
import { appleRunnerTestHost } from '../test-host.ts';
import type { ExecOptions } from '@agent-device/host-kit/command';
+import fs from 'node:fs';
+import path from 'node:path';
import {
createRunnerPhaseBudget,
+ requireRunnerBuildSettingsMatchBuildLog,
diffComparableRunnerCacheMetadata,
resolveRunnerBundleBuildSettings,
resolveRunnerMaxConcurrentDestinationsFlag,
@@ -17,6 +20,8 @@ import {
} from '../runner-cache-metadata.ts';
import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../apple-runner-platform.ts';
import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
+import { mkdtempForTestSync } from './tmp-dir.ts';
+import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts';
const runCmdSync = stubAppleToolchainProbes();
@@ -121,7 +126,7 @@ test('resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing',
'-IDEPackageSupportDisableManifestSandbox=1',
'-IDEPackageSupportDisablePluginExecutionSandbox=1',
'ENABLE_USER_SCRIPT_SANDBOXING=NO',
- 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox',
+ 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY',
]);
});
@@ -133,7 +138,7 @@ test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when r
'-IDEPackageSupportDisableManifestSandbox=1',
'-IDEPackageSupportDisablePluginExecutionSandbox=1',
'ENABLE_USER_SCRIPT_SANDBOXING=NO',
- 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS',
+ 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY -D AGENT_DEVICE_RUNNER_UNIT_TESTS',
]);
} finally {
if (previous === undefined) {
@@ -172,9 +177,10 @@ test('metadata diff names only the comparable keys that differ, with expected an
runnerPerformanceBuildSettings: ['ENABLE_CODE_COVERAGE=YES'],
artifacts: {
xctestrunPath: '/tmp/derived/Runner.xctestrun',
- xctestrunMtimeMs: 1,
xctestrunSize: 2,
- productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }],
+ xctestrunDigest: 'a'.repeat(64),
+ productPaths: ['/tmp/derived/Runner.app'],
+ entries: [{ path: 'Runner.app/Runner', size: 2, mode: 0o755, digest: 'b'.repeat(64) }],
},
};
@@ -682,3 +688,66 @@ test('only a complete, parsed toolchain fingerprint is memoized', () => {
[first.xcodeVersion, first.xcodeBuildVersion, first.sdkVersion, first.sdkBuildVersion],
);
});
+
+function recordedRunnerBuildArguments(): string[] {
+ const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+ return [
+ ...metadata.runnerBundleBuildSettings,
+ ...metadata.runnerSigningBuildSettings,
+ ...metadata.runnerPerformanceBuildSettings,
+ ...metadata.runnerArchBuildSettings,
+ ...metadata.runnerSandboxBuildArgs,
+ ];
+}
+
+function writeRunnerBuildLog(root: string, args: readonly string[]): string {
+ const logPath = path.join(root, 'agent-device-build-for-testing.log');
+ fs.writeFileSync(logPath, xcodebuildLogWithBuildArguments(args));
+ return logPath;
+}
+
+test('a build log holding the recorded recipe certifies the build', () => {
+ const root = mkdtempForTestSync('runner-build-log-');
+ const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+ const logPath = writeRunnerBuildLog(root, recordedRunnerBuildArguments());
+
+ assert.doesNotThrow(() => requireRunnerBuildSettingsMatchBuildLog(metadata, logPath));
+});
+
+test('a build log missing a recorded package-sandbox flag fails the check', () => {
+ const root = mkdtempForTestSync('runner-build-log-');
+ const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+ // `-I` flags are not build settings, so the settings block would stay complete without them.
+ const logPath = writeRunnerBuildLog(
+ root,
+ recordedRunnerBuildArguments().filter((arg) => !arg.startsWith('-IDEPackageSupport')),
+ );
+
+ assert.throws(
+ () => requireRunnerBuildSettingsMatchBuildLog(metadata, logPath),
+ (error: unknown) => {
+ assert.ok(error instanceof AppError);
+ assert.deepEqual(
+ (error.details as { differences: Array<{ key: string; expected: string }> }).differences
+ .map((difference) => difference.expected)
+ .sort(),
+ [
+ '-IDEPackageSupportDisableManifestSandbox=1',
+ '-IDEPackageSupportDisablePluginExecutionSandbox=1',
+ ],
+ );
+ return true;
+ },
+ );
+});
+
+test('an unreadable or setting-less build log fails the check', () => {
+ const root = mkdtempForTestSync('runner-build-log-');
+ const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+
+ assert.throws(
+ () =>
+ requireRunnerBuildSettingsMatchBuildLog(metadata, path.join(root, 'never-written-build.log')),
+ /did not use the settings its cache identity records/,
+ );
+});
diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts
new file mode 100644
index 0000000000..3ed5c010e9
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { onTestFinished } from 'vitest';
+import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts';
+import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
+import type { ExistingXctestrunState, RunnerXctestrunCacheMetadata } from '../runner-cache.ts';
+import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
+import { IOS_SIMULATOR } from './device-fixtures.ts';
+import { mkdtempForTestSync } from './tmp-dir.ts';
+
+// The identity half reads the host toolchain, so the fixture that builds a manifest answers those
+// probes itself: a consumer must not have to know it is importing an Xcode-touching helper.
+stubAppleToolchainProbes();
+
+/**
+ * A cache root and a content manifest for the tests that decide what a manifest certifies: the
+ * bytes must exist before a digest can name them, and the layout must be the one a build leaves.
+ */
+
+export const EXECUTABLE_BYTES = Buffer.alloc(4096, 7);
+
+export type CachedRunnerBuild = {
+ derived: string;
+ xctestrunPath: string;
+ runnerAppPath: string;
+ executablePath: string;
+ expected: RunnerXctestrunCacheMetadata;
+};
+
+/**
+ * A cache root laid out the way a build leaves it: the `.xctestrun` under Build/Products, a
+ * product bundle with an executable, and a manifest certifying both.
+ */
+export async function makeCachedRunnerBuild(): Promise {
+ const derived = mkdtempForTestSync('agent-device-runner-cache-eval-');
+ onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true }));
+ const productsPath = path.join(derived, 'Build', 'Products');
+ const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app');
+ fs.mkdirSync(runnerAppPath, { recursive: true });
+ const xctestrunPath = publishedXctestrun(derived);
+ fs.writeFileSync(xctestrunPath, 'xctestrun');
+ const executablePath = path.join(runnerAppPath, 'Runner');
+ fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 });
+ const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+ // A fixture tree that cannot be certified would leave every consumer facing an opaque
+ // `artifact_manifest_missing` instead of the named refusal the writer reports.
+ assert.equal(
+ await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]),
+ null,
+ );
+ return { derived, xctestrunPath, runnerAppPath, executablePath, expected };
+}
+
+export function publishedXctestrun(derived: string): string {
+ return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun');
+}
+
+export function mismatchOf(
+ state: ExistingXctestrunState,
+): Extract {
+ assert.equal(state.reason, 'artifact_content_mismatch');
+ return state;
+}
diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts
index b184b2fda1..315b66cb33 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts
@@ -1,46 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
-import path from 'node:path';
import { onTestFinished, test } from 'vitest';
-import {
- evaluateExistingXctestrun,
- writeRunnerCacheMetadata,
- type ExistingXctestrunState,
-} from '../runner-cache.ts';
+import { evaluateExistingXctestrun, writeRunnerCacheMetadata } from '../runner-cache.ts';
import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';
-import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
+import { restoreEnvVar } from './runner-xctestrun.fixtures.ts';
+import { makeCachedRunnerBuild } from './runner-cache.fixtures.ts';
-stubAppleToolchainProbes();
+test('reuse ignores the non-comparable package version', async () => {
+ const { derived, expected } = await makeCachedRunnerBuild();
-function evaluateAgainstCachedMetadata(
- cached: Record,
-): Promise {
- const derived = mkdtempForTestSync('agent-device-runner-cache-eval-');
- onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true }));
- const xctestrunPath = path.join(derived, 'Runner.xctestrun');
- fs.writeFileSync(xctestrunPath, 'xctestrun');
- writeRunnerCacheMetadata(derived, cached as never);
- return evaluateExistingXctestrun({
+ const state = await evaluateExistingXctestrun({
derived,
- projectRoot: process.cwd(),
- expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR),
- findXctestrun: () => xctestrunPath,
- xctestrunReferencesProjectRoot: () => true,
- resolveExistingXctestrunProductPaths: () => Promise.resolve([path.join(derived, 'Runner.app')]),
+ expectedCacheMetadata: { ...expected, packageVersion: `${expected.packageVersion}-next` },
});
-}
+
+ assert.equal(state.reason, 'reuse_ready');
+});
test('a metadata mismatch names the differing keys with expected and actual', async () => {
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
-
- const state = await evaluateAgainstCachedMetadata({
+ const derived = mkdtempForTestSync('agent-device-runner-cache-eval-');
+ onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true }));
+ writeRunnerCacheMetadata(derived, {
...expected,
xcodeBuildVersion: '17A100',
runnerSandboxBuildArgs: [...expected.runnerSandboxBuildArgs, 'EXTRA=1'],
});
+ const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected });
+
assert.equal(state.reason, 'cache_metadata_mismatch');
assert.deepEqual(state.reason === 'cache_metadata_mismatch' ? state.metadataDifferences : null, [
{
@@ -52,13 +42,47 @@ test('a metadata mismatch names the differing keys with expected and actual', as
]);
});
-test('metadata that differs only in the non-comparable fields still reuses the cache', async () => {
- const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+test('an architecture override changes the cache identity', () => {
+ const previous = process.env.AGENT_DEVICE_XCUITEST_ARCHS;
+ try {
+ process.env.AGENT_DEVICE_XCUITEST_ARCHS = 'arm64';
+ const pinned = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
+ delete process.env.AGENT_DEVICE_XCUITEST_ARCHS;
+ const unpinned = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
- const state = await evaluateAgainstCachedMetadata({
- ...expected,
- packageVersion: `${expected.packageVersion}-next`,
- });
+ assert.deepEqual(pinned.runnerArchBuildSettings, ['ARCHS=arm64']);
+ assert.deepEqual(unpinned.runnerArchBuildSettings, []);
+ assert.notDeepEqual(pinned, unpinned);
+ } finally {
+ if (previous === undefined) {
+ delete process.env.AGENT_DEVICE_XCUITEST_ARCHS;
+ } else {
+ process.env.AGENT_DEVICE_XCUITEST_ARCHS = previous;
+ }
+ }
+});
- assert.equal(state.reason, 'reuse_ready');
+test('every runner build compiles the isolation canary, in both test variants', () => {
+ const resolveSwiftFlags = () =>
+ resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).runnerSandboxBuildArgs.find((arg) =>
+ arg.startsWith('OTHER_SWIFT_FLAGS='),
+ );
+ const previous = process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS;
+ try {
+ delete process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS;
+ assert.equal(
+ resolveSwiftFlags(),
+ 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY',
+ );
+
+ process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = '1';
+ // A build that skipped the canary would pass every other gate while the isolation scan
+ // silently had no positive control, so the unit-test variant must carry it too.
+ assert.equal(
+ resolveSwiftFlags(),
+ 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY -D AGENT_DEVICE_RUNNER_UNIT_TESTS',
+ );
+ } finally {
+ restoreEnvVar('AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS', previous);
+ }
});
diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts
index 9b99d107eb..5722bb1517 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts
@@ -1,33 +1,7 @@
-import {
- createRequestCanceledError,
- isRequestCanceledError,
- AppError,
-} from '@agent-device/kernel/errors';
-import type { RequestProgressEvent } from '@agent-device/contracts/progress';
-import { beforeEach, test, onTestFinished, vi } from 'vitest';
+import { onTestFinished, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { mkdtempForTest } from './tmp-dir.ts';
-import { appleRunnerTestHost } from '../test-host.ts';
-
-const mockRunCmdStreaming = vi.fn();
-const mockRunCmdSync = vi.fn();
-const { mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({
- mockRepairMacOsRunnerProductsIfNeeded: vi.fn(),
-}));
-
-vi.mock('../runner-macos-products.ts', async () => {
- const actual = await vi.importActual(
- '../runner-macos-products.ts',
- );
- return {
- ...actual,
- repairMacOsRunnerProductsIfNeeded: mockRepairMacOsRunnerProductsIfNeeded,
- };
-});
-
import type { DeviceInfo } from '@agent-device/kernel/device';
import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts';
import { withRunnerCommandId } from '../runner-contract.ts';
@@ -38,17 +12,18 @@ import {
import {
acquireRunnerXctestrunCacheLock,
assertSafeDerivedCleanup,
- resolveRunnerCacheMetadataPath,
shouldDeleteRunnerDerivedRootEntry,
- writeRunnerCacheMetadata,
} from '../runner-cache.ts';
-import { ensureXctestrunArtifact, xctestrunReferencesProjectRoot } from '../runner-artifact.ts';
import {
- createRunnerPhaseBudget,
- markRunnerXctestrunArtifactBadForRun,
resolveExpectedRunnerCacheMetadata,
resolveRunnerDerivedPath,
} from '../runner-xctestrun.ts';
+import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
+import {
+ REPO_ROOT_FOR_TEST,
+ makeScratchDir,
+ withoutRunnerDerivedPathEnv,
+} from './runner-xctestrun.fixtures.ts';
const iosSimulator: DeviceInfo = {
platform: 'apple',
@@ -94,148 +69,11 @@ const macOsDevice: DeviceInfo = {
booted: true,
};
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
+const repoRoot = REPO_ROOT_FOR_TEST;
-async function makeTmpDir(): Promise {
- const tmpDir = await mkdtempForTest('agent-device-xctestrun-');
- onTestFinished(async () => {
- await fs.promises.rm(tmpDir, { recursive: true, force: true });
- });
- return tmpDir;
-}
-
-async function makeProjectTmpDir(): Promise {
- const tmpRoot = path.join(repoRoot, '.tmp');
- await fs.promises.mkdir(tmpRoot, { recursive: true });
- const tmpDir = await fs.promises.mkdtemp(path.join(tmpRoot, 'agent-device-xctestrun-'));
- onTestFinished(async () => {
- await fs.promises.rm(tmpDir, { recursive: true, force: true });
- });
- return tmpDir;
-}
-
-function writeXctestrunFixture(
- xctestrunPath: string,
- options: { projectRoot: string; productRelativePaths: string[] },
-): void {
- const entries = options.productRelativePaths
- .map((relativePath) => ` __TESTROOT__/${relativePath}`)
- .join('\n');
- fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true });
- fs.writeFileSync(
- xctestrunPath,
- `
-
-
-
- ProjectRootHint
- ${options.projectRoot}
- ProductPaths
-
-${entries}
-
-
-`,
- 'utf8',
- );
-}
-
-function withRunnerDerivedPathEnv(derivedPath: string): void {
- const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
- process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath;
- onTestFinished(() => {
- restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath);
- });
-}
-
-function withoutRunnerDerivedPathEnv(): void {
- const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
- delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
- onTestFinished(() => {
- restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath);
- });
-}
-
-function restoreEnvVar(name: string, value: string | undefined): void {
- if (value === undefined) {
- delete process.env[name];
- return;
- }
- process.env[name] = value;
-}
-
-function stripRunnerCacheArtifacts(metadata: Record): Record {
- const { artifacts: _artifacts, ...rest } = metadata;
- return rest;
-}
-
-function writeRunnerCacheMetadataWithArtifacts(params: {
- derivedPath: string;
- device: DeviceInfo;
- xctestrunPath: string;
- productPaths: string[];
-}): void {
- fs.writeFileSync(
- resolveRunnerCacheMetadataPath(params.derivedPath),
- JSON.stringify(
- {
- ...resolveExpectedRunnerCacheMetadata(params.device, repoRoot),
- artifacts: {
- xctestrunPath: params.xctestrunPath,
- xctestrunMtimeMs: Math.trunc(fs.statSync(params.xctestrunPath).mtimeMs),
- xctestrunSize: fs.statSync(params.xctestrunPath).size,
- productPaths: params.productPaths.map((productPath) => ({
- path: productPath,
- mtimeMs: Math.trunc(fs.statSync(productPath).mtimeMs),
- size: fs.statSync(productPath).size,
- })),
- },
- },
- null,
- 2,
- ),
- );
-}
-
-async function makeCachedRunnerXctestrun(): Promise<{
- derivedPath: string;
- existingXctestrunPath: string;
-}> {
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const existingXctestrunPath = path.join(derivedPath, 'existing.xctestrun');
- await fs.promises.mkdir(derivedPath, { recursive: true });
- await fs.promises.mkdir(path.join(derivedPath, 'Runner.app'), { recursive: true });
- writeXctestrunFixture(existingXctestrunPath, {
- projectRoot: repoRoot,
- productRelativePaths: ['Runner.app'],
- });
- writeRunnerCacheMetadata(derivedPath, resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot));
- return { derivedPath, existingXctestrunPath };
-}
-
-beforeEach(() => {
- vi.resetAllMocks();
- appleRunnerTestHost.update({
- runCmdStreaming: mockRunCmdStreaming,
- runCmdSync: mockRunCmdSync,
- readProcessStartTime: () => 'test-process-start',
- });
- mockRunCmdStreaming.mockResolvedValue(undefined);
- mockRunCmdSync.mockImplementation((command: string, args: string[]) => {
- if (command === 'xcodebuild' && args[0] === '-version') {
- return { exitCode: 0, stdout: 'Xcode 26.2\nBuild version 17C52\n', stderr: '' };
- }
- if (command === 'xcrun' && args.includes('--show-sdk-version')) {
- return { exitCode: 0, stdout: '26.2\n', stderr: '' };
- }
- if (command === 'xcrun' && args.includes('--show-sdk-build-version')) {
- return { exitCode: 0, stdout: '23C53\n', stderr: '' };
- }
- throw new Error(`Unexpected Apple fingerprint command: ${command} ${args.join(' ')}`);
- });
- mockRepairMacOsRunnerProductsIfNeeded.mockResolvedValue(undefined);
-});
+// These cases key the cache without building it, so they answer the cache's toolchain probes
+// from a fixed Xcode instead of the host's.
+stubAppleToolchainProbes();
test('resolveRunnerDestination uses simulator destination for simulators', () => {
assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1');
@@ -326,25 +164,6 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp'
});
});
-test('xctestrunReferencesProjectRoot rejects stale worktree artifacts', async () => {
- const tmpDir = await makeTmpDir();
- const xctestrunPath = path.join(tmpDir, 'AgentDeviceRunner.xctestrun');
- fs.writeFileSync(
- xctestrunPath,
- 'SourceFilesCommonPathPrefix/tmp/other-worktree/agent-device/apple/runner/AgentDeviceRunner',
- 'utf8',
- );
-
- assert.equal(
- xctestrunReferencesProjectRoot(xctestrunPath, '/tmp/current-worktree/agent-device'),
- false,
- );
- assert.equal(
- xctestrunReferencesProjectRoot(xctestrunPath, '/tmp/other-worktree/agent-device'),
- true,
- );
-});
-
test('resolveRunnerDerivedPath keys default cache by runner metadata', () => {
withoutRunnerDerivedPathEnv();
const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator, repoRoot);
@@ -378,7 +197,7 @@ test('resolveRunnerDerivedPath keys default cache by runner metadata', () => {
test('resolveRunnerDerivedPath reuses cache path for identical runner source fingerprints', async () => {
withoutRunnerDerivedPathEnv();
- const tmpDir = await makeTmpDir();
+ const tmpDir = await makeScratchDir();
const firstRoot = path.join(tmpDir, 'first');
const secondRoot = path.join(tmpDir, 'second');
const runnerRelativePath = path.join(
@@ -429,7 +248,7 @@ test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers',
onTestFinished(() => {
vi.useRealTimers();
});
- const tmpDir = await makeTmpDir();
+ const tmpDir = await makeScratchDir();
const derivedPath = path.join(tmpDir, 'derived');
const releaseFirst = await acquireRunnerXctestrunCacheLock(derivedPath);
let secondAcquired = false;
@@ -445,499 +264,6 @@ test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers',
assert.equal(secondAcquired, true);
});
-test('ensureXctestrunArtifact reuses matching manifest artifacts from another project root', async () => {
- const tmpDir = await makeTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const productPath = path.join(derivedPath, 'Runner.app');
- const xctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
- await fs.promises.mkdir(productPath, { recursive: true });
- writeXctestrunFixture(xctestrunPath, {
- projectRoot: '/tmp/other-agent-device-worktree',
- productRelativePaths: ['Runner.app'],
- });
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath,
- productPaths: [productPath],
- });
- withRunnerDerivedPathEnv(derivedPath);
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, xctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
- assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [productPath]);
-});
-
-test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not match', async () => {
- const projectRoot = repoRoot;
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const productPath = path.join(derivedPath, 'Runner.app');
- const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun');
- const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
- await fs.promises.mkdir(productPath, { recursive: true });
- writeXctestrunFixture(foreignXctestrunPath, {
- projectRoot: '/tmp/other-agent-device-worktree',
- productRelativePaths: ['Runner.app'],
- });
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath: foreignXctestrunPath,
- productPaths: [productPath],
- });
- const metadataPath = resolveRunnerCacheMetadataPath(derivedPath);
- const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
- staleMetadata.runnerSandboxBuildArgs = staleMetadata.runnerSandboxBuildArgs.map((arg: string) =>
- arg.startsWith('OTHER_SWIFT_FLAGS=')
- ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'
- : arg,
- );
- fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2));
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRunCmdStreaming.mockImplementation(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, rebuiltXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
- assert.equal(fs.existsSync(foreignXctestrunPath), false);
-});
-
-test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root', async () => {
- const projectRoot = repoRoot;
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const externalDir = path.join(tmpDir, 'external');
- const externalProductPath = path.join(externalDir, 'Runner.app');
- const externalXctestrunPath = path.join(externalDir, 'external.xctestrun');
- const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
- await fs.promises.mkdir(externalProductPath, { recursive: true });
- writeXctestrunFixture(externalXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- await fs.promises.mkdir(derivedPath, { recursive: true });
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath: externalXctestrunPath,
- productPaths: [externalProductPath],
- });
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRunCmdStreaming.mockImplementation(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, rebuiltXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
-});
-
-test('ensureXctestrunArtifact aborts only the disconnected request build and preserves concurrent unrelated builds', async () => {
- // The request AbortSignal must reach the xctestrun build (killProcessTree via
- // runCmdStreaming); removing global abort must not orphan a disconnected prep.
- // Request-scoped: aborting one request's build leaves an unrelated concurrent
- // build (different device -> different derived, different signal) untouched.
- withoutRunnerDerivedPathEnv();
- const canceledDevice = iosSimulator;
- const survivorDevice = macOsDevice;
- for (const device of [canceledDevice, survivorDevice]) {
- const derived = resolveRunnerDerivedPath(
- device,
- resolveExpectedRunnerCacheMetadata(device, repoRoot),
- );
- onTestFinished(async () => {
- await fs.promises.rm(derived, { recursive: true, force: true });
- });
- }
-
- const canceledController = new AbortController();
- const survivorController = new AbortController();
- const deferred = (): { promise: Promise; resolve: (value: T) => void } => {
- let resolve!: (value: T) => void;
- const promise = new Promise((res) => {
- resolve = res;
- });
- return { promise, resolve };
- };
- const waitForAbort = (signal: AbortSignal): Promise =>
- signal.aborted
- ? Promise.resolve()
- : new Promise((resolve) =>
- signal.addEventListener('abort', () => resolve(), {
- once: true,
- }),
- );
- const canceledBuildStarted = deferred();
- const survivorBuildStarted = deferred();
- const releaseSurvivor = deferred();
- const cancellationError = createRequestCanceledError();
-
- mockRunCmdStreaming.mockImplementation(async (_cmd, args, options) => {
- const derived = args[args.indexOf('-derivedDataPath') + 1];
- if (options?.signal === canceledController.signal) {
- canceledBuildStarted.resolve();
- await waitForAbort(options.signal);
- throw cancellationError;
- }
- survivorBuildStarted.resolve();
- await releaseSurvivor.promise;
- await fs.promises.mkdir(path.join(derived, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(path.join(derived, 'rebuilt', 'rebuilt.xctestrun'), {
- projectRoot: repoRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const canceledPromise = ensureXctestrunArtifact(canceledDevice, {
- budget: createRunnerPhaseBudget(undefined, canceledController.signal),
- });
- const survivorPromise = ensureXctestrunArtifact(survivorDevice, {
- budget: createRunnerPhaseBudget(undefined, survivorController.signal),
- });
-
- await Promise.all([canceledBuildStarted.promise, survivorBuildStarted.promise]);
-
- canceledController.abort();
- await assert.rejects(canceledPromise, (error: unknown) => {
- assert.equal(error, cancellationError);
- assert.ok(isRequestCanceledError(error));
- return true;
- });
- // The unrelated concurrent build's signal was never aborted.
- assert.equal(survivorController.signal.aborted, false);
-
- releaseSurvivor.resolve();
- const survivorResult = await survivorPromise;
- assert.ok(survivorResult.xctestrunPath.endsWith('rebuilt.xctestrun'));
-
- const canceledCall = mockRunCmdStreaming.mock.calls.find(
- (call) => call[2]?.signal === canceledController.signal,
- );
- const survivorCall = mockRunCmdStreaming.mock.calls.find(
- (call) => call[2]?.signal === survivorController.signal,
- );
- assert.ok(canceledCall, 'canceled build received its request signal');
- assert.ok(survivorCall, 'survivor build received its request signal');
-});
-
-test('ensureXctestrunArtifact rebuilds after cached macOS runner repair failure', async () => {
- // Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once,
- // rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state.
- const projectRoot = repoRoot;
- const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun();
- const projectPath = path.join(
- projectRoot,
- 'apple',
- 'runner',
- 'AgentDeviceRunner',
- 'AgentDeviceRunner.xcodeproj',
- );
-
- const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
-
- withRunnerDerivedPathEnv(derivedPath);
-
- const repairedPaths: string[] = [];
-
- mockRepairMacOsRunnerProductsIfNeeded.mockImplementation(
- async (_device, _productPaths, xctestrunPath) => {
- repairedPaths.push(xctestrunPath);
- if (xctestrunPath === existingXctestrunPath) {
- throw new AppError('COMMAND_FAILED', 'cached runner is damaged', {
- reason: 'RUNNER_PRODUCT_REPAIR_FAILED',
- });
- }
- },
- );
- mockRunCmdStreaming.mockImplementation(async (command, args) => {
- assert.equal(command, 'xcodebuild');
- assert.ok(Array.isArray(args));
- assert.equal(args[args.indexOf('-project') + 1], projectPath);
- assert.equal(args[args.indexOf('-derivedDataPath') + 1], derivedPath);
- await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, rebuiltXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
- assert.equal(fs.existsSync(existingXctestrunPath), false);
- assert.deepEqual(repairedPaths, [existingXctestrunPath, rebuiltXctestrunPath]);
-});
-
-test('ensureXctestrunArtifact prefers validated cache manifest over recursive scan', async () => {
- const tmpDir = await makeTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app');
- const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
- const newerProductPath = path.join(derivedPath, 'NewerRunner.app');
- const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun');
- await fs.promises.mkdir(manifestProductPath, { recursive: true });
- await fs.promises.mkdir(newerProductPath, { recursive: true });
- writeXctestrunFixture(manifestXctestrunPath, {
- projectRoot: repoRoot,
- productRelativePaths: ['ManifestRunner.app'],
- });
- writeXctestrunFixture(newerXctestrunPath, {
- projectRoot: repoRoot,
- productRelativePaths: ['NewerRunner.app'],
- });
- const now = new Date();
- fs.utimesSync(manifestXctestrunPath, now, now);
- fs.utimesSync(
- newerXctestrunPath,
- new Date(now.getTime() + 5_000),
- new Date(now.getTime() + 5_000),
- );
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath: manifestXctestrunPath,
- productPaths: [manifestProductPath],
- });
- withRunnerDerivedPathEnv(derivedPath);
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, manifestXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
- assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]);
-});
-
-test('ensureXctestrunArtifact falls back to scan when cache manifest is stale', async () => {
- const tmpDir = await makeTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app');
- const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun');
- const newerProductPath = path.join(derivedPath, 'NewerRunner.app');
- const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun');
- await fs.promises.mkdir(manifestProductPath, { recursive: true });
- await fs.promises.mkdir(newerProductPath, { recursive: true });
- writeXctestrunFixture(manifestXctestrunPath, {
- projectRoot: repoRoot,
- productRelativePaths: ['ManifestRunner.app'],
- });
- writeXctestrunFixture(newerXctestrunPath, {
- projectRoot: repoRoot,
- productRelativePaths: ['NewerRunner.app'],
- });
- const now = new Date();
- fs.utimesSync(manifestXctestrunPath, now, now);
- fs.utimesSync(
- newerXctestrunPath,
- new Date(now.getTime() + 5_000),
- new Date(now.getTime() + 5_000),
- );
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath: manifestXctestrunPath,
- productPaths: [manifestProductPath],
- });
- fs.utimesSync(
- manifestProductPath,
- new Date(now.getTime() + 10_000),
- new Date(now.getTime() + 10_000),
- );
- withRunnerDerivedPathEnv(derivedPath);
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, newerXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
- assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [newerProductPath]);
-});
-
-test('ensureXctestrunArtifact rebuilds cached runner when Swift build flags mismatch', async () => {
- const projectRoot = repoRoot;
- const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun();
- const metadataPath = resolveRunnerCacheMetadataPath(derivedPath);
- const expectedMetadata = resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot);
- const staleMetadata = {
- ...expectedMetadata,
- runnerSandboxBuildArgs: expectedMetadata.runnerSandboxBuildArgs.map((arg) =>
- arg.startsWith('OTHER_SWIFT_FLAGS=')
- ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'
- : arg,
- ),
- };
- fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2));
-
- const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
-
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRunCmdStreaming.mockImplementation(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath;
-
- assert.equal(result, rebuiltXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
- assert.equal(fs.existsSync(existingXctestrunPath), false);
- const rebuiltMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
- assert.deepEqual(
- stripRunnerCacheArtifacts(rebuiltMetadata),
- resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot),
- );
- assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath);
-});
-
-test('ensureXctestrunArtifact passes sandbox-disabling settings to xcodebuild', async () => {
- const projectRoot = repoRoot;
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun');
-
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRunCmdStreaming.mockImplementationOnce(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'Build', 'Products', 'Runner.app'), {
- recursive: true,
- });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const result = await ensureXctestrunArtifact(iosSimulator, {
- forceRunnerXctestrunRebuild: true,
- });
-
- assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
- const args = mockRunCmdStreaming.mock.calls[0]?.[1] ?? [];
- assert.equal(args.includes('-IDEPackageSupportDisableManifestSandbox=1'), true);
- assert.equal(args.includes('-IDEPackageSupportDisablePluginExecutionSandbox=1'), true);
- assert.equal(args.includes('ENABLE_USER_SCRIPT_SANDBOXING=NO'), true);
- assert.equal(args.includes('OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox'), true);
-});
-
-test('ensureXctestrunArtifact emits build progress on cache miss', async () => {
- const projectRoot = repoRoot;
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun');
- const events: RequestProgressEvent[] = [];
-
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRunCmdStreaming.mockImplementationOnce(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'Build', 'Products', 'Runner.app'), {
- recursive: true,
- });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- appleRunnerTestHost.update({ emitRequestProgress: (event) => events.push(event) });
-
- const result = await ensureXctestrunArtifact(iosSimulator, {
- forceRunnerXctestrunRebuild: true,
- });
-
- assert.equal(result.xctestrunPath, rebuiltXctestrunPath);
- assert.deepEqual(events, [
- {
- type: 'command',
- status: 'progress',
- message: 'Building Apple runner...',
- },
- ]);
-});
-
-test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => {
- const projectRoot = repoRoot;
- const tmpDir = await makeProjectTmpDir();
- const derivedPath = path.join(tmpDir, 'custom-derived');
- const productPath = path.join(derivedPath, 'Runner.app');
- const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun');
- await fs.promises.mkdir(productPath, { recursive: true });
- writeXctestrunFixture(cachedXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- writeRunnerCacheMetadataWithArtifacts({
- derivedPath,
- device: macOsDevice,
- xctestrunPath: cachedXctestrunPath,
- productPaths: [productPath],
- });
- withRunnerDerivedPathEnv(derivedPath);
-
- const hit = await ensureXctestrunArtifact(macOsDevice, {});
-
- assert.equal(hit.xctestrunPath, cachedXctestrunPath);
- assert.equal(hit.cache, 'exact');
- assert.equal(hit.artifact, 'valid');
- assert.equal(hit.buildMs, 0);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
-
- await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed');
- assert.equal(fs.existsSync(cachedXctestrunPath), false);
-
- const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
- mockRunCmdStreaming.mockImplementationOnce(async () => {
- await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
- writeXctestrunFixture(rebuiltXctestrunPath, {
- projectRoot,
- productRelativePaths: ['Runner.app'],
- });
- });
-
- const rebuilt = await ensureXctestrunArtifact(macOsDevice, {
- budget: createRunnerPhaseBudget(300_000, undefined),
- });
-
- assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath);
- assert.equal(rebuilt.cache, 'miss');
- assert.equal(rebuilt.artifact, 'rebuilt');
- assert.equal(rebuilt.reason, 'missing_xctestrun');
- assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
- assert.equal(Math.ceil(Number(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs) / 1e3), 300); // phase remainder (#2422)
-});
-
-test('ensureXctestrunArtifact rethrows unexpected cached macOS runner repair errors', async () => {
- const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun();
-
- withRunnerDerivedPathEnv(derivedPath);
-
- mockRepairMacOsRunnerProductsIfNeeded.mockRejectedValue(new Error('permission denied'));
-
- await assert.rejects(ensureXctestrunArtifact(macOsDevice, {}), /permission denied/);
- assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
- assert.equal(fs.existsSync(existingXctestrunPath), true);
-});
-
test('shouldDeleteRunnerDerivedRootEntry only removes known xcode transient entries', () => {
assert.equal(shouldDeleteRunnerDerivedRootEntry('Build'), true);
assert.equal(shouldDeleteRunnerDerivedRootEntry('Logs'), true);
diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts
index e1eb003d7e..140d1eff22 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts
@@ -87,7 +87,7 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea
test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => {
const restoredArtifact = makeRunnerArtifact({
xctestrunPath: '/tmp/restored.xctestrun',
- cache: 'restore-key',
+ cache: 'exact',
artifact: 'valid',
});
const rebuiltArtifact = makeRunnerArtifact({
diff --git a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts
index 116a51748e..8d93d4f0e0 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts
@@ -111,6 +111,71 @@ test('computeRunnerSourceFingerprint ignores development-only SwiftPM trees but
assert.notEqual(computeRunnerSourceFingerprint(root), afterIgnoredChanges);
});
+test('computeRunnerSourceFingerprint covers Xcode project, scheme, and workspace files', () => {
+ const root = makeTempRoot();
+ const projectRoot = path.join(root, 'apple', 'runner', 'AgentDeviceRunner');
+ const projectPackage = path.join(projectRoot, 'AgentDeviceRunner.xcodeproj');
+ const scheme = path.join(
+ projectPackage,
+ 'xcshareddata',
+ 'xcschemes',
+ 'AgentDeviceRunner.xcscheme',
+ );
+ const workspaceData = path.join(
+ projectPackage,
+ 'project.xcworkspace',
+ 'contents.xcworkspacedata',
+ );
+ const pbxproj = path.join(projectPackage, 'project.pbxproj');
+ fs.mkdirSync(path.dirname(scheme), { recursive: true });
+ fs.mkdirSync(path.dirname(workspaceData), { recursive: true });
+ fs.writeFileSync(path.join(projectRoot, 'Runner.swift'), 'runner\n');
+ fs.writeFileSync(pbxproj, '// Begin project\n');
+ fs.writeFileSync(scheme, '\n');
+ fs.writeFileSync(workspaceData, '\n');
+
+ const before = computeRunnerSourceFingerprint(root);
+
+ fs.writeFileSync(pbxproj, '// Begin project\n// membership changed\n');
+ const afterPbxproj = computeRunnerSourceFingerprint(root);
+ assert.notEqual(afterPbxproj, before);
+
+ fs.writeFileSync(scheme, `\n`);
+ const afterScheme = computeRunnerSourceFingerprint(root);
+ assert.notEqual(afterScheme, afterPbxproj);
+
+ fs.writeFileSync(workspaceData, `\n`);
+ assert.notEqual(computeRunnerSourceFingerprint(root), afterScheme);
+});
+
+test('computeRunnerSourceFingerprint ignores per-user Xcode state', () => {
+ const root = makeTempRoot();
+ const projectPackage = path.join(
+ root,
+ 'apple',
+ 'runner',
+ 'AgentDeviceRunner',
+ 'AgentDeviceRunner.xcodeproj',
+ );
+ const userScheme = path.join(
+ projectPackage,
+ 'xcuserdata',
+ 'someone.xcuserdatad',
+ 'xcschemes',
+ 'AgentDeviceRunner.xcscheme',
+ );
+ fs.mkdirSync(path.dirname(userScheme), { recursive: true });
+ fs.writeFileSync(path.join(projectPackage, 'project.pbxproj'), '// project\n');
+ fs.writeFileSync(userScheme, '\n');
+
+ const before = computeRunnerSourceFingerprint(root);
+ fs.writeFileSync(userScheme, `\n`);
+ fs.writeFileSync(path.join(projectPackage, '.DS_Store'), 'finder\n');
+ fs.writeFileSync(path.join(projectPackage, 'Workspace.xcuserstate'), 'xcode-user-state\n');
+
+ assert.equal(computeRunnerSourceFingerprint(root), before);
+});
+
const IGNORED_SOURCE_DIRECTORY_NAMES = [
'Tests',
'SnapshotPresentationConformance',
diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts
new file mode 100644
index 0000000000..4386a3844b
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts
@@ -0,0 +1,179 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { onTestFinished } from 'vitest';
+import { fileURLToPath } from 'node:url';
+import { mkdtempForTest } from './tmp-dir.ts';
+import type { DeviceInfo } from '@agent-device/kernel/device';
+import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts';
+import type { RunnerCacheRefusal } from '../runner-artifact-manifest.ts';
+import { resolveExpectedRunnerCacheMetadata } from '../runner-xctestrun.ts';
+
+// Scratch trees and certified runner products shared by the tests that exercise
+// cache reuse: a case needs an `.xctestrun` naming a product bundle whose bytes
+// a manifest certifies, which is more than `writeXctestrunFixture` alone gives.
+
+export const REPO_ROOT_FOR_TEST = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '../../../../..',
+);
+
+/** A scratch directory removed when the calling test finishes. */
+export async function makeScratchDir(): Promise {
+ const tmpDir = await mkdtempForTest('agent-device-xctestrun-');
+ onTestFinished(async () => {
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
+ });
+ return tmpDir;
+}
+
+/**
+ * A scratch directory under the repository's own `.tmp`, for cases whose build must resolve
+ * the runner project — `ensureXctestrunArtifact` refuses to start one without it.
+ */
+export async function makeProjectScratchDir(): Promise {
+ const tmpRoot = path.join(REPO_ROOT_FOR_TEST, '.tmp');
+ await fs.promises.mkdir(tmpRoot, { recursive: true });
+ const tmpDir = await fs.promises.mkdtemp(path.join(tmpRoot, 'agent-device-xctestrun-'));
+ onTestFinished(async () => {
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
+ });
+ return tmpDir;
+}
+
+export function writeXctestrunFixture(
+ xctestrunPath: string,
+ options: { projectRoot: string; productRelativePaths: string[] },
+): void {
+ // Paths reach this plist as text, and a checkout directory can carry an `&` or a `'` in its
+ // name. Unescaped, the plist would be malformed and the product-path reader would find nothing,
+ // so the case under test would silently exercise a different path.
+ const entries = options.productRelativePaths
+ .map(
+ (relativePath) => ` ${escapeXmlText(`__TESTROOT__/${relativePath}`)}`,
+ )
+ .join('\n');
+ fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true });
+ fs.writeFileSync(
+ xctestrunPath,
+ `
+
+
+
+ ProjectRootHint
+ ${escapeXmlText(options.projectRoot)}
+ ProductPaths
+
+${entries}
+
+
+`,
+ 'utf8',
+ );
+}
+
+function escapeXmlText(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+export function withRunnerDerivedPathEnv(derivedPath: string): void {
+ const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+ process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath;
+ onTestFinished(() => {
+ restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath);
+ });
+}
+
+export function withoutRunnerDerivedPathEnv(): void {
+ const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+ delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+ onTestFinished(() => {
+ restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath);
+ });
+}
+
+export function restoreEnvVar(name: string, value: string | undefined): void {
+ if (value === undefined) {
+ delete process.env[name];
+ return;
+ }
+ process.env[name] = value;
+}
+
+export function stripRunnerCacheArtifacts(
+ metadata: Record,
+): Record {
+ const { artifacts: _artifacts, ...rest } = metadata;
+ return rest;
+}
+
+/**
+ * A content manifest certifies bytes, so a fixture product needs some. Mirrors what a build
+ * leaves behind: a bundle directory holding an executable.
+ */
+/** The bytes a fixture product bundle holds, exported so a tamper can keep the same length. */
+export const RUNNER_FIXTURE_EXECUTABLE_BYTES = Buffer.from('runner-executable\n');
+
+export async function seedRunnerProductBundle(bundlePath: string): Promise {
+ await fs.promises.mkdir(bundlePath, { recursive: true });
+ await fs.promises.writeFile(
+ path.join(bundlePath, path.basename(bundlePath, '.app')),
+ RUNNER_FIXTURE_EXECUTABLE_BYTES,
+ { mode: 0o755 },
+ );
+}
+
+/**
+ * Publishes the metadata the production writer would, so a fixture tree is really certified.
+ * Returns the refusal when the tree cannot be certified, which callers assert rather than ignore.
+ */
+export async function writeRunnerCacheMetadataWithArtifacts(params: {
+ derivedPath: string;
+ device: DeviceInfo;
+ xctestrunPath: string;
+ productPaths: string[];
+}): Promise {
+ return writeRunnerCacheMetadataForArtifacts(
+ params.derivedPath,
+ resolveExpectedRunnerCacheMetadata(params.device, REPO_ROOT_FOR_TEST),
+ params.xctestrunPath,
+ params.productPaths,
+ );
+}
+
+/**
+ * A macOS runner product tree that already carries a valid manifest, the shape a restored
+ * cache arrives in. Under the repository's `.tmp` so the build it can fall back to resolves
+ * the runner project.
+ */
+export async function makeCachedRunnerXctestrun(device: DeviceInfo): Promise<{
+ derivedPath: string;
+ existingXctestrunPath: string;
+}> {
+ const tmpDir = await makeProjectScratchDir();
+ const derivedPath = path.join(tmpDir, 'custom-derived');
+ const existingXctestrunPath = path.join(derivedPath, 'existing.xctestrun');
+ await fs.promises.mkdir(derivedPath, { recursive: true });
+ await seedRunnerProductBundle(path.join(derivedPath, 'Runner.app'));
+ writeXctestrunFixture(existingXctestrunPath, {
+ projectRoot: REPO_ROOT_FOR_TEST,
+ productRelativePaths: ['Runner.app'],
+ });
+ // The reuse tests that take this tree are asserting behaviour on a CERTIFIED cache; an
+ // uncertifiable one would silently exercise a different scenario than they set up.
+ assert.equal(
+ await writeRunnerCacheMetadataWithArtifacts({
+ derivedPath,
+ device,
+ xctestrunPath: existingXctestrunPath,
+ productPaths: [path.join(derivedPath, 'Runner.app')],
+ }),
+ null,
+ );
+ return { derivedPath, existingXctestrunPath };
+}
diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts
index be8a4d2ba6..a10054ea65 100644
--- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts
+++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts
@@ -1,21 +1,23 @@
import { test, vi, beforeEach } from 'vitest';
import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
// oxlint-disable-next-line no-restricted-imports -- mirrors production's os.tmpdir xctestrun path
import os from 'node:os';
import path from 'node:path';
-import { pathToFileURL } from 'node:url';
import { mkdtempForTestSync } from './tmp-dir.ts';
import {
buildRunnerSessionXctestrunPathCleanupPattern,
buildRunnerSessionXctestrunSuffix,
} from '../runner-artifact-env.ts';
+import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts';
+import { writeXctestrunFixture } from './runner-xctestrun.fixtures.ts';
import { appleRunnerTestHost } from '../test-host.ts';
import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command';
-// This script runs outside the package's host abstraction (it is invoked directly via
-// dynamic import in the "setup metadata script" test below) and calls node:child_process
-// execFileSync itself, so it cannot be faked through a host override; the module mock stays.
+// The toolchain probes reach the exec layer through `execFileSync`, which the package's host
+// seam does not wrap; the module mock keeps a test from shelling out to a real Xcode.
const { mockExecFileSync } = vi.hoisted(() => ({
mockExecFileSync: vi.fn(),
}));
@@ -29,6 +31,9 @@ const mockRunCmdSync = vi.fn();
import type { DeviceInfo } from '@agent-device/kernel/device';
import { findXctestrun, scoreXctestrunCandidate } from '../runner-artifact.ts';
+import { evaluateExistingXctestrun } from '../runner-cache.ts';
+import { resolveExistingXctestrunProductPaths } from '../runner-xctestrun-products.ts';
+import type { RunnerXctestrunCacheArtifacts } from '../runner-cache-metadata.ts';
import {
ensureXctestrunArtifact,
markRunnerXctestrunArtifactBadForRun,
@@ -37,6 +42,8 @@ import {
resolveRunnerDerivedPath,
} from '../runner-xctestrun.ts';
+const repoRoot = process.cwd();
+
const iosSimulator: DeviceInfo = {
platform: 'apple',
id: 'sim-1',
@@ -205,73 +212,286 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula
assert.ok(simulatorScore > macosEnvScore);
});
-test('setup metadata script matches expected iOS simulator cache metadata', async () => {
+/**
+ * The build script and the metadata writer are separate processes with one contract: the recipe
+ * `xcuitest-build-settings.ts` hands `xcodebuild` is the recipe the published identity records,
+ * and the manifest certifies the bytes that build left on disk. Both scripts are driven the way
+ * `build-xcuitest-apple.sh` drives them — real processes, a stand-in toolchain on PATH, and a
+ * build log echoing the settings it was handed — because the scripts import the composition
+ * root, which cannot share a process with this suite's test host.
+ */
+test('the build script and the metadata writer publish one iOS simulator identity', async () => {
await withTempDir('runner-cache-metadata-', async (root) => {
- const repoRoot = process.cwd();
- const scriptPath = path.join(repoRoot, 'scripts', 'write-xcuitest-cache-metadata.mjs');
- const projectRoot = path.join(root, 'project');
- const derivedRoot = path.join(root, 'derived');
- fs.mkdirSync(derivedRoot, { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), {
- recursive: true,
- });
- fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"version":"0.19.0"}\n');
- fs.writeFileSync(
- path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'),
- 'final class Runner {}\n',
- );
- const runnerUnitTest = path.join(
- projectRoot,
- 'apple',
- 'runner',
- 'AgentDeviceRunner',
- 'AgentDeviceRunnerUITests',
- 'UnitTests',
- 'Invariant.swift',
+ const project = seedRunnerBuildFixture(root);
+ const buildSettings = runBuildSettings(project);
+ fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildArguments(buildSettings));
+
+ const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [
+ 'ios',
+ project.derivedPath,
+ project.destination,
+ project.buildLogPath,
+ project.bin,
+ ]);
+ assert.equal(written.status, 0, written.stderr);
+
+ const published = readRunnerCacheManifest(project.derivedPath);
+ const { artifacts: _artifacts, ...publishedIdentity } = published;
+ assert.deepEqual(
+ stripVolatile(publishedIdentity),
+ stripVolatile(resolveExpectedRunnerCacheMetadata(iosSimulator, project.root)),
);
- fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true });
- fs.writeFileSync(runnerUnitTest, 'unit-one\n');
- const ignoredSharedSource = path.join(
- projectRoot,
- 'apple',
- 'snapshot-presentation',
- 'Tests',
- 'Ignored.swift',
+
+ assert.ok(artifactsOf(published).xctestrunDigest);
+ const entries = new Map(
+ artifactsOf(published).entries.map((entry: any) => [entry.path, entry]),
);
- fs.mkdirSync(path.dirname(ignoredSharedSource), { recursive: true });
- fs.writeFileSync(ignoredSharedSource, 'ignored-one\n');
- const { writeXcuitestCacheMetadata } = await import(
- `${pathToFileURL(scriptPath).href}?case=${Date.now()}`
+ const executableEntry = entries.get(
+ path.relative(project.derivedPath, project.executablePath).replaceAll(path.sep, '/'),
);
- const firstMetadata = writeXcuitestCacheMetadata(
- ['ios', derivedRoot, 'generic/platform=iOS Simulator'],
- projectRoot,
+ assert.ok(executableEntry, 'the manifest must list the runner executable');
+ assert.equal(executableEntry!.mode, 0o755);
+ assert.equal(
+ executableEntry!.digest,
+ crypto.createHash('sha256').update(fs.readFileSync(project.executablePath)).digest('hex'),
);
- const actual = JSON.parse(
- fs.readFileSync(path.join(derivedRoot, '.agent-device-runner-cache.json'), 'utf8'),
+ // What the daemon will do with a restored tree: the manifest it just read must certify it.
+ const state = await evaluateExistingXctestrun({
+ derived: project.derivedPath,
+ expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(iosSimulator, project.root),
+ });
+ assert.equal(state.reason, 'reuse_ready');
+
+ // The same files must keep reporting one fingerprint while only ignored sources change.
+ fs.writeFileSync(project.ignoredSharedSource, 'ignored-two\n');
+ assert.equal(
+ resolveExpectedRunnerCacheMetadata(iosSimulator, project.root).runnerSourceFingerprint,
+ published.runnerSourceFingerprint,
+ );
+ fs.writeFileSync(project.runnerUnitTest, 'unit-two\n');
+ assert.notEqual(
+ resolveExpectedRunnerCacheMetadata(iosSimulator, project.root).runnerSourceFingerprint,
+ published.runnerSourceFingerprint,
);
- const { artifacts: _actualArtifacts, ...actualComparable } = actual;
- const { artifacts: _expectedArtifacts, ...expectedComparable } =
- resolveExpectedRunnerCacheMetadata(iosSimulator, projectRoot);
+ });
+}, 120_000);
+
+test('an .xctestrun fixture holding paths that need XML escaping names those products', async () => {
+ // CI checkouts and fork names can carry `&` or `'`. Written into the plist unescaped they make
+ // it malformed, and the reuse cases built on this fixture would silently exercise a rebuild
+ // instead of the path they name.
+ const root = mkdtempForTestSync('agent-device-xctestrun-escaping-');
+ const projectRoot = path.join(root, "a & b's ");
+ const derivedPath = path.join(projectRoot, '.tmp', 'derived');
+ const bundlePath = path.join(
+ derivedPath,
+ 'Build',
+ 'Products',
+ 'Debug-iphonesimulator',
+ "Runner & Co's.app",
+ );
+ await fs.promises.mkdir(bundlePath, { recursive: true });
+ await fs.promises.writeFile(path.join(bundlePath, 'Runner'), 'runner', { mode: 0o755 });
+ const xctestrunPath = path.join(derivedPath, 'Build', 'Products', 'Runner.xctestrun');
+ writeXctestrunFixture(xctestrunPath, {
+ projectRoot,
+ productRelativePaths: ["Debug-iphonesimulator/Runner & Co's.app"],
+ });
- assert.deepEqual(actualComparable, expectedComparable);
+ assert.deepEqual(await resolveExistingXctestrunProductPaths(xctestrunPath), [bundlePath]);
+});
- fs.writeFileSync(ignoredSharedSource, 'ignored-two\n');
- const secondMetadata = writeXcuitestCacheMetadata(
- ['ios', derivedRoot, 'generic/platform=iOS Simulator'],
- projectRoot,
+test('the metadata writer refuses a build log whose recipe it did not record', async () => {
+ await withTempDir('runner-cache-metadata-', async (root) => {
+ const project = seedRunnerBuildFixture(root);
+ const drifted = runBuildSettings(project).filter(
+ (setting) => !setting.startsWith('ONLY_ACTIVE_ARCH='),
);
- assert.equal(secondMetadata.runnerSourceFingerprint, firstMetadata.runnerSourceFingerprint);
-
- fs.writeFileSync(runnerUnitTest, 'unit-two\n');
- const thirdMetadata = writeXcuitestCacheMetadata(
- ['ios', derivedRoot, 'generic/platform=iOS Simulator'],
- projectRoot,
+ fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildArguments(drifted));
+
+ const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [
+ 'ios',
+ project.derivedPath,
+ project.destination,
+ project.buildLogPath,
+ project.bin,
+ ]);
+ assert.notEqual(written.status, 0);
+ assert.match(written.stderr, /did not use the settings its cache identity records/);
+ assert.equal(
+ fs.existsSync(path.join(project.derivedPath, '.agent-device-runner-cache.json')),
+ false,
);
- assert.notEqual(thirdMetadata.runnerSourceFingerprint, secondMetadata.runnerSourceFingerprint);
});
-}, 15_000);
+}, 120_000);
+
+type RunnerBuildFixture = {
+ root: string;
+ bin: string;
+ derivedPath: string;
+ buildLogPath: string;
+ destination: string;
+ executablePath: string;
+ runnerUnitTest: string;
+ ignoredSharedSource: string;
+};
+
+/**
+ * A project root plus the DerivedData a successful `build-for-testing` would leave behind:
+ * an `.xctestrun` naming a product bundle whose executable is on disk.
+ */
+function seedRunnerBuildFixture(root: string): RunnerBuildFixture {
+ const projectRoot = path.join(root, 'project');
+ const derivedPath = path.join(root, 'derived');
+ const productsRoot = path.join(derivedPath, 'Build', 'Products');
+ const runnerAppPath = path.join(productsRoot, 'Debug-iphonesimulator', 'Runner-Runner.app');
+ const executablePath = path.join(runnerAppPath, 'Runner');
+ const xctestrunPath = path.join(
+ productsRoot,
+ 'AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun',
+ );
+ fs.mkdirSync(runnerAppPath, { recursive: true });
+ fs.writeFileSync(executablePath, Buffer.from('runner-executable\n'), { mode: 0o755 });
+ fs.mkdirSync(path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true });
+ fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"version":"0.19.0"}\n');
+ fs.writeFileSync(
+ path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'),
+ 'final class Runner {}\n',
+ );
+ const runnerUnitTest = path.join(
+ projectRoot,
+ 'apple',
+ 'runner',
+ 'AgentDeviceRunner',
+ 'AgentDeviceRunnerUITests',
+ 'UnitTests',
+ 'Invariant.swift',
+ );
+ fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true });
+ fs.writeFileSync(runnerUnitTest, 'unit-one\n');
+ const ignoredSharedSource = path.join(
+ projectRoot,
+ 'apple',
+ 'snapshot-presentation',
+ 'Tests',
+ 'Ignored.swift',
+ );
+ fs.mkdirSync(path.dirname(ignoredSharedSource), { recursive: true });
+ fs.writeFileSync(ignoredSharedSource, 'ignored-one\n');
+ fs.writeFileSync(
+ xctestrunPath,
+ `
+
+
+
+ ProjectRootHint
+ ${projectRoot}
+ ProductPaths
+
+ __TESTROOT__/Debug-iphonesimulator/Runner-Runner.app
+
+
+`,
+ );
+ const buildLogPath = path.join(derivedPath, 'Logs', 'agent-device-build-for-testing.log');
+ fs.mkdirSync(path.dirname(buildLogPath), { recursive: true });
+ return {
+ root: projectRoot,
+ bin: fakeAppleToolchainBin(root),
+ derivedPath,
+ buildLogPath,
+ destination: 'generic/platform=iOS Simulator',
+ executablePath,
+ runnerUnitTest,
+ ignoredSharedSource,
+ };
+}
+
+/**
+ * The three tools the scripts exec: `xcodebuild -version` and two `xcrun --sdk` probes, answering
+ * exactly what this suite's fingerprint stub reports so the child and parent identities agree.
+ */
+function fakeAppleToolchainBin(root: string): string {
+ const bin = path.join(root, 'bin');
+ fs.mkdirSync(bin, { recursive: true });
+ writeFakeTool(
+ bin,
+ 'xcodebuild',
+ String.raw`{ printf 'Xcode 26.2
+'; printf 'Build version 17C52
+'; }`,
+ );
+ writeFakeTool(
+ bin,
+ 'xcrun',
+ String.raw`{ for arg in "$@"; do
+ if [ "$arg" = "--show-sdk-build-version" ]; then printf "23C53
+"; exit 0; fi
+done
+printf "26.2
+"; }`,
+ );
+ return bin;
+}
+
+function writeFakeTool(bin: string, name: string, body: string): void {
+ fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`);
+ fs.chmodSync(path.join(bin, name), 0o755);
+}
+
+/** The recipe the emitter hands `xcodebuild`, read the way the build script reads it. */
+function runBuildSettings(project: RunnerBuildFixture): string[] {
+ const emitted = runScript(project.root, 'xcuitest-build-settings.ts', [
+ 'ios',
+ project.destination,
+ project.bin,
+ ]);
+ assert.equal(emitted.status, 0, emitted.stderr);
+ return emitted.stdout.split('\n').filter((line: string) => line !== '');
+}
+
+/** Runs one repository script in a real process, the way the build script does. */
+function runScript(
+ cwd: string,
+ script: string,
+ args: readonly string[],
+): {
+ status: number;
+ stdout: string;
+ stderr: string;
+} {
+ const fakeBin = args.at(-1)!;
+ const rest = args.slice(0, -1);
+ const result = spawnSync(
+ process.execPath,
+ ['--experimental-strip-types', path.join(repoRoot, 'scripts', script), ...rest],
+ {
+ cwd,
+ encoding: 'utf8',
+ env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}` },
+ },
+ );
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
+}
+
+function readRunnerCacheManifest(derivedPath: string): any {
+ return JSON.parse(
+ fs.readFileSync(path.join(derivedPath, '.agent-device-runner-cache.json'), 'utf8'),
+ );
+}
+
+function artifactsOf(manifest: any): RunnerXctestrunCacheArtifacts {
+ if (!manifest.artifacts) {
+ throw new Error('The writer must publish an artifact manifest alongside the identity.');
+ }
+ return manifest.artifacts as RunnerXctestrunCacheArtifacts;
+}
+
+function stripVolatile(metadata: Record): Record {
+ const { packageVersion: _packageVersion, ...rest } = metadata;
+ return rest;
+}
test('runner cache key ignores package version but honors toolchain and SDK changes', () => {
const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator);
diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts
index 5bf365141f..3a52ebe6a0 100644
--- a/packages/platform-apple/src/runner/apple-runner-platform.ts
+++ b/packages/platform-apple/src/runner/apple-runner-platform.ts
@@ -127,6 +127,96 @@ const RUNNER_PLATFORM_PROFILES: Record
+> = {
+ ios: 'mobile',
+ macos: 'desktop',
+ tvos: 'tv',
+ visionos: 'mobile',
+};
+
+const RUNNER_SCRIPT_APPLE_OS: Record<
+ RunnerXcuitestScriptPlatform,
+ NonNullable
+> = {
+ ios: 'ios',
+ macos: 'macos',
+ tvos: 'tvos',
+ visionos: 'visionos',
+};
+
+/**
+ * The device a build-script invocation stands in for: the script names its platform as a
+ * literal and a destination string, and the cache metadata owner speaks `DeviceInfo`.
+ * Resolving identity through this one mapping keeps a script-written manifest comparable to
+ * the metadata a daemon resolves for the same build.
+ */
+export function resolveRunnerScriptDevice(
+ platform: RunnerXcuitestScriptPlatform,
+ destination: string,
+): DeviceInfo {
+ return {
+ platform: 'apple',
+ id: `runner-script-${platform}`,
+ name: `Apple runner build script (${platform})`,
+ kind: platform === 'macos' ? 'device' : resolveRunnerScriptDestinationKind(destination),
+ target: RUNNER_SCRIPT_TARGET[platform],
+ appleOs: RUNNER_SCRIPT_APPLE_OS[platform],
+ };
+}
+
+/**
+ * Whether an `xcodebuild -destination` string names a simulator, read from its `platform=` token
+ * rather than a substring of the whole string: `xcodebuild` accepts `platform=iOS simulator` in
+ * any casing, and a destination that merely happens to contain the word would otherwise certify a
+ * simulator-SDK build under a physical-device identity that agrees with it key for key.
+ *
+ * A destination with no `platform=` token — a bare `id=` works for `xcodebuild` — leaves the
+ * SDK choice to the scheme, so no identity can be recorded for it and the build is refused.
+ */
+function resolveRunnerScriptDestinationKind(destination: string): DeviceInfo['kind'] {
+ const platformToken = readDestinationPlatformToken(destination);
+ if (platformToken === undefined) {
+ throw new AppError(
+ 'INVALID_ARGS',
+ 'The Apple runner build destination must name its platform',
+ {
+ destination,
+ hint: 'Pass a destination with a platform= token, e.g. generic/platform=iOS Simulator for a simulator or generic/platform=iOS for a physical device.',
+ },
+ );
+ }
+ const named = platformToken.toLowerCase();
+ return named.endsWith('simulator') ? 'simulator' : 'device';
+}
+
+function readDestinationPlatformToken(destination: string): string | undefined {
+ for (const clause of destination.split(',')) {
+ const separator = clause.indexOf('=');
+ if (separator < 0) continue;
+ const key = clause
+ .slice(0, separator)
+ .trim()
+ .toLowerCase()
+ .replace(/^generic\//, '');
+ if (key === 'platform') {
+ return clause.slice(separator + 1).trim() || undefined;
+ }
+ }
+ return undefined;
+}
+
export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName {
if (!isApplePlatform(device.platform)) {
throw new AppError(
diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts
new file mode 100644
index 0000000000..b9a8486291
--- /dev/null
+++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts
@@ -0,0 +1,663 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import type {
+ RunnerCacheArtifactEntry,
+ RunnerCacheArtifactFileEntry,
+ RunnerCacheArtifactSymlinkEntry,
+ RunnerXctestrunCacheArtifacts,
+ RunnerXctestrunCacheMetadata,
+} from './runner-cache-metadata.ts';
+
+/** Ceiling on one digested artifact file. Runner products are tens of MB at most. */
+const RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES = 128 * 1024 * 1024;
+const RUNNER_CACHE_ARTIFACT_MODE_BITS = 0o7777;
+
+/**
+ * Whether a cached runner product tree may be reused. One traversal answers this for both sides:
+ * the writer digests a tree to publish a content manifest, and the reader walks the same tree to
+ * certify it against the manifest it published. A tree either side cannot fully describe is not
+ * reusable, because nothing then proves the bytes on disk are the bytes a build produced.
+ */
+
+/** Why a content manifest does not certify the products on disk. */
+export type RunnerCacheArtifactMismatch =
+ | { path: string; reason: 'missing' }
+ | { path: string; reason: 'kind_changed' }
+ | { path: string; reason: 'size_changed'; expected: number; actual: number }
+ | { path: string; reason: 'digest_mismatch' }
+ | { path: string; reason: 'mode_changed'; expected: number; actual: number }
+ | { path: string; reason: 'symlink_target_changed'; expected: string; actual: string }
+ /** A symlinked entry whose target resolves outside every product the manifest covers. */
+ | { path: string; reason: 'symlink_escapes_cache'; target: string }
+ /** A product root that is, or sits under, a symlink leading outside the cache root. */
+ | { path: string; reason: 'root_escapes_cache'; target: string }
+ /** A root or entry the manifest cannot describe: unreadable, or an exotic entry kind. */
+ | { path: string; reason: 'root_unusable' }
+ | { path: string; reason: 'undeclared_entry' }
+ | { path: string; reason: 'file_too_large'; size: number };
+
+/**
+ * Why a tree could not carry a content manifest, and so cannot be reused. A symlinked root or a
+ * symlinked entry names where its bytes actually resolve; `root_unusable` covers what no manifest
+ * can hold — an unreadable directory, an entry kind with no manifest form, an oversized file — and
+ * names the refusing entry when the walk reached one.
+ * Reported by {@link writeRunnerCacheMetadataForArtifacts}.
+ */
+export type RunnerCacheRefusal =
+ | { reason: 'root_escapes_cache'; path: string; target: string }
+ | { reason: 'symlink_escapes_cache'; path: string; target: string }
+ | { reason: 'root_unusable'; path?: string };
+
+type RunnerCacheArtifactWrite =
+ | { ok: true; artifacts: RunnerXctestrunCacheArtifacts }
+ | { ok: false; refusal: RunnerCacheRefusal };
+
+/**
+ * Digests a freshly built product tree into the manifest its cache entry will carry, or names why
+ * the tree cannot carry one.
+ */
+export function buildRunnerCacheArtifactManifest(
+ cacheRoot: string,
+ xctestrunPath: string,
+ productPaths: readonly string[],
+): RunnerCacheArtifactWrite {
+ const canonicalCacheRoot = resolveRealPath(cacheRoot);
+ if (canonicalCacheRoot === null) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: cacheRoot } };
+ }
+ const xctestrun = digestXctestrun(cacheRoot, xctestrunPath, canonicalCacheRoot);
+ if ('reason' in xctestrun) {
+ return { ok: false, refusal: xctestrun };
+ }
+ const walked = resolveWalkedRoots(productPaths, cacheRoot, canonicalCacheRoot);
+ if (!walked.ok) {
+ return { ok: false, refusal: walked.refusal };
+ }
+ const entries: RunnerCacheArtifactEntry[] = [];
+ for (const walkedRoot of walked.roots) {
+ const collected = collectRunnerCacheArtifactEntries(
+ canonicalCacheRoot,
+ walkedRoot,
+ walked.roots,
+ );
+ if (!collected.ok) {
+ return { ok: false, refusal: collected.refusal };
+ }
+ entries.push(...collected.entries);
+ }
+ if (entries.length === 0) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: productPaths.join(', ') } };
+ }
+ entries.sort((left, right) => left.path.localeCompare(right.path));
+ return {
+ ok: true,
+ artifacts: {
+ xctestrunPath,
+ xctestrunSize: xctestrun.size,
+ xctestrunDigest: xctestrun.digest,
+ productPaths: [...productPaths],
+ entries,
+ },
+ };
+}
+
+/**
+ * The `.xctestrun` the manifest will certify: a regular file, not a symlink, whose bytes resolve
+ * to inside the cache. A symlink here would make the manifest name one file while its digest
+ * described another.
+ */
+function digestXctestrun(
+ cacheRoot: string,
+ xctestrunPath: string,
+ canonicalCacheRoot: string,
+): { digest: string; size: number } | RunnerCacheRefusal {
+ const relativePath = toManifestPath(cacheRoot, xctestrunPath) ?? xctestrunPath;
+ const resolved = resolveRealPath(xctestrunPath);
+ if (resolved === null) {
+ return { reason: 'root_unusable', path: relativePath };
+ }
+ if (!isPathInsideDirectory(resolved, canonicalCacheRoot)) {
+ return { reason: 'root_escapes_cache', path: relativePath, target: resolved };
+ }
+ // The manifest names a regular file at this exact path; the reader lstats it and calls a
+ // symlink a kind change. Certifying an in-cache link here would publish a manifest the reader
+ // refuses at first sight, i.e. a rebuild on every launch.
+ if (!isRegularFile(xctestrunPath)) {
+ return { reason: 'root_unusable', path: relativePath };
+ }
+ const digested = digestFile(xctestrunPath);
+ return digested ?? { reason: 'root_unusable', path: relativePath };
+}
+
+function isRegularFile(filePath: string): boolean {
+ try {
+ return fs.lstatSync(filePath).isFile();
+ } catch {
+ return false;
+ }
+}
+
+/** The product roots a manifest covers, or why none of them can be walked. */
+type RunnerCacheWalkedRoots =
+ | { ok: true; roots: string[] }
+ | { ok: false; refusal: RunnerCacheRefusal };
+
+/**
+ * The product roots this manifest will cover, each resolved through the filesystem and required
+ * to land inside the cache root as the filesystem resolves it. A root that is a symlink, or sits
+ * under one that leaves the cache, is refused here rather than walked: its manifest-relative
+ * names would describe a tree the cache does not own, and re-pointing the symlink would swap
+ * those bytes without any manifest entry noticing.
+ */
+function resolveWalkedRoots(
+ productPaths: readonly string[],
+ cacheRoot: string,
+ canonicalCacheRoot: string,
+): RunnerCacheWalkedRoots {
+ const resolved: string[] = [];
+ for (const productPath of productPaths) {
+ const resolvedRoot = resolveRealPath(productPath);
+ if (resolvedRoot === null) {
+ return { ok: false, refusal: unusableRootRefusal(cacheRoot, productPath) };
+ }
+ if (!isPathInsideDirectory(resolvedRoot, canonicalCacheRoot)) {
+ return {
+ ok: false,
+ refusal: {
+ reason: 'root_escapes_cache',
+ path: toManifestPath(cacheRoot, productPath) ?? productPath,
+ target: resolvedRoot,
+ },
+ };
+ }
+ resolved.push(resolvedRoot);
+ }
+ const roots = dropNestedRoots(resolved);
+ if (roots.length > 0) {
+ return { ok: true, roots };
+ }
+ return { ok: false, refusal: { reason: 'root_unusable', path: productPaths.join(', ') } };
+}
+
+/**
+ * The kept set of resolved roots, identical whatever order the caller spelled them in: sorted
+ * so every ancestor precedes its descendants, then an equal or nested candidate is dropped.
+ * Deduplicating in input order instead would keep a descendant whose ancestor arrives later,
+ * walking that subtree twice, and an alias resolving onto a kept root would ride along as a
+ * second root whose leaves the reader calls undeclared.
+ */
+function dropNestedRoots(resolvedRoots: readonly string[]): string[] {
+ const kept: string[] = [];
+ for (const candidate of new Set([...resolvedRoots].sort())) {
+ if (!kept.some((keptRoot) => isPathInsideDirectory(candidate, keptRoot))) {
+ kept.push(candidate);
+ }
+ }
+ return kept;
+}
+
+/**
+ * One leaf of a cached product: where it lives in the manifest, where it lives on disk, and
+ * what `lstat` says about it. The writer digests these; the reader compares them to the manifest.
+ */
+type RunnerCacheArtifactLeaf = {
+ relativePath: string;
+ fullPath: string;
+ stat: fs.Stats;
+};
+
+type RunnerCacheLeafWalk =
+ | { ok: true; leaves: RunnerCacheArtifactLeaf[] }
+ | { ok: false; refusal: RunnerCacheRefusal };
+
+/**
+ * The single traversal both cache sides share: every file and symlink under one product root, in
+ * manifest-relative form, where `walkedRoots` are all the roots the manifest covers.
+ *
+ * A symlink is contained only when its target resolves, through the filesystem rather than
+ * lexically, inside one of those roots. That is what makes the target's bytes certified: the walk
+ * of that root digests them. A symlink pointing anywhere else — outside the cache, or into a
+ * directory of it that no root covers — leaves the product uncertifiable, as does a kind the
+ * manifest cannot represent (a socket, a device) or an unreadable directory.
+ */
+// fallow-ignore-next-line complexity
+function walkRunnerCacheArtifactLeaves(
+ cacheRoot: string,
+ root: string,
+ walkedRoots: readonly string[],
+): RunnerCacheLeafWalk {
+ const leaves: RunnerCacheArtifactLeaf[] = [];
+ const stack: string[] = [root];
+ while (stack.length > 0) {
+ const directory = stack.pop()!;
+ let names: string[];
+ try {
+ names = fs.readdirSync(directory);
+ } catch {
+ const relativePath = toManifestPath(cacheRoot, directory);
+ return { ok: false, refusal: { reason: 'root_unusable', path: relativePath ?? undefined } };
+ }
+ for (const name of names) {
+ const fullPath = path.join(directory, name);
+ const relativePath = toManifestPath(cacheRoot, fullPath);
+ if (relativePath === null) {
+ // Only a root that escaped the cache can produce this, and the caller checked the roots.
+ return { ok: false, refusal: { reason: 'root_unusable', path: fullPath } };
+ }
+ let stat: fs.Stats;
+ try {
+ stat = fs.lstatSync(fullPath);
+ } catch {
+ return { ok: false, refusal: { reason: 'root_unusable', path: relativePath } };
+ }
+ if (!isManifestDescribingEntry(stat)) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: relativePath } };
+ }
+ if (stat.isSymbolicLink()) {
+ const target = readContainedSymlinkTarget(fullPath, walkedRoots);
+ if (target === null) {
+ return {
+ ok: false,
+ refusal: {
+ reason: 'symlink_escapes_cache',
+ path: relativePath,
+ target: readSymlinkTarget(fullPath) ?? '(unreadable)',
+ },
+ };
+ }
+ }
+ if (!stat.isDirectory()) {
+ leaves.push({ relativePath, fullPath, stat });
+ } else {
+ stack.push(fullPath);
+ }
+ }
+ }
+ return { ok: true, leaves };
+}
+
+/**
+ * A symlink's raw target, but only when it resolves inside one of `walkedRoots`. A dangling target
+ * is judged where it would land, so a link carried outside by its own parent chain is refused.
+ */
+function readContainedSymlinkTarget(
+ linkPath: string,
+ walkedRoots: readonly string[],
+): string | null {
+ const target = readSymlinkTarget(linkPath);
+ if (target === null) {
+ return null;
+ }
+ const resolvedTarget = resolveSymlinkTarget(linkPath, target);
+ if (resolvedTarget === null) {
+ return null;
+ }
+ return walkedRoots.some((walkedRoot) => isPathInsideDirectory(resolvedTarget, walkedRoot))
+ ? target
+ : null;
+}
+
+/** A symlink's raw target, or null when the link cannot be read. */
+function readSymlinkTarget(linkPath: string): string | null {
+ try {
+ return fs.readlinkSync(linkPath);
+ } catch {
+ return null;
+ }
+}
+
+function resolveSymlinkTarget(linkPath: string, target: string): string | null {
+ const resolvedParent = resolveRealPath(path.dirname(linkPath));
+ if (resolvedParent === null) {
+ return null;
+ }
+ const anchoredPath = path.isAbsolute(target)
+ ? path.resolve(target)
+ : path.resolve(resolvedParent, target);
+ return resolveRealPath(anchoredPath) ?? anchoredPath;
+}
+
+type RunnerCacheEntryCollection =
+ | { ok: true; entries: RunnerCacheArtifactEntry[] }
+ | { ok: false; refusal: RunnerCacheRefusal };
+
+/**
+ * Whether one directory entry is something a content manifest can describe at all: a directory to
+ * descend into, a regular file, or a symlink. A socket or a device makes a product uncertifiable.
+ */
+function isManifestDescribingEntry(stat: fs.Stats): boolean {
+ return stat.isDirectory() || stat.isFile() || stat.isSymbolicLink();
+}
+
+function collectRunnerCacheArtifactEntries(
+ cacheRoot: string,
+ root: string,
+ walkedRoots: readonly string[],
+): RunnerCacheEntryCollection {
+ const walk = walkRunnerCacheArtifactLeaves(cacheRoot, root, walkedRoots);
+ if (!walk.ok) {
+ return { ok: false, refusal: walk.refusal };
+ }
+ const entries: RunnerCacheArtifactEntry[] = [];
+ for (const leaf of walk.leaves) {
+ if (leaf.stat.isSymbolicLink()) {
+ const target = readSymlinkTarget(leaf.fullPath);
+ if (target === null) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } };
+ }
+ entries.push({ path: leaf.relativePath, symlink: target });
+ continue;
+ }
+ if (leaf.stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } };
+ }
+ const digest = digestFile(leaf.fullPath);
+ if (!digest) {
+ return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } };
+ }
+ entries.push({
+ path: leaf.relativePath,
+ size: digest.size,
+ mode: leaf.stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS,
+ digest: digest.digest,
+ });
+ }
+ return { ok: true, entries };
+}
+
+function toManifestPath(cacheRoot: string, fullPath: string): string | null {
+ const relativePath = path.relative(path.resolve(cacheRoot), path.resolve(fullPath));
+ if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
+ return null;
+ }
+ return relativePath;
+}
+
+function resolveRealPath(targetPath: string): string | null {
+ try {
+ return fs.realpathSync(targetPath);
+ } catch {
+ return null;
+ }
+}
+
+function digestFile(filePath: string): { digest: string; size: number } | null {
+ try {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) {
+ return null;
+ }
+ const hash = crypto.createHash('sha256');
+ hash.update(fs.readFileSync(filePath));
+ return { digest: hash.digest('hex'), size: stat.size };
+ } catch {
+ return null;
+ }
+}
+
+type RunnerCacheArtifactValidation =
+ | { ok: true; xctestrunPath: string; productPaths: string[] }
+ | { ok: false; mismatch: RunnerCacheArtifactMismatch | null };
+
+/**
+ * Checks a cache root's manifest against the tree on disk: the `.xctestrun` and every product the
+ * manifest names, byte for byte, inside the roots the manifest itself claims.
+ */
+export function validateRunnerCacheArtifactManifest(
+ derived: string,
+ metadata: RunnerXctestrunCacheMetadata,
+): RunnerCacheArtifactValidation {
+ const artifacts = metadata.artifacts;
+ const canonicalCacheRoot = artifacts ? resolveRealPath(derived) : null;
+ if (!artifacts || !isRunnerCacheArtifacts(artifacts) || canonicalCacheRoot === null) {
+ return { ok: false, mismatch: null };
+ }
+ if (!isPathInsideDirectory(artifacts.xctestrunPath, derived)) {
+ return { ok: false, mismatch: null };
+ }
+ const xctestrunMismatch = validateManifestedXctestrun(artifacts, canonicalCacheRoot);
+ if (xctestrunMismatch) {
+ return { ok: false, mismatch: xctestrunMismatch };
+ }
+ const productMismatch = validateManifestedProducts(
+ artifacts,
+ canonicalCacheRoot,
+ resolveWalkedRoots(artifacts.productPaths, derived, canonicalCacheRoot),
+ );
+ if (productMismatch) {
+ return { ok: false, mismatch: productMismatch };
+ }
+ return {
+ ok: true,
+ xctestrunPath: artifacts.xctestrunPath,
+ productPaths: [...artifacts.productPaths],
+ };
+}
+
+/**
+ * Walks each product root and certifies its leaves against the manifest. An entry the manifest
+ * declares but the walks never returned never existed here, and one the walks returned that the
+ * manifest never names is a byte nobody accounted for.
+ */
+function validateManifestedProducts(
+ artifacts: RunnerXctestrunCacheArtifacts,
+ canonicalCacheRoot: string,
+ walked: RunnerCacheWalkedRoots,
+): RunnerCacheArtifactMismatch | null {
+ if (!walked.ok) {
+ return toWalkRefusal(walked.refusal);
+ }
+ const declared = new Map(artifacts.entries.map((entry) => [entry.path, entry]));
+ for (const walkedRoot of walked.roots) {
+ const mismatch = validateProductAgainstManifest(
+ canonicalCacheRoot,
+ walkedRoot,
+ walked.roots,
+ declared,
+ );
+ if (mismatch) {
+ return mismatch;
+ }
+ }
+ const unlisted = declared.keys().next();
+ return unlisted.done ? null : { path: unlisted.value, reason: 'missing' };
+}
+
+/**
+ * The `.xctestrun` as it sits now: a regular file whose bytes resolve to inside the cache root,
+ * matching the digest the manifest holds. A manifest path swapped for a symlink elsewhere would
+ * otherwise be digested from outside the cache.
+ */
+function validateManifestedXctestrun(
+ artifacts: RunnerXctestrunCacheArtifacts,
+ canonicalCacheRoot: string,
+): RunnerCacheArtifactMismatch | null {
+ const mismatch = validateManifestedFile(
+ artifacts.xctestrunPath,
+ artifacts.xctestrunSize,
+ artifacts.xctestrunDigest,
+ );
+ if (mismatch) {
+ return mismatch;
+ }
+ const resolved = resolveRealPath(artifacts.xctestrunPath);
+ return resolved !== null && isPathInsideDirectory(resolved, canonicalCacheRoot)
+ ? null
+ : { path: artifacts.xctestrunPath, reason: 'root_escapes_cache', target: resolved ?? '(?)' };
+}
+
+/** Widens a walk refusal into the reason a reuse decision reports. */
+function toWalkRefusal(refusal: RunnerCacheRefusal): RunnerCacheArtifactMismatch {
+ if (refusal.reason === 'root_unusable') {
+ return { path: refusal.path ?? '(cache root)', reason: 'root_unusable' };
+ }
+ return { ...refusal };
+}
+
+/** Names a root the walk never reached, so a refusal always reports something to inspect. */
+function unusableRootRefusal(cacheRoot: string, productPath: string): RunnerCacheRefusal {
+ return {
+ reason: 'root_unusable',
+ path: toManifestPath(cacheRoot, productPath) ?? undefined,
+ };
+}
+
+/**
+ * Walks one product with the same traversal the writer used and removes each leaf it can certify
+ * from `declared`. A leaf on disk the manifest never names is unaccounted for, and a tree the walk
+ * cannot describe cannot be certified at all.
+ */
+function validateProductAgainstManifest(
+ canonicalCacheRoot: string,
+ productPath: string,
+ walkedRoots: readonly string[],
+ declared: Map,
+): RunnerCacheArtifactMismatch | null {
+ const walk = walkRunnerCacheArtifactLeaves(canonicalCacheRoot, productPath, walkedRoots);
+ if (!walk.ok) {
+ return walk.refusal
+ ? toWalkRefusal(walk.refusal)
+ : { path: productPath, reason: 'root_unusable' };
+ }
+ for (const leaf of walk.leaves) {
+ const entry = declared.get(leaf.relativePath);
+ if (!entry) {
+ return { path: leaf.relativePath, reason: 'undeclared_entry' };
+ }
+ const mismatch = validateManifestEntry(leaf.fullPath, leaf.relativePath, entry);
+ if (mismatch) {
+ return mismatch;
+ }
+ declared.delete(leaf.relativePath);
+ }
+ return null;
+}
+
+function validateManifestEntry(
+ fullPath: string,
+ relativePath: string,
+ entry: RunnerCacheArtifactEntry,
+): RunnerCacheArtifactMismatch | null {
+ if ('symlink' in entry) {
+ const actual = readSymlinkTarget(fullPath);
+ if (actual === null) {
+ return { path: relativePath, reason: 'missing' };
+ }
+ return actual === entry.symlink
+ ? null
+ : {
+ path: relativePath,
+ reason: 'symlink_target_changed',
+ expected: entry.symlink,
+ actual,
+ };
+ }
+ return validateManifestedFile(fullPath, entry.size, entry.digest, entry.mode, relativePath);
+}
+
+function validateManifestedFile(
+ fullPath: string,
+ expectedSize: number,
+ expectedDigest: string,
+ expectedMode?: number,
+ relativePath?: string,
+): RunnerCacheArtifactMismatch | null {
+ const reportedPath = relativePath ?? fullPath;
+ let stat: fs.Stats;
+ try {
+ stat = fs.lstatSync(fullPath);
+ } catch {
+ return { path: reportedPath, reason: 'missing' };
+ }
+ if (!stat.isFile()) {
+ return { path: reportedPath, reason: 'kind_changed' };
+ }
+ if (stat.size !== expectedSize) {
+ return {
+ path: reportedPath,
+ reason: 'size_changed',
+ expected: expectedSize,
+ actual: stat.size,
+ };
+ }
+ if (
+ expectedMode !== undefined &&
+ (stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS) !== expectedMode
+ ) {
+ return {
+ path: reportedPath,
+ reason: 'mode_changed',
+ expected: expectedMode,
+ actual: stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS,
+ };
+ }
+ const digested = digestFile(fullPath);
+ if (!digested) {
+ return { path: reportedPath, reason: 'file_too_large', size: stat.size };
+ }
+ if (digested.digest !== expectedDigest) {
+ return { path: reportedPath, reason: 'digest_mismatch' };
+ }
+ return null;
+}
+
+function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheArtifacts {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false;
+ }
+ const artifacts = value as Partial;
+ return (
+ typeof artifacts.xctestrunPath === 'string' &&
+ isNonNegativeInteger(artifacts.xctestrunSize) &&
+ typeof artifacts.xctestrunDigest === 'string' &&
+ isNonEmptyStringArray(artifacts.productPaths) &&
+ isNonEmptyArray(artifacts.entries) &&
+ artifacts.entries.every(isRunnerCacheArtifactEntry)
+ );
+}
+
+function isRunnerCacheArtifactEntry(value: unknown): value is RunnerCacheArtifactEntry {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false;
+ }
+ const entry = value as Partial &
+ Partial;
+ if (typeof entry.path !== 'string' || !isManifestRelativePath(entry.path)) {
+ return false;
+ }
+ if (typeof entry.symlink === 'string') {
+ return entry.digest === undefined;
+ }
+ return (
+ typeof entry.digest === 'string' &&
+ isNonNegativeInteger(entry.size) &&
+ typeof entry.mode === 'number'
+ );
+}
+
+/** A manifest path must stay inside the cache root it was written under. */
+function isManifestRelativePath(relativePath: string): boolean {
+ return (
+ !relativePath.startsWith('/') &&
+ !relativePath.startsWith('..') &&
+ !path.isAbsolute(relativePath)
+ );
+}
+
+function isNonNegativeInteger(value: unknown): value is number {
+ return Number.isInteger(value) && (value as number) >= 0;
+}
+
+function isNonEmptyStringArray(value: unknown): value is string[] {
+ return isNonEmptyArray(value) && value.every((item) => typeof item === 'string');
+}
+
+function isNonEmptyArray- (value: unknown): value is Item[] {
+ return Array.isArray(value) && value.length > 0;
+}
+
+function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
+ const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath));
+ return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
+}
diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts
index 9b724b57c8..52b06c1d39 100644
--- a/packages/platform-apple/src/runner/runner-artifact.ts
+++ b/packages/platform-apple/src/runner/runner-artifact.ts
@@ -30,12 +30,14 @@ import {
evaluateExistingXctestrun,
requireRunnerPhaseRemainingMs,
resolveExpectedRunnerCacheMetadata,
+ resolveRunnerArchBuildSettings,
resolveRunnerBundleBuildSettings,
resolveRunnerDerivedPath,
resolveRunnerMaxConcurrentDestinationsFlag,
resolveRunnerPerformanceBuildSettings,
resolveRunnerSandboxBuildArgs,
resolveRunnerSigningBuildSettings,
+ requireCertifiedRunnerCacheArtifacts,
writeRunnerCacheMetadataForArtifacts,
type ExistingXctestrunState,
type RunnerPhaseBudget,
@@ -66,7 +68,7 @@ export type RunnerXctestrunArtifact = {
cache: RunnerXctestrunCacheKind;
artifact: RunnerXctestrunArtifactState;
buildMs: number;
- xctestrunPathSource: 'manifest' | 'scan' | 'build' | 'external';
+ xctestrunPathSource: 'manifest' | 'build' | 'external';
reason?: string;
};
@@ -174,17 +176,11 @@ async function ensureXctestrunUnderCacheLock(params: {
}): Promise {
const { device, options, projectRoot, expectedCacheMetadata, derived } = params;
cleanRunnerDerivedBeforeEvaluation(derived, params.forceRebuild);
- const existing = await evaluateExistingXctestrunForDevice({
- device,
+ const existing = await evaluateExistingXctestrun({
derived,
- projectRoot,
expectedCacheMetadata,
});
- const cache =
- existing.reason === 'reuse_ready' ? 'exact' : existing.xctestrunPath ? 'restore-key' : 'miss';
- if (existing.reason !== 'reuse_ready') {
- emitRunnerXctestrunRebuildDecision(existing, derived);
- }
+ const cache = existing.reason === 'reuse_ready' ? 'exact' : 'miss';
const reusable = await resolveReusableXctestrunArtifact({
device,
derived,
@@ -193,7 +189,13 @@ async function ensureXctestrunUnderCacheLock(params: {
cache,
});
if (reusable) return reusable;
- if (existing.xctestrunPath) {
+ if (existing.reason !== 'reuse_ready') {
+ emitRunnerXctestrunRebuildDecision(existing, derived);
+ }
+ // Nothing survived evaluation — a certified state that failed repair, or one the manifest
+ // refuses — so the tree is discarded before the rebuild. A missing manifest is not ours to
+ // delete: that directory is either a first build or one the caller laid out itself.
+ if (existing.reason !== 'cache_metadata_missing') {
assertSafeDerivedCleanup(derived);
cleanRunnerDerivedArtifacts(derived);
}
@@ -230,7 +232,7 @@ async function resolveReusableXctestrunArtifact(params: {
cache,
artifact: 'valid',
buildMs: 0,
- xctestrunPathSource: existing.source,
+ xctestrunPathSource: 'manifest',
};
}
@@ -273,8 +275,17 @@ async function buildXctestrunArtifact(params: {
await repairMacOsRunnerProductsIfNeeded(device, builtProductPaths, built);
// Release/dev script builds patch the synthesized XCTest runner app in scripts/.
// This covers direct local xcodebuilds triggered by ensureXctestrunArtifact on cache miss.
+ // The manifest is written last so it certifies the bytes that actually run.
await applyXctestRunnerAppIcon(builtProductPaths);
- writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths);
+ requireCertifiedRunnerCacheArtifacts(
+ await writeRunnerCacheMetadataForArtifacts(
+ derived,
+ expectedCacheMetadata,
+ built,
+ builtProductPaths,
+ ),
+ derived,
+ );
emitRunnerXctestrunDecision('build', 'built_new', {
derived,
xctestrunPath: built,
@@ -298,16 +309,19 @@ async function tryReuseExistingXctestrun(
): Promise {
try {
await repairMacOsRunnerProductsIfNeeded(device, existing.productPaths, existing.xctestrunPath);
+ requireCertifiedRunnerCacheArtifacts(
+ await writeRunnerCacheMetadataForArtifacts(
+ derived,
+ expectedCacheMetadata,
+ existing.xctestrunPath,
+ existing.productPaths,
+ ),
+ derived,
+ );
emitRunnerXctestrunDecision('reuse', 'reuse_ready', {
derived,
xctestrunPath: existing.xctestrunPath,
});
- writeRunnerCacheMetadataForArtifacts(
- derived,
- expectedCacheMetadata,
- existing.xctestrunPath,
- existing.productPaths,
- );
return existing.xctestrunPath;
} catch (error) {
if (!isExpectedRunnerRepairFailure(error)) {
@@ -322,44 +336,23 @@ async function tryReuseExistingXctestrun(
}
// Cache probe for preflight surfaces (doctor): runs the same no-build reuse
-// evaluation as the ensure path (cache metadata + product-path validation),
-// so a partial or stale cache never reports as ready. Resolving the expected
-// metadata stats the runner sources and reads tool versions (~100ms, cached
-// per process) but never builds.
+// evaluation as the ensure path (cache metadata + content-manifest validation),
+// so a partial, restored, or tampered cache never reports as ready. Resolving
+// the expected metadata stats the runner sources and reads tool versions
+// (~100ms, cached per process) and the manifest digests the products (tens of
+// ms) but never builds.
export async function hasCachedAppleRunnerArtifact(device: DeviceInfo): Promise {
try {
const projectRoot = findProjectRoot();
const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot);
const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata);
- const existing = await evaluateExistingXctestrunForDevice({
- device,
- derived,
- projectRoot,
- expectedCacheMetadata,
- });
+ const existing = await evaluateExistingXctestrun({ derived, expectedCacheMetadata });
return existing.reason === 'reuse_ready';
} catch {
return false;
}
}
-function evaluateExistingXctestrunForDevice(params: {
- device: DeviceInfo;
- derived: string;
- projectRoot: string;
- expectedCacheMetadata: RunnerXctestrunCacheMetadata;
-}): Promise {
- const { device, derived, projectRoot, expectedCacheMetadata } = params;
- return evaluateExistingXctestrun({
- derived,
- projectRoot,
- expectedCacheMetadata,
- findXctestrun: (root) => findXctestrun(root, device),
- xctestrunReferencesProjectRoot,
- resolveExistingXctestrunProductPaths,
- });
-}
-
type XctestrunCandidate = {
path: string;
mtimeMs: number;
@@ -438,27 +431,6 @@ export function scoreXctestrunCandidate(candidatePath: string, device: DeviceInf
return score;
}
-export function xctestrunReferencesProjectRoot(
- xctestrunPath: string,
- projectRoot: string,
-): boolean {
- try {
- const contents = fs.readFileSync(xctestrunPath, 'utf8');
- const candidateRoots = new Set([projectRoot]);
- try {
- candidateRoots.add(fs.realpathSync(projectRoot));
- } catch {}
- for (const root of candidateRoots) {
- if (contents.includes(root)) {
- return true;
- }
- }
- return false;
- } catch {
- return false;
- }
-}
-
async function buildRunnerXctestrun(
device: DeviceInfo,
projectPath: string,
@@ -475,6 +447,7 @@ async function buildRunnerXctestrun(
);
const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
const performanceBuildSettings = resolveRunnerPerformanceBuildSettings();
+ const archBuildSettings = resolveRunnerArchBuildSettings(process.env);
const sandboxBuildArgs = resolveRunnerSandboxBuildArgs();
try {
await runCmdStreaming(
@@ -493,6 +466,7 @@ async function buildRunnerXctestrun(
'-derivedDataPath',
derived,
...performanceBuildSettings,
+ ...archBuildSettings,
...sandboxBuildArgs,
...runnerBundleBuildSettings,
...provisioningArgs,
diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts
index af48309f62..475f40718a 100644
--- a/packages/platform-apple/src/runner/runner-cache-metadata.ts
+++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
+import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
@@ -29,7 +30,7 @@ import { computeRunnerSourceFingerprint } from './runner-source.ts';
const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner');
export const RUNNER_CACHE_METADATA_FILE = '.agent-device-runner-cache.json';
-const RUNNER_CACHE_SCHEMA_VERSION = 2;
+const RUNNER_CACHE_SCHEMA_VERSION = 3;
const RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH = 300;
/**
@@ -49,9 +50,14 @@ const RUNNER_SANDBOX_BUILD_ARGS = [
'-IDEPackageSupportDisablePluginExecutionSandbox=1',
'ENABLE_USER_SCRIPT_SANDBOXING=NO',
] as const;
-const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox';
-const RUNNER_UNIT_TEST_SWIFT_FLAGS =
- '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS';
+/**
+ * The isolation-scan canary compiles in every runner build, whether a build came from
+ * `scripts/build-xcuitest-apple.sh` or from `ensureXctestrunArtifact`, so the metadata's
+ * recorded Swift flags describe what the compiler actually received on both paths.
+ */
+const RUNNER_RUNTIME_SWIFT_FLAGS =
+ '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY';
+const RUNNER_UNIT_TEST_SWIFT_FLAGS = `${RUNNER_RUNTIME_SWIFT_FLAGS} -D AGENT_DEVICE_RUNNER_UNIT_TESTS`;
/** Toolchain half of the runner cache key. Every field is a probed value. */
export type RunnerToolchainFingerprint = {
@@ -169,23 +175,41 @@ export type RunnerXctestrunCacheMetadata = RunnerToolchainFingerprint & {
runnerBundleBuildSettings: string[];
runnerSigningBuildSettings: string[];
runnerPerformanceBuildSettings: string[];
+ runnerArchBuildSettings: string[];
runnerSandboxBuildArgs: string[];
artifacts?: RunnerXctestrunCacheArtifacts;
};
export type RunnerXctestrunCacheArtifacts = {
xctestrunPath: string;
- xctestrunMtimeMs: number;
xctestrunSize: number;
- productPaths: RunnerXctestrunCacheProductArtifact[];
+ xctestrunDigest: string;
+ productPaths: string[];
+ /** Paths are relative to the cache root the manifest was written under. */
+ entries: RunnerCacheArtifactEntry[];
};
-export type RunnerXctestrunCacheProductArtifact = {
+/**
+ * One file inside a cached product bundle: its bytes hashed, its permission bits, and the
+ * size that makes an equal-size rewrite with a stale mtime visible as a digest mismatch.
+ */
+export type RunnerCacheArtifactFileEntry = {
path: string;
- mtimeMs: number;
size: number;
+ mode: number;
+ digest: string;
};
+/** One symlink inside a cached product bundle, recorded as its raw target string. */
+export type RunnerCacheArtifactSymlinkEntry = {
+ path: string;
+ symlink: string;
+};
+
+export type RunnerCacheArtifactEntry =
+ | RunnerCacheArtifactFileEntry
+ | RunnerCacheArtifactSymlinkEntry;
+
function normalizeBundleId(value: string | undefined): string {
return value?.trim() ?? '';
}
@@ -245,6 +269,7 @@ export function resolveExpectedRunnerCacheMetadata(
device,
),
runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(),
+ runnerArchBuildSettings: resolveRunnerArchBuildSettings(process.env),
runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(),
};
}
@@ -587,6 +612,17 @@ export function resolveRunnerPerformanceBuildSettings(): string[] {
];
}
+/**
+ * The architecture an explicit `AGENT_DEVICE_XCUITEST_ARCHS` pins. A generic simulator
+ * destination leaves the active arch undefined and Xcode picks one per version, so the
+ * override changes the bytes on disk and must reach both the `xcodebuild` arguments and the
+ * cache identity from this one resolver.
+ */
+export function resolveRunnerArchBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] {
+ const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim();
+ return archs ? [`ARCHS=${archs}`] : [];
+}
+
export function resolveRunnerSandboxBuildArgs(): string[] {
return [
...RUNNER_SANDBOX_BUILD_ARGS,
@@ -599,3 +635,154 @@ function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string {
? RUNNER_UNIT_TEST_SWIFT_FLAGS
: RUNNER_RUNTIME_SWIFT_FLAGS;
}
+
+const BUILD_SETTINGS_HEADER = /^\s*Build settings from command line:\s*$/;
+const BUILD_SETTING_LINE = /^\s+([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/;
+const RECORDED_BUILD_SETTING = /^([A-Z][A-Z0-9_]*)=(.*)$/;
+const COMMAND_LINE_INVOCATION_HEADER = /^\s*Command line invocation:\s*$/;
+
+export type RunnerBuildSettingEvidence = {
+ key: string;
+ expected: string;
+ actual: string;
+};
+
+/**
+ * What one build log says it was handed: the settings block `xcodebuild` echoed, and the
+ * invocation line that carries every argument, including the ones that are not build settings.
+ * Null when the log holds no settings block at all.
+ */
+type RunnerBuildLogRecipe = {
+ settings: Map;
+ invocationLine: string;
+};
+
+function readRunnerBuildLogRecipe(logPath: string): RunnerBuildLogRecipe | null {
+ let contents: string;
+ try {
+ contents = fs.readFileSync(logPath, 'utf8');
+ } catch {
+ return null;
+ }
+ const lines = contents.split('\n');
+ const settings = new Map();
+ let inBlock = false;
+ let invocationLine = '';
+ for (const [index, line] of lines.entries()) {
+ if (COMMAND_LINE_INVOCATION_HEADER.test(line)) {
+ invocationLine = lines[index + 1] ?? '';
+ continue;
+ }
+ if (BUILD_SETTINGS_HEADER.test(line)) {
+ inBlock = true;
+ continue;
+ }
+ if (!inBlock) continue;
+ const setting = BUILD_SETTING_LINE.exec(line);
+ if (!setting) break;
+ settings.set(setting[1]!, setting[2]!.trimEnd());
+ }
+ return inBlock ? { settings, invocationLine } : null;
+}
+
+/** Every argument the cache identity records as a recipe, in the `xcodebuild` spelling. */
+function recordedRunnerBuildArguments(metadata: RunnerXctestrunCacheMetadata): string[] {
+ return [
+ ...metadata.runnerBundleBuildSettings,
+ ...metadata.runnerSigningBuildSettings,
+ ...metadata.runnerPerformanceBuildSettings,
+ ...metadata.runnerArchBuildSettings,
+ ...metadata.runnerSandboxBuildArgs,
+ ];
+}
+
+function recordedRunnerBuildSettings(
+ metadata: RunnerXctestrunCacheMetadata,
+): Record {
+ const recorded: Record = {};
+ for (const arg of recordedRunnerBuildArguments(metadata)) {
+ const setting = RECORDED_BUILD_SETTING.exec(arg);
+ if (setting) {
+ recorded[setting[1]!] = setting[2]!;
+ }
+ }
+ return recorded;
+}
+
+/**
+ * Recorded arguments `xcodebuild` echoes on the invocation line rather than in its settings block,
+ * which is where its whole recipe shows: `-I` user-default flags such as the package-sandbox
+ * disables. Without this the settings diff would call a recipe complete while ignoring them.
+ */
+function diffRunnerInvocationFlagsAgainstBuildLog(
+ metadata: RunnerXctestrunCacheMetadata,
+ invocationLine: string,
+): RunnerBuildSettingEvidence[] {
+ return recordedRunnerBuildArguments(metadata)
+ .filter((arg) => !RECORDED_BUILD_SETTING.test(arg))
+ .filter((arg) => !invocationLine.includes(arg))
+ .map((arg) => ({
+ key: '(invocation flag)',
+ expected: arg,
+ actual: 'absent from the "Command line invocation:" line',
+ }));
+}
+
+/**
+ * The recorded settings a build log shows `xcodebuild` did not receive exactly as recorded. An
+ * empty recorded value matches an absent report, which is how `CODE_SIGN_IDENTITY=` arrives.
+ */
+function diffRunnerBuildSettingsAgainstBuildLog(
+ metadata: RunnerXctestrunCacheMetadata,
+ logPath: string,
+): RunnerBuildSettingEvidence[] {
+ const reported = readRunnerBuildLogRecipe(logPath);
+ if (!reported) {
+ return [
+ {
+ key: '(build log)',
+ expected: 'a "Build settings from command line:" block',
+ actual: 'missing or unreadable log',
+ },
+ ];
+ }
+ const settingDifferences = Object.entries(recordedRunnerBuildSettings(metadata))
+ .filter(([key, expected]) => {
+ const actual = reported.settings.get(key);
+ return actual === undefined ? expected !== '' : actual !== expected;
+ })
+ .map(([key, expected]) => ({
+ key,
+ expected,
+ actual: reported.settings.get(key) ?? '(absent)',
+ }));
+ return [
+ ...settingDifferences,
+ ...diffRunnerInvocationFlagsAgainstBuildLog(metadata, reported.invocationLine),
+ ];
+}
+
+/**
+ * Fails when a build log shows a recipe other than the one `metadata` records — a setting whose
+ * value differs or went missing, or a recorded flag absent from the invocation — so a caller that
+ * drifted from this identity cannot have its products certified under it.
+ */
+export function requireRunnerBuildSettingsMatchBuildLog(
+ metadata: RunnerXctestrunCacheMetadata,
+ logPath: string,
+): void {
+ const differences = diffRunnerBuildSettingsAgainstBuildLog(metadata, logPath);
+ if (differences.length === 0) {
+ return;
+ }
+ throw new AppError(
+ 'COMMAND_FAILED',
+ 'The Apple runner build did not use the settings its cache identity records',
+ {
+ reason: 'runner_build_settings_mismatch',
+ buildLogPath: logPath,
+ differences,
+ hint: 'Align the build invocation with the runner cache identity resolvers, or rebuild without the cache.',
+ },
+ );
+}
diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts
index 501458404c..abfd2f9305 100644
--- a/packages/platform-apple/src/runner/runner-cache.ts
+++ b/packages/platform-apple/src/runner/runner-cache.ts
@@ -15,13 +15,16 @@ import {
diffComparableRunnerCacheMetadata,
stableJsonStringify,
type RunnerCacheMetadataDifference,
- type RunnerXctestrunCacheArtifacts,
type RunnerXctestrunCacheMetadata,
- type RunnerXctestrunCacheProductArtifact,
} from './runner-cache-metadata.ts';
+import type {
+ RunnerCacheArtifactMismatch,
+ RunnerCacheRefusal,
+} from './runner-artifact-manifest.ts';
export {
requireRunnerPhaseRemainingMs,
resolveExpectedRunnerCacheMetadata,
+ resolveRunnerArchBuildSettings,
resolveRunnerBundleBuildSettings,
resolveRunnerDerivedPath,
resolveRunnerMaxConcurrentDestinationsFlag,
@@ -38,30 +41,30 @@ const RUNNER_XCTESTRUN_CACHE_LOCK_OWNER_GRACE_MS = 5_000;
const badRunnerArtifactsForRun = new Set();
-export type RunnerXctestrunCacheKind = 'exact' | 'restore-key' | 'miss' | 'external';
+export type RunnerXctestrunCacheKind = 'exact' | 'miss' | 'external';
export type ExistingXctestrunState =
- | {
- reason: 'missing_xctestrun';
- xctestrunPath: null;
- }
| {
reason: 'reuse_ready';
xctestrunPath: string;
productPaths: string[];
- source: 'manifest' | 'scan';
}
| {
- reason: 'project_root_mismatch' | 'missing_products' | 'cache_metadata_missing';
- xctestrunPath: string;
+ reason: 'cache_metadata_missing' | 'artifact_manifest_missing';
+ xctestrunPath: string | null;
+ productPaths: string[];
+ }
+ | {
+ reason: 'artifact_content_mismatch';
+ xctestrunPath: string | null;
productPaths: string[];
- source: 'manifest' | 'scan';
+ /** The first entry whose bytes, kind, or mode disagree with the manifest. */
+ mismatch: RunnerCacheArtifactMismatch;
}
| {
reason: 'cache_metadata_mismatch';
- xctestrunPath: string;
+ xctestrunPath: string | null;
productPaths: string[];
- source: 'manifest' | 'scan';
/** Which comparable keys differ, so a rebuild names its cause. */
metadataDifferences: RunnerCacheMetadataDifference[];
};
@@ -148,15 +151,61 @@ export function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild
badRunnerArtifactsForRun.delete(derived);
}
-export function writeRunnerCacheMetadataForArtifacts(
+/**
+ * Publishes cache metadata whose `artifacts` manifest digests the exact bytes of the `.xctestrun`
+ * and every file and symlink under the referenced product paths, keyed relative to the cache root.
+ * Reuse is authorized from this manifest alone.
+ *
+ * A tree the walk cannot describe is published without a manifest, which makes it a permanent
+ * miss, and the refusal is returned so the caller can name it. Silently uncertifiable products
+ * would otherwise cost a full rebuild on every launch with nothing to trace.
+ */
+export async function writeRunnerCacheMetadataForArtifacts(
derived: string,
metadata: RunnerXctestrunCacheMetadata,
xctestrunPath: string,
- productPaths: string[],
-): void {
+ productPaths: readonly string[],
+): Promise {
+ const { buildRunnerCacheArtifactManifest } = await import('./runner-artifact-manifest.ts');
+ const built = buildRunnerCacheArtifactManifest(derived, xctestrunPath, productPaths);
writeRunnerCacheMetadata(
derived,
- withRunnerCacheArtifacts(metadata, xctestrunPath, productPaths),
+ built.ok ? { ...metadata, artifacts: built.artifacts } : metadata,
+ );
+ if (built.ok) {
+ return null;
+ }
+ emitRunnerXctestrunDecision('preserve', 'uncertifiable_products', {
+ derived,
+ xctestrunPath,
+ ...(built.refusal ? { refusal: built.refusal } : {}),
+ });
+ return built.refusal;
+}
+
+/**
+ * A cache tree a content manifest cannot certify is not safe to launch: nothing can prove those
+ * bytes came from this build. Fails with the refusing entry rather than reporting a plain miss,
+ * which would rebuild the same uncertifiable tree on every launch.
+ */
+export function requireCertifiedRunnerCacheArtifacts(
+ refusal: RunnerCacheRefusal | null,
+ derived: string,
+): void {
+ if (!refusal) {
+ return;
+ }
+ throw new AppError(
+ 'COMMAND_FAILED',
+ 'The Apple runner products cannot be certified for cache reuse',
+ {
+ reason: 'runner_cache_uncertifiable',
+ refusalReason: refusal.reason,
+ refusingPath: refusal.path ?? derived,
+ ...(refusal.reason === 'root_unusable' ? {} : { resolvesTo: refusal.target }),
+ derived,
+ hint: `Inspect ${refusal.path ?? derived} under ${derived}. A symlinked or unreadable product tree must be replaced; run pnpm build:xcuitest with a clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH.`,
+ },
);
}
@@ -235,122 +284,6 @@ function evaluateRunnerCacheMetadata(
return { ok: true, metadata: actual };
}
-function withRunnerCacheArtifacts(
- metadata: RunnerXctestrunCacheMetadata,
- xctestrunPath: string,
- productPaths: readonly string[],
-): RunnerXctestrunCacheMetadata {
- const artifacts = buildRunnerCacheArtifacts(xctestrunPath, productPaths);
- return artifacts ? { ...metadata, artifacts } : metadata;
-}
-
-function buildRunnerCacheArtifacts(
- xctestrunPath: string,
- productPaths: readonly string[],
-): RunnerXctestrunCacheArtifacts | null {
- const xctestrunStats = readPathSignature(xctestrunPath);
- if (xctestrunStats === null || productPaths.length === 0) {
- return null;
- }
- const productArtifacts: RunnerXctestrunCacheProductArtifact[] = [];
- for (const productPath of productPaths) {
- const stats = readPathSignature(productPath);
- if (stats === null) {
- return null;
- }
- productArtifacts.push({ path: productPath, ...stats });
- }
- return {
- xctestrunPath,
- xctestrunMtimeMs: xctestrunStats.mtimeMs,
- xctestrunSize: xctestrunStats.size,
- productPaths: productArtifacts,
- };
-}
-
-function readValidatedRunnerCacheArtifacts(
- derived: string,
- metadata: RunnerXctestrunCacheMetadata | null,
-): { xctestrunPath: string; productPaths: string[] } | null {
- const artifacts = metadata?.artifacts;
- if (!isRunnerCacheArtifacts(artifacts)) {
- return null;
- }
- if (!isPathInsideDirectory(artifacts.xctestrunPath, derived)) {
- return null;
- }
- if (
- !pathSignatureMatches(artifacts.xctestrunPath, {
- mtimeMs: artifacts.xctestrunMtimeMs,
- size: artifacts.xctestrunSize,
- })
- ) {
- return null;
- }
- const productPaths: string[] = [];
- for (const product of artifacts.productPaths) {
- if (!isPathInsideDirectory(product.path, derived)) {
- return null;
- }
- if (!pathSignatureMatches(product.path, product)) {
- return null;
- }
- productPaths.push(product.path);
- }
- return { xctestrunPath: artifacts.xctestrunPath, productPaths };
-}
-
-function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheArtifacts {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return false;
- }
- const artifacts = value as Partial;
- return (
- typeof artifacts.xctestrunPath === 'string' &&
- Number.isInteger(artifacts.xctestrunMtimeMs) &&
- Number.isInteger(artifacts.xctestrunSize) &&
- Array.isArray(artifacts.productPaths) &&
- artifacts.productPaths.length > 0 &&
- artifacts.productPaths.every(isRunnerCacheProductArtifact)
- );
-}
-
-function isRunnerCacheProductArtifact(
- value: unknown,
-): value is RunnerXctestrunCacheProductArtifact {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return false;
- }
- const product = value as Partial;
- return (
- typeof product.path === 'string' &&
- Number.isInteger(product.mtimeMs) &&
- Number.isInteger(product.size)
- );
-}
-
-function readPathSignature(filePath: string): { mtimeMs: number; size: number } | null {
- try {
- const stat = fs.statSync(filePath);
- return { mtimeMs: Math.trunc(stat.mtimeMs), size: stat.size };
- } catch {
- return null;
- }
-}
-
-function pathSignatureMatches(
- filePath: string,
- expected: { mtimeMs: number; size: number },
-): boolean {
- const actual = readPathSignature(filePath);
- return actual?.mtimeMs === expected.mtimeMs && actual.size === expected.size;
-}
-
-function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
- const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath));
- return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
-}
-
function shouldCleanDerived(): boolean {
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
}
@@ -382,54 +315,43 @@ function isPathInsideProjectTmp(targetPath: string): boolean {
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
-// fallow-ignore-next-line complexity
export async function evaluateExistingXctestrun(options: {
derived: string;
- projectRoot: string;
expectedCacheMetadata: RunnerXctestrunCacheMetadata;
- findXctestrun: (root: string) => string | null;
- xctestrunReferencesProjectRoot: (xctestrunPath: string, projectRoot: string) => boolean;
- resolveExistingXctestrunProductPaths: (xctestrunPath: string) => Promise;
}): Promise {
const cacheMetadata = evaluateRunnerCacheMetadata(options.derived, options.expectedCacheMetadata);
- const manifest = cacheMetadata.ok
- ? readValidatedRunnerCacheArtifacts(options.derived, cacheMetadata.metadata)
- : null;
- const xctestrunPath = manifest?.xctestrunPath ?? options.findXctestrun(options.derived);
- if (!xctestrunPath) {
- return { reason: 'missing_xctestrun', xctestrunPath: null };
- }
- const hasValidatedManifest = manifest?.xctestrunPath === xctestrunPath;
- const source = hasValidatedManifest ? 'manifest' : 'scan';
- const productPaths = hasValidatedManifest
- ? manifest.productPaths
- : await options.resolveExistingXctestrunProductPaths(xctestrunPath);
- if (!productPaths) {
- return { reason: 'missing_products', xctestrunPath, productPaths: [], source };
- }
- if (
- !options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot) &&
- !hasValidatedManifest
- ) {
- return { reason: 'project_root_mismatch', xctestrunPath, productPaths, source };
- }
if (!cacheMetadata.ok) {
return cacheMetadata.reason === 'cache_metadata_mismatch'
? {
reason: cacheMetadata.reason,
- xctestrunPath,
- productPaths,
- source,
+ xctestrunPath: null,
+ productPaths: [],
metadataDifferences: cacheMetadata.differences,
}
- : { reason: cacheMetadata.reason, xctestrunPath, productPaths, source };
+ : { reason: cacheMetadata.reason, xctestrunPath: null, productPaths: [] };
}
- return { reason: 'reuse_ready', xctestrunPath, productPaths, source };
+ const { validateRunnerCacheArtifactManifest } = await import('./runner-artifact-manifest.ts');
+ const artifacts = validateRunnerCacheArtifactManifest(options.derived, cacheMetadata.metadata);
+ if (!artifacts.ok) {
+ return artifacts.mismatch
+ ? {
+ reason: 'artifact_content_mismatch',
+ xctestrunPath: null,
+ productPaths: [],
+ mismatch: artifacts.mismatch,
+ }
+ : { reason: 'artifact_manifest_missing', xctestrunPath: null, productPaths: [] };
+ }
+ return {
+ reason: 'reuse_ready',
+ xctestrunPath: artifacts.xctestrunPath,
+ productPaths: artifacts.productPaths,
+ };
}
/**
* Reports why a cache state cannot be reused, naming the differing keys when the
- * cause is a metadata mismatch.
+ * cause is a metadata mismatch and the failing entry when the cause is content.
*/
export function emitRunnerXctestrunRebuildDecision(
existing: Exclude,
@@ -437,10 +359,10 @@ export function emitRunnerXctestrunRebuildDecision(
): void {
emitRunnerXctestrunDecision('rebuild', existing.reason, {
derived,
- xctestrunPath: existing.xctestrunPath,
...(existing.reason === 'cache_metadata_mismatch'
? { metadataDifferences: existing.metadataDifferences }
: {}),
+ ...(existing.reason === 'artifact_content_mismatch' ? { mismatch: existing.mismatch } : {}),
});
}
@@ -448,9 +370,8 @@ export function emitRunnerXctestrunDecision(
action: 'clean' | 'reuse' | 'rebuild' | 'build' | 'preserve',
reason:
| 'forced_clean'
- | 'missing_xctestrun'
- | 'project_root_mismatch'
- | 'missing_products'
+ | 'artifact_manifest_missing'
+ | 'artifact_content_mismatch'
| 'cache_metadata_missing'
| 'cache_metadata_mismatch'
| 'repair_failed'
@@ -459,11 +380,12 @@ export function emitRunnerXctestrunDecision(
| 'bad_artifact'
| 'built_new'
| 'external_xctestrun'
- | 'external_bad_artifact',
+ | 'external_bad_artifact'
+ | 'uncertifiable_products',
data: Record,
): void {
emitDiagnostic({
- level: action === 'rebuild' ? 'warn' : 'info',
+ level: action === 'rebuild' || action === 'preserve' ? 'warn' : 'info',
phase: 'runner_xctestrun_cache',
data: {
action,
diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts
index 2512e25130..58a2678cca 100644
--- a/packages/platform-apple/src/runner/runner-source.ts
+++ b/packages/platform-apple/src/runner/runner-source.ts
@@ -32,6 +32,13 @@ export function resolveAppleSnapshotPresentationSourceRoot(projectRoot: string):
}
const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']);
+/**
+ * Finder and editor droppings that land inside an Xcode package without being build input. They
+ * would otherwise flip the fingerprint and cost a full runner rebuild whenever a checkout is
+ * browsed. Xcode keeps user state under `xcuserdata`, already ignored as a directory.
+ */
+const XCODE_PACKAGE_NON_BUILD_FILE_NAMES = new Set(['.DS_Store']);
+const XCODE_PACKAGE_NON_BUILD_FILE_EXTENSIONS = new Set(['.xcuserstate']);
const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([
'.build',
'.swiftpm',
@@ -116,44 +123,74 @@ function collectRunnerSourceFilesUnderRoot(
ignoredDirectoryNames: ReadonlySet,
): string[] {
return fs.existsSync(root)
- ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames)
+ ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames, false)
: [];
}
function collectRunnerSourceFilesInDirectory(
directory: string,
ignoredDirectoryNames: ReadonlySet,
+ includeEveryFile: boolean,
): string[] {
const files: string[] = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
- if (!ignoredDirectoryNames.has(entry.name)) {
- files.push(...collectRunnerSourceFilesInDirectory(fullPath, ignoredDirectoryNames));
+ if (ignoredDirectoryNames.has(entry.name)) continue;
+ const nestedIncludeEveryFile = includeEveryFile || isXcodePackageDirectory(entry.name);
+ files.push(
+ ...collectRunnerSourceFilesInDirectory(
+ fullPath,
+ ignoredDirectoryNames,
+ nestedIncludeEveryFile,
+ ),
+ );
+ } else if (entry.isFile()) {
+ if (
+ includeEveryFile ? isXcodePackageNonBuildFile(entry.name) : !isRunnerSourceFile(entry.name)
+ ) {
+ continue;
}
- } else if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) {
files.push(fullPath);
}
}
return files;
}
-function isRunnerSourceFile(fileName: string, filePath: string): boolean {
- if (fileName === 'project.pbxproj') {
- return filePath.includes(`${path.sep}.xcodeproj${path.sep}`);
- }
- return [
- '.jpg',
- '.json',
- '.png',
- '.swift',
- '.m',
- '.h',
- '.plist',
- '.entitlements',
- '.xctestplan',
- '.xcconfig',
- '.storyboard',
- '.xib',
- ].includes(path.extname(fileName));
+/** Whether a file inside an Xcode package is Finder or editor droppings rather than build input. */
+function isXcodePackageNonBuildFile(fileName: string): boolean {
+ return (
+ XCODE_PACKAGE_NON_BUILD_FILE_NAMES.has(fileName) ||
+ XCODE_PACKAGE_NON_BUILD_FILE_EXTENSIONS.has(path.extname(fileName))
+ );
+}
+
+/**
+ * Xcode owns the contents of a project or workspace package — `project.pbxproj`, shared
+ * schemes, and workspace data all change what a build produces — so every file inside one
+ * is build input, not just the ones with a recognizable extension.
+ */
+function isXcodePackageDirectory(directoryName: string): boolean {
+ return directoryName.endsWith('.xcodeproj') || directoryName.endsWith('.xcworkspace');
+}
+
+const RUNNER_SOURCE_FILE_EXTENSIONS = new Set([
+ '.jpg',
+ '.json',
+ '.png',
+ '.swift',
+ '.m',
+ '.h',
+ '.plist',
+ '.entitlements',
+ '.xctestplan',
+ '.xcconfig',
+ '.storyboard',
+ '.xib',
+ '.xcscheme',
+ '.xcworkspacedata',
+]);
+
+function isRunnerSourceFile(fileName: string): boolean {
+ return RUNNER_SOURCE_FILE_EXTENSIONS.has(path.extname(fileName));
}
diff --git a/scripts/__tests__/apple-ci-impact.test.ts b/scripts/__tests__/apple-ci-impact.test.ts
index e6a00d9dce..3cc8e306fc 100644
--- a/scripts/__tests__/apple-ci-impact.test.ts
+++ b/scripts/__tests__/apple-ci-impact.test.ts
@@ -72,17 +72,38 @@ type AppleRunnerBuildStep = {
with?: Record;
};
-function appleRunnerBuildAction(): { text: string; steps: AppleRunnerBuildStep[] } {
+function appleRunnerBuildAction(): {
+ text: string;
+ steps: AppleRunnerBuildStep[];
+ inputs: Record;
+} {
const action = fs.readFileSync(
path.join(repoRoot, '.github/actions/setup-apple-runner-build/action.yml'),
'utf8',
);
const doc = parse(action) as {
+ inputs?: Record;
runs: { steps: AppleRunnerBuildStep[] };
};
- return { text: action, steps: doc.runs.steps };
+ return { text: action, steps: doc.runs.steps, inputs: doc.inputs ?? {} };
}
+/**
+ * The metadata writer resolves the build identity from these two values, so a job that omitted
+ * them must be refused when it starts rather than at re-publish time, after a full build.
+ */
+test('the Apple runner build action requires the identity its metadata writer reads', () => {
+ const { inputs } = appleRunnerBuildAction();
+ expect(inputs['xcuitest-platform']).toEqual({
+ required: true,
+ description: expect.stringContaining('AGENT_DEVICE_XCUITEST_PLATFORM'),
+ });
+ expect(inputs['xcuitest-destination']).toEqual({
+ required: true,
+ description: expect.stringContaining('AGENT_DEVICE_XCUITEST_DESTINATION'),
+ });
+});
+
test('Apple runner build cache uses only declared source and schema hashes', () => {
const { text, steps } = appleRunnerBuildAction();
const restoreIndex = steps.findIndex((step) => step.name === 'Restore Apple runner build cache');
@@ -95,6 +116,9 @@ test('Apple runner build cache uses only declared source and schema hashes', ()
'scripts/build-xcuitest-apple.sh',
]);
expect(steps[restoreIndex]?.with?.key).toContain('steps.cache-schema.outputs.value');
+ // Restoring under a prefix would hand a job products whose content manifest belongs to
+ // another identity, so the cache is exact-match only and the source hash must be in the key.
+ expect(steps[restoreIndex]?.with?.key).toContain('steps.source-hash.outputs.value');
expect(steps[restoreIndex]?.with?.['restore-keys']).toBeUndefined();
expect(cacheInputs(text)).not.toContain('scripts/patch-xcuitest-runner-icon.ts');
});
@@ -107,6 +131,9 @@ test('restored native products are rebuilt before caching and icon patching', ()
);
const saveIndex = steps.findIndex((step) => step.name === 'Save Apple runner build cache');
const patchIndex = steps.findIndex((step) => step.name === 'Patch XCTest runner icon');
+ const republishIndex = steps.findIndex(
+ (step) => step.name === 'Re-publish Apple runner cache metadata',
+ );
expect(buildIndex).toBeGreaterThan(restoreIndex);
expect(steps[buildIndex]?.if).toBeUndefined();
expect(steps[buildIndex]?.env?.AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH).toBe('1');
@@ -115,6 +142,11 @@ test('restored native products are rebuilt before caching and icon patching', ()
expect(patchIndex).toBeGreaterThan(saveIndex);
expect(steps[patchIndex]?.if).toBeUndefined();
expect(steps[patchIndex]?.run).toContain('scripts/patch-xcuitest-runner-icon.ts');
+ // The cache is saved unpatched, so the manifest that ships inside it certifies unpatched
+ // bytes. The tree that actually runs is republished after the patch rewrote those bytes.
+ expect(republishIndex).toBeGreaterThan(patchIndex);
+ expect(steps[republishIndex]?.run).toContain('scripts/write-xcuitest-cache-metadata.ts');
+ expect(steps[republishIndex]?.run).toContain('agent-device-build-for-testing.log');
expect(fs.readFileSync(path.join(repoRoot, 'scripts/build-xcuitest-apple.sh'), 'utf8')).toContain(
'if ! is_truthy "${AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH:-}"; then',
);
diff --git a/scripts/build-xcuitest-apple.sh b/scripts/build-xcuitest-apple.sh
index 6d93bcc0fd..78887ddd7d 100644
--- a/scripts/build-xcuitest-apple.sh
+++ b/scripts/build-xcuitest-apple.sh
@@ -4,7 +4,6 @@ set -eu
PLATFORM="${AGENT_DEVICE_XCUITEST_PLATFORM:-}"
PROJECT_PATH="apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj"
SCHEME="AgentDeviceRunner"
-DEFAULT_IOS_RUNNER_APP_BUNDLE_ID="com.callstack.agentdevice.runner"
if [ -z "$PLATFORM" ]; then
echo "AGENT_DEVICE_XCUITEST_PLATFORM is required (ios, macos, tvos, visionos)" >&2
@@ -121,50 +120,31 @@ resolve_clean_path() {
DESTINATION="${AGENT_DEVICE_XCUITEST_DESTINATION:-$(resolve_default_destination)}"
DERIVED_PATH="${AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH:-$(resolve_default_derived_path)}"
CLEAN_PATH="$(resolve_clean_path)"
-RUNNER_APP_BUNDLE_ID="${AGENT_DEVICE_IOS_BUNDLE_ID:-${AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID:-$DEFAULT_IOS_RUNNER_APP_BUNDLE_ID}}"
-RUNNER_TEST_BUNDLE_ID="${AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID:-$RUNNER_APP_BUNDLE_ID.uitests}"
-SIGNING_BUILD_SETTINGS=""
-
-if [ "$PLATFORM" = "macos" ]; then
- SIGNING_BUILD_SETTINGS="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM="
-fi
if is_truthy "${AGENT_DEVICE_IOS_CLEAN_DERIVED:-}"; then
rm -rf "$CLEAN_PATH"
fi
-SWIFT_FLAGS='$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY'
-if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then
- SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS"
-fi
-
-# Optional arch override. A generic simulator destination leaves the active arch
-# undefined; Xcode versions differ on the default (26.6 picks x86_64, which runs
-# under Rosetta on arm64 hosts). Set AGENT_DEVICE_XCUITEST_ARCHS=arm64 to pin it.
-ARCH_BUILD_SETTINGS=""
-if [ -n "${AGENT_DEVICE_XCUITEST_ARCHS:-}" ]; then
- ARCH_BUILD_SETTINGS="ARCHS=$AGENT_DEVICE_XCUITEST_ARCHS"
-fi
+# The compiler recipe and the cache identity come from one owner: this build passes exactly the
+# build settings scripts/write-xcuitest-cache-metadata.ts records, so a manifest can never
+# certify products compiled under settings the cache key does not name.
+BUILD_SETTINGS_FILE="$DERIVED_PATH/Logs/agent-device-build-settings.txt"
+mkdir -p "$DERIVED_PATH/Logs"
+node --experimental-strip-types scripts/xcuitest-build-settings.ts "$PLATFORM" "$DESTINATION" \
+ > "$BUILD_SETTINGS_FILE"
build_for_testing() {
+ set --
+ while IFS= read -r build_setting; do
+ [ -n "$build_setting" ] || continue
+ set -- "$@" "$build_setting"
+ done < "$BUILD_SETTINGS_FILE"
node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \
-project "$PROJECT_PATH" \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_PATH" \
- AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID="$RUNNER_APP_BUNDLE_ID" \
- AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID="$RUNNER_TEST_BUNDLE_ID" \
- COMPILER_INDEX_STORE_ENABLE=NO \
- ENABLE_CODE_COVERAGE=NO \
- ONLY_ACTIVE_ARCH=YES \
- ENABLE_PREVIEWS=NO \
- ENABLE_DEBUG_DYLIB=NO \
- -IDEPackageSupportDisableManifestSandbox=1 \
- -IDEPackageSupportDisablePluginExecutionSandbox=1 \
- ENABLE_USER_SCRIPT_SANDBOXING=NO \
- OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \
- $ARCH_BUILD_SETTINGS \
- $SIGNING_BUILD_SETTINGS
+ "$@"
}
# The isolation scan reads the compiler diagnostics in the build log, and an incremental build
@@ -199,4 +179,4 @@ fi
if ! is_truthy "${AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH:-}"; then
node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "$DERIVED_PATH"
fi
-node scripts/write-xcuitest-cache-metadata.mjs "$PLATFORM" "$DERIVED_PATH" "$DESTINATION"
+node --experimental-strip-types scripts/write-xcuitest-cache-metadata.ts "$PLATFORM" "$DERIVED_PATH" "$DESTINATION" "$BUILD_LOG"
diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs
deleted file mode 100644
index 18c94ec693..0000000000
--- a/scripts/write-xcuitest-cache-metadata.mjs
+++ /dev/null
@@ -1,527 +0,0 @@
-#!/usr/bin/env node
-import crypto from 'node:crypto';
-import fs from 'node:fs';
-import path from 'node:path';
-import { execFileSync } from 'node:child_process';
-import { pathToFileURL } from 'node:url';
-
-let platform = '';
-let derivedPath = '';
-let destination = '';
-let projectRoot = '';
-let metadataPath = '';
-
-const USAGE =
- 'Usage: write-xcuitest-cache-metadata.mjs ';
-
-const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';
-const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']);
-const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([
- '.build',
- '.swiftpm',
- 'SnapshotPresentationConformance',
- 'Tests',
- 'xcuserdata',
-]);
-
-function isTruthy(value) {
- return ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'ON'].includes(String(value ?? ''));
-}
-
-function readPackageVersion() {
- try {
- const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
- return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
- } catch {
- return '0.0.0';
- }
-}
-
-function normalizeBundleId(value) {
- return typeof value === 'string' ? value.trim() : '';
-}
-
-function resolveRunnerAppBundleId() {
- return (
- normalizeBundleId(process.env.AGENT_DEVICE_IOS_BUNDLE_ID) ||
- normalizeBundleId(process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID) ||
- DEFAULT_IOS_RUNNER_APP_BUNDLE_ID
- );
-}
-
-function resolveRunnerTestBundleId() {
- return (
- normalizeBundleId(process.env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID) ||
- `${resolveRunnerAppBundleId()}.uitests`
- );
-}
-
-function computeRunnerSourceFingerprint() {
- const sourceRoots = [
- {
- path: path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'),
- ignoredDirectoryNames: RUNNER_SOURCE_IGNORED_DIR_NAMES,
- },
- {
- path: path.join(projectRoot, 'apple', 'snapshot-presentation'),
- ignoredDirectoryNames: SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES,
- },
- ];
- const files = collectRunnerSourceFiles(sourceRoots);
- const hash = crypto.createHash('sha256');
- for (const file of files) {
- hash.update(path.relative(projectRoot, file));
- hash.update('\0');
- hash.update(fs.readFileSync(file));
- hash.update('\0');
- }
- return hash.digest('hex');
-}
-
-function collectRunnerSourceFiles(roots) {
- const files = [];
- for (const { path: root, ignoredDirectoryNames } of roots) {
- if (!fs.existsSync(root)) {
- continue;
- }
- const stack = [root];
- while (stack.length > 0) {
- const current = stack.pop();
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
- const fullPath = path.join(current, entry.name);
- if (entry.isDirectory()) {
- if (ignoredDirectoryNames.has(entry.name)) continue;
- stack.push(fullPath);
- continue;
- }
- if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) {
- files.push(fullPath);
- }
- }
- }
- }
- return [...new Set(files)].sort((a, b) => a.localeCompare(b));
-}
-
-function isRunnerSourceFile(fileName, filePath) {
- if (fileName === 'project.pbxproj') {
- return filePath.includes(`${path.sep}.xcodeproj${path.sep}`);
- }
- return [
- '.jpg',
- '.json',
- '.png',
- '.swift',
- '.m',
- '.h',
- '.plist',
- '.entitlements',
- '.xctestplan',
- '.xcconfig',
- '.storyboard',
- '.xib',
- ].includes(path.extname(fileName));
-}
-
-function resolvePlatformName() {
- if (platform === 'ios') return 'iOS';
- if (platform === 'tvos') return 'tvOS';
- if (platform === 'macos') return 'macOS';
- if (platform === 'visionos') return 'visionOS';
- throw new Error(`Unsupported platform: ${platform}`);
-}
-
-function resolveDeviceKind() {
- if (platform === 'macos') return 'device';
- return destination.includes('Simulator') ? 'simulator' : 'device';
-}
-
-function resolveTarget() {
- if (platform === 'macos') return 'desktop';
- if (platform === 'tvos') return 'tv';
- return 'mobile';
-}
-
-function resolveMacRunnerArch() {
- return process.arch === 'arm64' ? 'arm64' : 'x86_64';
-}
-
-function resolveBuildDestinationFamily() {
- const platformName = resolvePlatformName();
- if (platformName === 'macOS') {
- return `platform=macOS,arch=${resolveMacRunnerArch()}`;
- }
- if (resolveDeviceKind() === 'simulator') {
- return `generic/platform=${platformName} Simulator`;
- }
- return `generic/platform=${platformName}`;
-}
-
-function resolveRunnerSdkName() {
- const platformName = resolvePlatformName();
- if (platformName === 'macOS') return 'macosx';
- if (platformName === 'tvOS') {
- return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos';
- }
- if (platformName === 'visionOS') {
- return resolveDeviceKind() === 'simulator' ? 'xrsimulator' : 'xros';
- }
- return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos';
-}
-
-function runAppleToolFingerprintCommand(command, args) {
- const probe = [command, ...args].join(' ');
- let output;
- try {
- output = execFileSync(command, args, {
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- timeout: 5000,
- maxBuffer: 128 * 1024,
- }).trim();
- } catch (error) {
- throw new Error(`Apple toolchain probe failed: ${probe} (${error?.message ?? error})`);
- }
- if (!output) {
- throw new Error(`Apple toolchain probe produced no output: ${probe}`);
- }
- return output;
-}
-
-function parseXcodeVersionOutput(output) {
- const version = output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim();
- const buildVersion = output.match(/^Build version\s+(.+)$/m)?.[1]?.trim();
- if (!version || !buildVersion) {
- throw new Error('Apple toolchain probe produced unrecognized output: xcodebuild -version');
- }
- return { version, buildVersion };
-}
-
-function resolveRunnerToolchainFingerprint() {
- const xcode = parseXcodeVersionOutput(runAppleToolFingerprintCommand('xcodebuild', ['-version']));
- const sdkName = resolveRunnerSdkName();
- return {
- xcodeVersion: xcode.version,
- xcodeBuildVersion: xcode.buildVersion,
- sdkName,
- sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']),
- sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [
- '--sdk',
- sdkName,
- '--show-sdk-build-version',
- ]),
- };
-}
-
-function resolveSigningBuildSettings() {
- if (platform !== 'macos') {
- return [];
- }
- return [
- 'CODE_SIGNING_ALLOWED=NO',
- 'CODE_SIGNING_REQUIRED=NO',
- 'CODE_SIGN_IDENTITY=',
- 'DEVELOPMENT_TEAM=',
- ];
-}
-
-function resolveSandboxBuildArgs() {
- const swiftFlags = isTruthy(process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS)
- ? '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'
- : '$(inherited) -disable-sandbox';
- return [
- '-IDEPackageSupportDisableManifestSandbox=1',
- '-IDEPackageSupportDisablePluginExecutionSandbox=1',
- 'ENABLE_USER_SCRIPT_SANDBOXING=NO',
- `OTHER_SWIFT_FLAGS=${swiftFlags}`,
- ];
-}
-
-export function writeXcuitestCacheMetadata(args = process.argv.slice(2), cwd = process.cwd()) {
- const [nextPlatform, nextDerivedPath, nextDestination] = args;
- if (!nextPlatform || !nextDerivedPath || !nextDestination) {
- throw new Error(USAGE);
- }
-
- platform = nextPlatform;
- derivedPath = nextDerivedPath;
- destination = nextDestination;
- projectRoot = cwd;
- metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json');
-
- const appBundleId = resolveRunnerAppBundleId();
- const testBundleId = resolveRunnerTestBundleId();
- const metadata = {
- schemaVersion: 2,
- packageVersion: readPackageVersion(),
- runnerSourceFingerprint: computeRunnerSourceFingerprint(),
- ...resolveRunnerToolchainFingerprint(),
- platformName: resolvePlatformName(),
- deviceKind: resolveDeviceKind(),
- target: resolveTarget(),
- buildDestinationFamily: resolveBuildDestinationFamily(),
- runnerBundleBuildSettings: [
- `AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=${appBundleId}`,
- `AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=${testBundleId}`,
- ],
- runnerSigningBuildSettings: resolveSigningBuildSettings(),
- runnerPerformanceBuildSettings: [
- 'COMPILER_INDEX_STORE_ENABLE=NO',
- 'ENABLE_CODE_COVERAGE=NO',
- 'ONLY_ACTIVE_ARCH=YES',
- 'ENABLE_PREVIEWS=NO',
- 'ENABLE_DEBUG_DYLIB=NO',
- ],
- runnerSandboxBuildArgs: resolveSandboxBuildArgs(),
- };
-
- const artifacts = resolveRunnerCacheArtifacts();
- if (artifacts) {
- metadata.artifacts = artifacts;
- }
-
- fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
- fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
- return metadata;
-}
-
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- try {
- writeXcuitestCacheMetadata();
- } catch (error) {
- console.error(error instanceof Error ? error.message : String(error));
- process.exit(1);
- }
-}
-
-function resolveRunnerCacheArtifacts() {
- const xctestrunPath = findXctestrun(derivedPath);
- if (!xctestrunPath) return null;
- const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath);
- if (!productPaths || productPaths.length === 0) return null;
- const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath);
- const xctestrunSize = readFileSize(xctestrunPath);
- if (xctestrunMtimeMs === null || xctestrunSize === null) return null;
- const productArtifacts = [];
- for (const productPath of productPaths) {
- const mtimeMs = readFileMtimeMs(productPath);
- const size = readFileSize(productPath);
- if (mtimeMs === null || size === null) return null;
- productArtifacts.push({ path: productPath, mtimeMs, size });
- }
- return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts };
-}
-
-function findXctestrun(root) {
- if (!fs.existsSync(root)) return null;
- const candidates = [];
- const stack = [root];
- while (stack.length > 0) {
- const current = stack.pop();
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
- const fullPath = path.join(current, entry.name);
- if (entry.isDirectory()) {
- stack.push(fullPath);
- continue;
- }
- if (!entry.isFile() || !entry.name.endsWith('.xctestrun')) {
- continue;
- }
- try {
- candidates.push({ path: fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs });
- } catch {}
- }
- }
- if (candidates.length === 0) return null;
- candidates.sort((left, right) => {
- const scoreDiff = scoreXctestrunCandidate(right.path) - scoreXctestrunCandidate(left.path);
- return scoreDiff || right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path);
- });
- return candidates[0]?.path ?? null;
-}
-
-function scoreXctestrunCandidate(candidatePath) {
- const basename = path.basename(candidatePath);
- let score = 0;
- if (basename.includes('.env.')) score -= 50;
- if (platform === 'ios') {
- score += destination.includes('Simulator')
- ? basename.includes('iphonesimulator')
- ? 100
- : 0
- : basename.includes('iphoneos')
- ? 100
- : 0;
- } else if (platform === 'tvos') {
- score += destination.includes('Simulator')
- ? basename.includes('appletvsimulator')
- ? 100
- : 0
- : basename.includes('appletvos')
- ? 100
- : 0;
- } else if (platform === 'macos') {
- score +=
- basename.includes('macos') || candidatePath.includes(`${path.sep}macos${path.sep}`) ? 100 : 0;
- } else if (platform === 'visionos') {
- score += destination.includes('Simulator')
- ? basename.includes('xrsimulator')
- ? 100
- : 0
- : basename.includes('xros')
- ? 100
- : 0;
- }
- return score;
-}
-
-function resolveExistingXctestrunProductPaths(xctestrunPath) {
- const values = resolveXctestrunProductReferences(xctestrunPath);
- if (!values || values.length === 0) return null;
- const testRoot = path.dirname(xctestrunPath);
- const resolvedPaths = new Set();
- const products = collectResolvedTestHostProducts(values, testRoot);
-
- for (const resolvedPath of products.testRootPaths) {
- if (!fs.existsSync(resolvedPath)) return null;
- resolvedPaths.add(resolvedPath);
- }
-
- for (const resolvedPath of resolveTestHostRelativePaths(products)) {
- if (!resolvedPath) return null;
- resolvedPaths.add(resolvedPath);
- }
-
- return Array.from(resolvedPaths);
-}
-
-function resolveXctestrunProductReferences(xctestrunPath) {
- let parsed;
- try {
- parsed = JSON.parse(
- execFileSync('plutil', ['-convert', 'json', '-o', '-', xctestrunPath], {
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- }),
- );
- } catch {
- return null;
- }
- return resolveXctestrunProductReferencesFromJson(parsed);
-}
-
-function resolveXctestrunProductReferencesFromJson(parsed) {
- const values = new Set();
- for (const target of collectXctestrunProductReferenceTargets(parsed)) {
- for (const value of collectXctestrunProductReferenceValuesFromTarget(target)) {
- values.add(value);
- }
- }
- return Array.from(values);
-}
-
-function collectXctestrunProductReferenceTargets(parsed) {
- return [parsed, ...collectConfiguredTestTargets(parsed), ...collectLegacyTestTargets(parsed)];
-}
-
-function collectConfiguredTestTargets(parsed) {
- const testConfigurations = parsed?.TestConfigurations;
- if (!Array.isArray(testConfigurations)) return [];
- const targets = [];
- for (const config of testConfigurations) {
- if (!isRecord(config) || !Array.isArray(config.TestTargets)) {
- continue;
- }
- targets.push(...config.TestTargets.filter(isRecord));
- }
- return targets;
-}
-
-function collectLegacyTestTargets(parsed) {
- if (!isRecord(parsed)) return [];
- return Object.values(parsed).filter((value) => isRecord(value) && 'TestBundlePath' in value);
-}
-
-function isRecord(value) {
- return value !== null && typeof value === 'object' && !Array.isArray(value);
-}
-
-function collectXctestrunProductReferenceValuesFromTarget(target) {
- const values = new Set();
- const productReferenceKeys = new Set([
- 'ProductPaths',
- 'DependentProductPaths',
- 'TestHostPath',
- 'TestBundlePath',
- 'UITargetAppPath',
- ]);
- for (const [key, value] of Object.entries(target)) {
- if (!productReferenceKeys.has(key)) continue;
- if (typeof value === 'string') {
- values.add(value);
- continue;
- }
- if (!Array.isArray(value)) continue;
- for (const item of value) {
- if (typeof item === 'string') values.add(item);
- }
- }
- return Array.from(values);
-}
-
-function collectResolvedTestHostProducts(values, testRoot) {
- const testRootPaths = [];
- const hostRoots = new Set();
- const hostRelativePaths = [];
-
- for (const value of values) {
- if (value.startsWith('__TESTHOST__/')) {
- hostRelativePaths.push(value.slice('__TESTHOST__/'.length));
- continue;
- }
- if (!value.startsWith('__TESTROOT__/')) continue;
- const relativePath = value.slice('__TESTROOT__/'.length);
- testRootPaths.push(path.join(testRoot, relativePath));
- const appBundleRoot = extractAppBundleRoot(relativePath);
- if (appBundleRoot) {
- hostRoots.add(path.join(testRoot, appBundleRoot));
- }
- }
-
- return {
- testRootPaths,
- hostRoots: Array.from(hostRoots),
- hostRelativePaths,
- };
-}
-
-function resolveTestHostRelativePaths(products) {
- return products.hostRelativePaths.map((relativePath) => {
- const resolvedHostRoot = products.hostRoots.find((hostRoot) =>
- fs.existsSync(path.join(hostRoot, relativePath)),
- );
- return resolvedHostRoot ? path.join(resolvedHostRoot, relativePath) : null;
- });
-}
-
-function extractAppBundleRoot(relativePath) {
- const match = /\.app(?:\/|$)/.exec(relativePath);
- if (!match || match.index === undefined) return null;
- return relativePath.slice(0, match.index + '.app'.length);
-}
-
-function readFileMtimeMs(filePath) {
- try {
- return Math.trunc(fs.statSync(filePath).mtimeMs);
- } catch {
- return null;
- }
-}
-
-function readFileSize(filePath) {
- try {
- return fs.statSync(filePath).size;
- } catch {
- return null;
- }
-}
diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts
new file mode 100644
index 0000000000..f4b8e8c4dd
--- /dev/null
+++ b/scripts/write-xcuitest-cache-metadata.ts
@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations';
+import {
+ findRunnerXctestrun,
+ isRunnerXcuitestScriptPlatform,
+ requireCertifiedRunnerCacheArtifacts,
+ requireRunnerBuildSettingsMatchBuildLog,
+ resolveExistingRunnerProductPaths,
+ resolveExpectedRunnerCacheMetadata,
+ resolveRunnerScriptDevice,
+ writeRunnerCacheMetadataForArtifacts,
+} from '@agent-device/platform-apple/runner/operations';
+
+const USAGE =
+ 'Usage: write-xcuitest-cache-metadata.ts ';
+
+/**
+ * Publishes the cache metadata for a `scripts/build-xcuitest-apple.sh` build. The identity half
+ * resolves through the same owner the daemon uses; the recorded recipe is then checked against
+ * what `xcodebuild` echoed in the build log, so a script that drifted from that identity cannot
+ * have its products certified. The artifact half digests the bytes on disk now, after the
+ * isolation scan succeeded, so a manifest exists exactly for builds that passed the scan.
+ */
+type WriterInvocation = {
+ device: ReturnType;
+ derivedPath: string;
+ buildLogPath: string;
+};
+
+// fallow-ignore-next-line complexity
+function parseWriterInvocation(args: readonly string[]): WriterInvocation {
+ const [platform, derivedPath, destination, buildLogPath] = args;
+ if (!platform || !derivedPath || !destination || !buildLogPath) {
+ throw new Error(USAGE);
+ }
+ if (!isRunnerXcuitestScriptPlatform(platform)) {
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+ return {
+ device: resolveRunnerScriptDevice(platform, destination),
+ derivedPath: path.resolve(derivedPath),
+ buildLogPath: path.resolve(buildLogPath),
+ };
+}
+
+async function writeXcuitestCacheMetadata(
+ args: readonly string[],
+ cwd: string,
+): Promise {
+ const { device, derivedPath, buildLogPath } = parseWriterInvocation(args);
+ const metadata = resolveExpectedRunnerCacheMetadata(device, cwd);
+ requireRunnerBuildSettingsMatchBuildLog(metadata, buildLogPath);
+
+ const xctestrunPath = findRunnerXctestrun(derivedPath, device);
+ if (!xctestrunPath) {
+ throw new Error(`No .xctestrun found under ${derivedPath}`);
+ }
+ const productPaths = await resolveExistingRunnerProductPaths(xctestrunPath);
+ if (!productPaths || productPaths.length === 0) {
+ throw new Error(`Runner products referenced by ${xctestrunPath} are missing`);
+ }
+ requireCertifiedRunnerCacheArtifacts(
+ await writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths),
+ derivedPath,
+ );
+ return metadata;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ try {
+ await writeXcuitestCacheMetadata(process.argv.slice(2), process.cwd());
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
diff --git a/scripts/xcuitest-build-settings.ts b/scripts/xcuitest-build-settings.ts
new file mode 100644
index 0000000000..9ab9f596db
--- /dev/null
+++ b/scripts/xcuitest-build-settings.ts
@@ -0,0 +1,46 @@
+// The build-recipe half of the Apple runner cache identity, in the words `xcodebuild` takes.
+// `scripts/build-xcuitest-apple.sh` passes what this prints, and
+// `scripts/write-xcuitest-cache-metadata.ts` records what the same resolvers return, so the
+// compiler invocation and the cache identity cannot drift apart.
+import { pathToFileURL } from 'node:url';
+import {
+ isRunnerXcuitestScriptPlatform,
+ resolveRunnerArchBuildSettings,
+ resolveRunnerBundleBuildSettings,
+ resolveRunnerPerformanceBuildSettings,
+ resolveRunnerSandboxBuildArgs,
+ resolveRunnerScriptDevice,
+ resolveRunnerSigningBuildSettings,
+} from '@agent-device/platform-apple/runner/operations';
+
+const USAGE = 'Usage: xcuitest-build-settings.ts ';
+
+/** The `xcodebuild` build settings `scripts/build-xcuitest-apple.sh` is about to run with. */
+function resolveXcuitestBuildSettings(
+ platform: string,
+ destination: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string[] {
+ if (!isRunnerXcuitestScriptPlatform(platform)) {
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+ const device = resolveRunnerScriptDevice(platform, destination);
+ return [
+ ...resolveRunnerPerformanceBuildSettings(),
+ ...resolveRunnerArchBuildSettings(env),
+ ...resolveRunnerSandboxBuildArgs(),
+ ...resolveRunnerBundleBuildSettings(env),
+ ...resolveRunnerSigningBuildSettings(env, device.kind === 'device', device),
+ ];
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const [platform, destination] = process.argv.slice(2);
+ if (!platform || !destination) {
+ console.error(USAGE);
+ process.exit(1);
+ }
+ for (const setting of resolveXcuitestBuildSettings(platform, destination)) {
+ process.stdout.write(`${setting}\n`);
+ }
+}
diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts
index 212235fd3e..fa942556d6 100644
--- a/src/commands/schema/cli-help-topics.test.ts
+++ b/src/commands/schema/cli-help-topics.test.ts
@@ -577,7 +577,7 @@ test('usageForCommand resolves validate help topic', async () => {
assert.match(help, /Use the settled diff as evidence/);
assert.match(help, /Close sessions and release leases/);
assert.match(help, /exact key that includes the agent-device package and Xcode version/);
- assert.match(help, /Avoid broad restore-key fallbacks/);
+ assert.match(help, /Runner reuse is authorized only by the cache metadata's content manifest/);
});
test('usageForCommand resolves macos help topic', async () => {
diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts
index c3fd791ac7..54967bf782 100644
--- a/src/commands/schema/cli-help.ts
+++ b/src/commands/schema/cli-help.ts
@@ -1039,7 +1039,7 @@ Required freshness gate before device verification:
Before local Android verification, run pnpm build:android before pnpm clean:daemon so the bundled helpers match current source.
For an Apple runner change, run pnpm build:xcuitest and avoid inherited retained runners from older source. Do not build the Apple runner for TypeScript-only changes.
Use open --relaunch when startup state matters. Use a purpose-specific --session for multi-step validation.
- CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch.
+ CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Runner reuse is authorized only by the cache metadata's content manifest, so a restore that fails validation rebuilds; prepare ios-runner already recovers one retryable non-connecting runner launch.
Loop:
1. Build or prepare the changed surface with the repo command that owns it.
diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts
index 8909079f82..742a5835db 100644
--- a/src/mcp/command-output-schemas.ts
+++ b/src/mcp/command-output-schemas.ts
@@ -596,7 +596,7 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = {
kind: enumSchema(DEVICE_KINDS),
durationMs: numberSchema(),
runner: objectSchema({}, []),
- cache: enumSchema(['exact', 'restore-key', 'miss', 'external']),
+ cache: enumSchema(['exact', 'miss', 'external']),
artifact: enumSchema(['valid', 'rebuilt']),
buildMs: numberSchema(),
connectMs: numberSchema(),
diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md
index ca8a246e10..2b4ad186af 100644
--- a/website/docs/docs/commands.md
+++ b/website/docs/docs/commands.md
@@ -270,7 +270,8 @@ agent-device prepare ios-runner --platform ios --timeout 240000
- If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once.
- If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild.
- CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version.
-- Avoid broad `restore-keys` fallbacks for runner caches. Reusing runner artifacts across Agent Device or Xcode versions can restore stale `.xctestrun` products; `prepare ios-runner` already handles bad exact-cache artifacts and one retryable non-connecting runner launch.
+- Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache.
+- Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal.
- Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues.
## TV targets