Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
'<plist><dict>',
'<key>TestConfigurations</key><array>',
'<dict>',
'<key>TestTargets</key><array>',
'<dict>',
'<key>ProductPaths</key><array>',
'<string>__TESTROOT__/Debug/AgentDeviceRunner.app</string>',
'</array>',
'<key>DependentProductPaths</key><array>',
'<string>__TESTROOT__/Debug/Frameworks/Helper.framework</string>',
'</array>',
'<key>TestHostPath</key><string>__TESTROOT__/Debug/AgentDeviceRunner.app</string>',
'<key>TestBundlePath</key><string>__TESTHOST__/Contents/PlugIns/AgentDeviceRunnerUITests.xctest</string>',
'<key>UITargetAppPath</key><string>__TESTROOT__/Debug/Target.app</string>',
'</dict>',
'</array>',
'</dict>',
'</array>',
'</dict></plist>',
].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.
Expand Down
4 changes: 2 additions & 2 deletions src/platforms/ios/perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -148,7 +148,7 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] {
}

async function parseIosDevicePerfTable(xml: string): Promise<IosDevicePerfProcessSample[]> {
const document = await parseXmlDocument(xml);
const document = parseXmlDocumentSync(xml);
const schema = findFirstXmlNode(
document,
(node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live',
Expand Down
26 changes: 10 additions & 16 deletions src/platforms/ios/plist.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string | undefined> {
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;
}
57 changes: 24 additions & 33 deletions src/platforms/ios/runner-xctestrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, Promise<unknown>>();
export const runnerPrepProcesses = new Set<ExecBackgroundResult['child']>();
Expand Down Expand Up @@ -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>${key}</key>\\s*<string>([\\s\\S]*?)</string>`, 'g');
function collectXctestrunXmlProductReferenceValues(nodes: XmlNode[]): string[] {
const values = new Set<string>();
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>${key}</key>\\s*<array>([\\s\\S]*?)</array>`, 'g');
const stringPattern = /<string>([\s\S]*?)<\/string>/g;
const values = new Set<string>();
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);
}

Expand Down
44 changes: 28 additions & 16 deletions src/platforms/ios/xml.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import { XMLParser } from 'fast-xml-parser';

export type XmlNode = {
name: string;
attributes: Record<string, string>;
text: string | null;
children: XmlNode[];
};

let xmlParserPromise: Promise<import('fast-xml-parser').XMLParser> | null = null;
let xmlParser: XMLParser | null = null;

export async function parseXmlDocument(xml: string): Promise<XmlNode[]> {
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<import('fast-xml-parser').XMLParser> {
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[] {
Expand Down
Loading