Files
callstack__agent-device/scripts/write-xcuitest-cache-metadata.mjs
T
Michał Pierzchała cba020de21 fix: add iOS private AX snapshot fallback (#758)
* fix: add iOS private AX snapshot fallback

* fix: add public iOS snapshot query recovery

* fix(ios): make private AX snapshot fallback recover deep React Native trees

Four fixes that turn the #758 private AX fallback from
works-on-one-tree-shape into reliable on Bluesky Home:

- Depth ladder: the AX server rejects bulk snapshot requests outright
  (kAXErrorIllegalArgument) once requested depth crosses a
  tree-size-dependent limit that moves with live content. Retry at
  56/40/24/12 instead of giving up after one attempt at 64.
- Real attribute identifiers: the server silently ignored the raw
  keypath strings the bridge passed, so every node came back with a
  zero frame (breaking ref taps and the interactive/compact filters,
  which is why 'snapshot -i -c' stayed sparse). Map keypaths through
  XCElementSnapshot.axAttributesForElementSnapshotKeyPaths (it returns
  an NSSet) and drop the mapper's expensive extras (automation type,
  window display id, base type) that pushed deep requests past the 30s
  main-thread watchdog.
- Viewport from the private root frame when the public windows query
  degrades to an infinite viewport, so off-screen drawer content stops
  passing the visibility filter.
- Runner source fingerprint now includes .m/.h, so bridge edits stop
  reusing stale cached runner builds.

Also hardens the bridge per review: UInt(exactly:) for untrusted
element types, pid_t-sized objc_msgSend for process id matching, and
objCType-checked NSValue frame decoding.

* fix(ios): recover deadline-truncated near-empty compact snapshots

The all-structural sparse detector misses the common large-RN-tree case
where the typed-query sweep resolves one or two stray controls before
its 1s deadline: the payload has 'content', so recovery never fires,
yet 2 nodes is useless in practice. Treat deadline-truncated payloads
with <= 8 nodes as needing recovery, and only replace the original
payload when the recovered tree actually carries more nodes. Completed
sweeps on legitimately minimal screens stay untouched (not truncated).

* chore: fix CI for the AX snapshot fallback branch

- Sync the setup metadata script's fingerprint extension list with the
  runtime (.m/.h were added for the ObjC bridge), fixing the cache
  metadata parity test.
- Reduce find.ts complexity flagged by fallow: hoist the node fetcher
  into createFindNodeFetcher with a recoverSparseInteractiveSnapshot
  helper, split match disambiguation and resolution scoring into
  narrowMultipleMatches/resolvedTouchScore, extract rectsMatch.

* feat(ios): make accessibility fallbacks and collapsed containers visible in snapshot output

Two transparency gaps from #701's 'no silent fallback' requirement:

- Runner-attached snapshot messages now surface as snapshot warnings
  (readAppleSnapshotResult previously dropped them), so every recovery
  through the fallback accessibility backend or query tier is announced,
  states what it usually means (the app publishes an unhealthy
  accessibility tree - fixing the app is the real cure), and points to
  screenshot as visual truth.

- A leaf whose label merges many comma-joined segments is flagged as a
  collapsed accessible container: the app marks a container accessible,
  hiding every descendant from assistive tech and automation alike.
  Nothing can be recovered below it (VoiceOver sees the same merged
  element), so the warning names the node, estimates the merged label
  count, and gives the app-side fix plus the screenshot/coordinate-tap
  workaround.

Validated live on the lab stress fixture (adlab://stress?accessible=1):
the 6-node tree now carries '@e5 [Other] merges ~126 labels...'.

* fix(ios): detect sparse trees with labeled roots and surface warnings through the daemon

Validated against a real-world repro (a production React Native app's
login screen, simulator build provided privately by the reporter): a
full-screen accessibilityViewIsModal overlay leaves the public snapshot
with just Application+Window. Two gaps kept recovery off:

- The sparse detector counted the Application label (the app's display
  name) as content and the full-screen root as hittable, so the app
  name alone defeated recovery. Application/Window labels and root
  hittability say nothing about tree health and no longer count.
- Interactor-level snapshot warnings were dropped by the daemon capture
  chain (only the runtime/commands layer kept them); they now thread
  through CaptureSnapshotResult into BackendSnapshotResult.

With both fixes that login screen recovers through the public query
tier: 16 nodes with every control addressable (fill @ref + read-back
verified), and the output carries the recovery warning. Bluesky-class
trees still ladder into the private fallback unchanged.
2026-06-12 07:55:17 +02:00

449 lines
13 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 args = process.argv.slice(2);
const [platform, derivedPath, destination] = args;
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',
'.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';
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';
}
return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos';
}
function runAppleToolFingerprintCommand(command, args) {
try {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
maxBuffer: 128 * 1024,
}).trim() || 'unknown';
} catch {
return 'unknown';
}
}
function parseXcodeVersionOutput(output) {
return {
version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown',
buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown',
};
}
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=',
];
}
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'],
};
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);
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;
}
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 readFileSize(filePath) {
try {
return fs.statSync(filePath).size;
} catch {
return null;
}
}
function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}