diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts
index 27f5f55a..1524bdad 100644
--- a/src/platforms/ios/__tests__/runner-client.test.ts
+++ b/src/platforms/ios/__tests__/runner-client.test.ts
@@ -519,6 +519,55 @@ test('xctestrunReferencesExistingProducts accepts xctestruns when referenced pro
assert.equal(xctestrunReferencesExistingProducts(xctestrunPath), true);
});
+test('xctestrunReferencesExistingProducts parses nested plist fallback values from XML', async () => {
+ const tmpDir = await makeTmpDir();
+ const productsDir = path.join(tmpDir, 'Build', 'Products');
+ const debugDir = path.join(productsDir, 'Debug');
+ await fs.promises.mkdir(path.join(debugDir, 'AgentDeviceRunner.app'), { recursive: true });
+ await fs.promises.mkdir(path.join(debugDir, 'Target.app'), { recursive: true });
+ await fs.promises.mkdir(path.join(debugDir, 'Frameworks', 'Helper.framework'), {
+ recursive: true,
+ });
+ await fs.promises.mkdir(
+ path.join(
+ debugDir,
+ 'AgentDeviceRunner.app',
+ 'Contents',
+ 'PlugIns',
+ 'AgentDeviceRunnerUITests.xctest',
+ ),
+ { recursive: true },
+ );
+ const xctestrunPath = path.join(productsDir, 'AgentDeviceRunner.xctestrun');
+ fs.writeFileSync(
+ xctestrunPath,
+ [
+ '',
+ 'TestConfigurations',
+ '',
+ 'TestTargets',
+ '',
+ 'ProductPaths',
+ '__TESTROOT__/Debug/AgentDeviceRunner.app',
+ '',
+ 'DependentProductPaths',
+ '__TESTROOT__/Debug/Frameworks/Helper.framework',
+ '',
+ 'TestHostPath__TESTROOT__/Debug/AgentDeviceRunner.app',
+ 'TestBundlePath__TESTHOST__/Contents/PlugIns/AgentDeviceRunnerUITests.xctest',
+ 'UITargetAppPath__TESTROOT__/Debug/Target.app',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ].join(''),
+ 'utf8',
+ );
+
+ assert.equal(xctestrunReferencesExistingProducts(xctestrunPath), true);
+});
+
test('ensureXctestrun 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.
diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts
index 41487751..0d35eff6 100644
--- a/src/platforms/ios/perf.ts
+++ b/src/platforms/ios/perf.ts
@@ -15,7 +15,7 @@ import {
} from './devicectl.ts';
import { readInfoPlistString } from './plist.ts';
import { buildSimctlArgsForDevice } from './simctl.ts';
-import { parseXmlDocument, type XmlNode } from './xml.ts';
+import { parseXmlDocumentSync, type XmlNode } from './xml.ts';
export const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot';
export const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot';
@@ -148,7 +148,7 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] {
}
async function parseIosDevicePerfTable(xml: string): Promise {
- const document = await parseXmlDocument(xml);
+ const document = parseXmlDocumentSync(xml);
const schema = findFirstXmlNode(
document,
(node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live',
diff --git a/src/platforms/ios/plist.ts b/src/platforms/ios/plist.ts
index 4a01c0e1..98621dd7 100644
--- a/src/platforms/ios/plist.ts
+++ b/src/platforms/ios/plist.ts
@@ -1,6 +1,6 @@
import { promises as fs } from 'node:fs';
import { runCmd } from '../../utils/exec.ts';
-import { parseXmlDocument } from './xml.ts';
+import { parseXmlDocumentSync, visitXmlPlistEntries } from './xml.ts';
export async function readInfoPlistString(
infoPlistPath: string,
@@ -22,25 +22,19 @@ export async function readInfoPlistString(
try {
const plist = await fs.readFile(infoPlistPath, 'utf8');
- return await readXmlPlistString(plist, key);
+ return readXmlPlistString(plist, key);
} catch {
return undefined;
}
}
-async function readXmlPlistString(plist: string, key: string): Promise {
- const document = await parseXmlDocument(plist);
- const plistNode = document.find((node) => node.name === 'plist');
- const dictNode = plistNode?.children.find((node) => node.name === 'dict');
- if (!dictNode) {
- return undefined;
- }
- for (let index = 0; index < dictNode.children.length - 1; index += 1) {
- const entry = dictNode.children[index];
- const nextEntry = dictNode.children[index + 1];
- if (entry?.name === 'key' && entry.text === key && nextEntry?.name === 'string') {
- return nextEntry.text ?? undefined;
+function readXmlPlistString(plist: string, key: string): string | undefined {
+ let result: string | undefined;
+ visitXmlPlistEntries(parseXmlDocumentSync(plist), (entryKey, valueNode) => {
+ if (result !== undefined || entryKey !== key || valueNode.name !== 'string') {
+ return;
}
- }
- return undefined;
+ result = valueNode.text ?? undefined;
+ });
+ return result;
}
diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts
index fae16458..e1b51fe4 100644
--- a/src/platforms/ios/runner-xctestrun.ts
+++ b/src/platforms/ios/runner-xctestrun.ts
@@ -21,6 +21,7 @@ import {
repairMacOsRunnerProductsIfNeeded,
isExpectedRunnerRepairFailure,
} from './runner-macos-products.ts';
+import { parseXmlDocumentSync, visitXmlPlistEntries, type XmlNode } from './xml.ts';
const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';
const XCTEST_DEVICE_SET_BASE_NAME = 'XCTestDevices';
@@ -31,6 +32,13 @@ const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner
const XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS = 30_000;
const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100;
const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000;
+const XCTESTRUN_PRODUCT_REFERENCE_KEYS = new Set([
+ 'ProductPaths',
+ 'DependentProductPaths',
+ 'TestHostPath',
+ 'TestBundlePath',
+ 'UITargetAppPath',
+]);
const runnerXctestrunBuildLocks = new Map>();
export const runnerPrepProcesses = new Set();
@@ -1150,45 +1158,28 @@ function collectXctestrunProductReferenceValuesFromTarget(
}
function resolveXctestrunProductReferencesFromXml(contents: string): string[] {
- const arrayPathKeys = ['ProductPaths', 'DependentProductPaths'];
- const stringPathKeys = ['TestHostPath', 'TestBundlePath', 'UITargetAppPath'];
- return Array.from(
- new Set([
- ...arrayPathKeys.flatMap((key) => extractPlistArrayStringValues(contents, key)),
- ...stringPathKeys.flatMap((key) => extractPlistStringValues(contents, key)),
- ]),
- );
+ return collectXctestrunXmlProductReferenceValues(parseXmlDocumentSync(contents));
}
-function extractPlistStringValues(contents: string, key: string): string[] {
- const pattern = new RegExp(`${key}\\s*([\\s\\S]*?)`, 'g');
+function collectXctestrunXmlProductReferenceValues(nodes: XmlNode[]): string[] {
const values = new Set();
- let match: RegExpExecArray | null;
- while ((match = pattern.exec(contents)) !== null) {
- const value = match[1]?.trim();
- if (value) {
- values.add(value);
+ visitXmlPlistEntries(nodes, (key, valueNode) => {
+ if (!XCTESTRUN_PRODUCT_REFERENCE_KEYS.has(key)) {
+ return;
}
- }
- return Array.from(values);
-}
-
-function extractPlistArrayStringValues(contents: string, key: string): string[] {
- // Best-effort XML extraction only. Prefer the plutil JSON path on macOS.
- const blockPattern = new RegExp(`${key}\\s*([\\s\\S]*?)`, 'g');
- const stringPattern = /([\s\S]*?)<\/string>/g;
- const values = new Set();
- let match: RegExpExecArray | null;
- while ((match = blockPattern.exec(contents)) !== null) {
- const block = match[1] ?? '';
- let stringMatch: RegExpExecArray | null;
- while ((stringMatch = stringPattern.exec(block)) !== null) {
- const value = stringMatch[1]?.trim();
- if (value) {
- values.add(value);
+ if (valueNode.name === 'string' && valueNode.text) {
+ values.add(valueNode.text);
+ return;
+ }
+ if (valueNode.name !== 'array') {
+ return;
+ }
+ for (const child of valueNode.children) {
+ if (child.name === 'string' && child.text) {
+ values.add(child.text);
}
}
- }
+ });
return Array.from(values);
}
diff --git a/src/platforms/ios/xml.ts b/src/platforms/ios/xml.ts
index 3d07a4ae..d9e31af0 100644
--- a/src/platforms/ios/xml.ts
+++ b/src/platforms/ios/xml.ts
@@ -1,3 +1,5 @@
+import { XMLParser } from 'fast-xml-parser';
+
export type XmlNode = {
name: string;
attributes: Record;
@@ -5,25 +7,35 @@ export type XmlNode = {
children: XmlNode[];
};
-let xmlParserPromise: Promise | null = null;
+let xmlParser: XMLParser | null = null;
-export async function parseXmlDocument(xml: string): Promise {
- const parser = await loadXmlParser();
- return normalizeXmlNodes(parser.parse(xml));
+export function parseXmlDocumentSync(xml: string): XmlNode[] {
+ xmlParser ??= new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '',
+ preserveOrder: true,
+ trimValues: true,
+ parseTagValue: false,
+ });
+ return normalizeXmlNodes(xmlParser.parse(xml));
}
-async function loadXmlParser(): Promise {
- xmlParserPromise ??= import('fast-xml-parser').then(
- ({ XMLParser }) =>
- new XMLParser({
- ignoreAttributes: false,
- attributeNamePrefix: '',
- preserveOrder: true,
- trimValues: true,
- parseTagValue: false,
- }),
- );
- return await xmlParserPromise;
+export function visitXmlPlistEntries(
+ nodes: XmlNode[],
+ visitor: (key: string, valueNode: XmlNode) => void,
+): void {
+ for (const node of nodes) {
+ if (node.name === 'dict') {
+ for (let index = 0; index < node.children.length - 1; index += 1) {
+ const entry = node.children[index];
+ const nextEntry = node.children[index + 1];
+ if (entry?.name === 'key' && entry.text && nextEntry) {
+ visitor(entry.text, nextEntry);
+ }
+ }
+ }
+ visitXmlPlistEntries(node.children, visitor);
+ }
}
function normalizeXmlNodes(value: unknown): XmlNode[] {