Files
callstack__agent-device/scripts/write-xcuitest-cache-metadata.mjs
Michał Pierzchała 96727a0b42 fix(apple-runner): never compare an unavailable toolchain probe; name the mismatching cache keys (#2306)
* fix(apple-runner): never compare an unavailable toolchain probe

A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used
to fall back to the literal `unknown`, which was memoized for the process and
then persisted into the rebuilt cache's metadata, so every later daemon on a
healthy host mismatched again and paid a full build-for-testing.

Unavailability is now a distinct outcome with no comparable value: only
successful probes are memoized, an unreadable toolchain fails the cache
decision with a retriable typed error naming the probe that could not answer,
and the CI metadata writer refuses to persist a probe it could not read. The
cache_metadata_mismatch diagnostic now lists the differing keys with expected
and actual values instead of only saying the metadata differed.

The runner-source fingerprint moves to the module that owns the runner's
source roots, keeping the cache-metadata module within its size budget without
adding a module to the Apple facades' eager closure.

* refactor(apple-runner): home the fingerprint tests and the rebuild-decision glue

Tests mirror source topology: the runner-source fingerprint tests move with the
function into runner-source.test.ts and call it directly instead of reaching it
through resolveExpectedRunnerCacheMetadata.

The rebuild diagnostic's mismatch details move next to the cache state that
carries them, so runner-artifact.ts — already past the 500-line extract
threshold — gains no behavior.

* fix(apple-runner): memoize only a parsed toolchain fingerprint

runToolchainProbe cached every nonempty zero-exit answer before
parseXcodeVersionOutput could classify it, so a transient malformed
xcodebuild answer stayed cached and every later cache decision in the
process kept failing after the host recovered. The memo now holds the
complete parsed fingerprint per SDK, written only after all three probes
answered and parsed; a failed round keeps nothing, so the next request
re-probes. Tests cover malformed-to-healthy recovery in one process without
resetting the memo, and that a partial round is not kept.

* style(apple-runner): oxfmt the cache-metadata module and its tests
2026-09-05 22:15:14 +02:00

528 lines
16 KiB
JavaScript

#!/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 <ios|macos|tvos|visionos> <derived> <destination>';
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;
}
}