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