Files
callstack__agent-device/scripts/write-xcuitest-cache-metadata.mjs
T
Michał Pierzchała 86db7e837f fix: align iOS runner cache target metadata (#682)
* fix: align iOS runner cache target metadata

* test: surface iOS cache metadata script stderr
2026-06-03 09:35:34 -07:00

384 lines
11 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';
const [platform, derivedPath, destination] = process.argv.slice(2);
if (!platform || !derivedPath || !destination) {
console.error('Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos> <derived> <destination>');
process.exit(1);
}
const projectRoot = process.cwd();
const metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json');
const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';
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 runnerRoot = path.join(projectRoot, 'ios-runner', 'AgentDeviceRunner');
const files = collectRunnerSourceFiles(runnerRoot);
const hash = crypto.createHash('sha256');
for (const file of files) {
hash.update(path.relative(runnerRoot, file));
hash.update('\0');
hash.update(fs.readFileSync(file));
hash.update('\0');
}
return hash.digest('hex');
}
function collectRunnerSourceFiles(root) {
if (!fs.existsSync(root)) {
return [];
}
const files = [];
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 (entry.name === 'xcuserdata') continue;
stack.push(fullPath);
continue;
}
if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) {
files.push(fullPath);
}
}
}
return 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',
'.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';
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 resolveSigningBuildSettings() {
if (platform !== 'macos') {
return [];
}
return [
'CODE_SIGNING_ALLOWED=NO',
'CODE_SIGNING_REQUIRED=NO',
'CODE_SIGN_IDENTITY=',
'DEVELOPMENT_TEAM=',
];
}
const appBundleId = resolveRunnerAppBundleId();
const testBundleId = resolveRunnerTestBundleId();
const metadata = {
schemaVersion: 1,
packageVersion: readPackageVersion(),
runnerSourceFingerprint: computeRunnerSourceFingerprint(),
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'],
};
const artifacts = resolveRunnerCacheArtifacts();
if (artifacts) {
metadata.artifacts = artifacts;
}
fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
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);
if (xctestrunMtimeMs === null) return null;
const productArtifacts = [];
for (const productPath of productPaths) {
const mtimeMs = readFileMtimeMs(productPath);
if (mtimeMs === null) return null;
productArtifacts.push({ path: productPath, mtimeMs });
}
return { xctestrunPath, xctestrunMtimeMs, 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;
}
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 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 isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}