From 678238a997b856ef63b59e853b04309aaea0b2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 17:04:27 +0200 Subject: [PATCH 1/2] refactor: use shared xml parser for xctestrun fallback --- .../ios/__tests__/runner-client.test.ts | 49 ++++++++++++++ src/platforms/ios/runner-xctestrun.ts | 67 ++++++++++--------- src/platforms/ios/xml.ts | 28 ++++---- 3 files changed, 96 insertions(+), 48 deletions(-) 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/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index fae16458..130425ea 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, 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,44 +1158,37 @@ 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)), - ]), - ); -} - -function extractPlistStringValues(contents: string, key: string): string[] { - const pattern = new RegExp(`${key}\\s*([\\s\\S]*?)`, 'g'); - const values = new Set(); - let match: RegExpExecArray | null; - while ((match = pattern.exec(contents)) !== null) { - const value = match[1]?.trim(); - if (value) { - values.add(value); - } - } - return Array.from(values); + return collectXctestrunXmlProductReferenceValues(parseXmlDocumentSync(contents)); } -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; +function collectXctestrunXmlProductReferenceValues(nodes: XmlNode[]): string[] { 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); + 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]; + const key = entry?.name === 'key' ? entry.text : null; + if (!key || !XCTESTRUN_PRODUCT_REFERENCE_KEYS.has(key) || !nextEntry) { + continue; + } + if (nextEntry.name === 'string' && nextEntry.text) { + values.add(nextEntry.text); + continue; + } + if (nextEntry.name !== 'array') { + continue; + } + for (const child of nextEntry.children) { + if (child.name === 'string' && child.text) { + values.add(child.text); + } + } } } + for (const value of collectXctestrunXmlProductReferenceValues(node.children)) { + values.add(value); + } } return Array.from(values); } diff --git a/src/platforms/ios/xml.ts b/src/platforms/ios/xml.ts index 3d07a4ae..278b777e 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,21 @@ 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)); + return parseXmlDocumentSync(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 parseXmlDocumentSync(xml: string): XmlNode[] { + xmlParser ??= new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + preserveOrder: true, + trimValues: true, + parseTagValue: false, + }); + return normalizeXmlNodes(xmlParser.parse(xml)); } function normalizeXmlNodes(value: unknown): XmlNode[] { From 6a9295713980dfe912bd9404174fb0c3f7321288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 17:35:23 +0200 Subject: [PATCH 2/2] refactor: share plist xml traversal helpers --- src/platforms/ios/perf.ts | 4 +-- src/platforms/ios/plist.ts | 26 +++++++---------- src/platforms/ios/runner-xctestrun.ts | 42 ++++++++++----------------- src/platforms/ios/xml.ts | 22 +++++++++++--- 4 files changed, 46 insertions(+), 48 deletions(-) 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 130425ea..e1b51fe4 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -21,7 +21,7 @@ import { repairMacOsRunnerProductsIfNeeded, isExpectedRunnerRepairFailure, } from './runner-macos-products.ts'; -import { parseXmlDocumentSync, type XmlNode } from './xml.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'; @@ -1163,33 +1163,23 @@ function resolveXctestrunProductReferencesFromXml(contents: string): string[] { function collectXctestrunXmlProductReferenceValues(nodes: XmlNode[]): string[] { const values = new Set(); - 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]; - const key = entry?.name === 'key' ? entry.text : null; - if (!key || !XCTESTRUN_PRODUCT_REFERENCE_KEYS.has(key) || !nextEntry) { - continue; - } - if (nextEntry.name === 'string' && nextEntry.text) { - values.add(nextEntry.text); - continue; - } - if (nextEntry.name !== 'array') { - continue; - } - for (const child of nextEntry.children) { - if (child.name === 'string' && child.text) { - values.add(child.text); - } - } - } + visitXmlPlistEntries(nodes, (key, valueNode) => { + if (!XCTESTRUN_PRODUCT_REFERENCE_KEYS.has(key)) { + return; } - for (const value of collectXctestrunXmlProductReferenceValues(node.children)) { - 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 278b777e..d9e31af0 100644 --- a/src/platforms/ios/xml.ts +++ b/src/platforms/ios/xml.ts @@ -9,10 +9,6 @@ export type XmlNode = { let xmlParser: XMLParser | null = null; -export async function parseXmlDocument(xml: string): Promise { - return parseXmlDocumentSync(xml); -} - export function parseXmlDocumentSync(xml: string): XmlNode[] { xmlParser ??= new XMLParser({ ignoreAttributes: false, @@ -24,6 +20,24 @@ export function parseXmlDocumentSync(xml: string): XmlNode[] { return normalizeXmlNodes(xmlParser.parse(xml)); } +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[] { if (!Array.isArray(value)) return []; const nodes: XmlNode[] = [];