Skip to content

Commit 80e1bb8

Browse files
authored
refactor: replace xctestrun XML regex parsing with shared helper (#362)
* refactor: use shared xml parser for xctestrun fallback * refactor: share plist xml traversal helpers
1 parent 83db76f commit 80e1bb8

5 files changed

Lines changed: 113 additions & 67 deletions

File tree

src/platforms/ios/__tests__/runner-client.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,55 @@ test('xctestrunReferencesExistingProducts accepts xctestruns when referenced pro
519519
assert.equal(xctestrunReferencesExistingProducts(xctestrunPath), true);
520520
});
521521

522+
test('xctestrunReferencesExistingProducts parses nested plist fallback values from XML', async () => {
523+
const tmpDir = await makeTmpDir();
524+
const productsDir = path.join(tmpDir, 'Build', 'Products');
525+
const debugDir = path.join(productsDir, 'Debug');
526+
await fs.promises.mkdir(path.join(debugDir, 'AgentDeviceRunner.app'), { recursive: true });
527+
await fs.promises.mkdir(path.join(debugDir, 'Target.app'), { recursive: true });
528+
await fs.promises.mkdir(path.join(debugDir, 'Frameworks', 'Helper.framework'), {
529+
recursive: true,
530+
});
531+
await fs.promises.mkdir(
532+
path.join(
533+
debugDir,
534+
'AgentDeviceRunner.app',
535+
'Contents',
536+
'PlugIns',
537+
'AgentDeviceRunnerUITests.xctest',
538+
),
539+
{ recursive: true },
540+
);
541+
const xctestrunPath = path.join(productsDir, 'AgentDeviceRunner.xctestrun');
542+
fs.writeFileSync(
543+
xctestrunPath,
544+
[
545+
'<plist><dict>',
546+
'<key>TestConfigurations</key><array>',
547+
'<dict>',
548+
'<key>TestTargets</key><array>',
549+
'<dict>',
550+
'<key>ProductPaths</key><array>',
551+
'<string>__TESTROOT__/Debug/AgentDeviceRunner.app</string>',
552+
'</array>',
553+
'<key>DependentProductPaths</key><array>',
554+
'<string>__TESTROOT__/Debug/Frameworks/Helper.framework</string>',
555+
'</array>',
556+
'<key>TestHostPath</key><string>__TESTROOT__/Debug/AgentDeviceRunner.app</string>',
557+
'<key>TestBundlePath</key><string>__TESTHOST__/Contents/PlugIns/AgentDeviceRunnerUITests.xctest</string>',
558+
'<key>UITargetAppPath</key><string>__TESTROOT__/Debug/Target.app</string>',
559+
'</dict>',
560+
'</array>',
561+
'</dict>',
562+
'</array>',
563+
'</dict></plist>',
564+
].join(''),
565+
'utf8',
566+
);
567+
568+
assert.equal(xctestrunReferencesExistingProducts(xctestrunPath), true);
569+
});
570+
522571
test('ensureXctestrun rebuilds after cached macOS runner repair failure', async () => {
523572
// Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once,
524573
// rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state.

src/platforms/ios/perf.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from './devicectl.ts';
1616
import { readInfoPlistString } from './plist.ts';
1717
import { buildSimctlArgsForDevice } from './simctl.ts';
18-
import { parseXmlDocument, type XmlNode } from './xml.ts';
18+
import { parseXmlDocumentSync, type XmlNode } from './xml.ts';
1919

2020
export const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot';
2121
export const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot';
@@ -148,7 +148,7 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] {
148148
}
149149

150150
async function parseIosDevicePerfTable(xml: string): Promise<IosDevicePerfProcessSample[]> {
151-
const document = await parseXmlDocument(xml);
151+
const document = parseXmlDocumentSync(xml);
152152
const schema = findFirstXmlNode(
153153
document,
154154
(node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live',

src/platforms/ios/plist.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { promises as fs } from 'node:fs';
22
import { runCmd } from '../../utils/exec.ts';
3-
import { parseXmlDocument } from './xml.ts';
3+
import { parseXmlDocumentSync, visitXmlPlistEntries } from './xml.ts';
44

55
export async function readInfoPlistString(
66
infoPlistPath: string,
@@ -22,25 +22,19 @@ export async function readInfoPlistString(
2222

2323
try {
2424
const plist = await fs.readFile(infoPlistPath, 'utf8');
25-
return await readXmlPlistString(plist, key);
25+
return readXmlPlistString(plist, key);
2626
} catch {
2727
return undefined;
2828
}
2929
}
3030

31-
async function readXmlPlistString(plist: string, key: string): Promise<string | undefined> {
32-
const document = await parseXmlDocument(plist);
33-
const plistNode = document.find((node) => node.name === 'plist');
34-
const dictNode = plistNode?.children.find((node) => node.name === 'dict');
35-
if (!dictNode) {
36-
return undefined;
37-
}
38-
for (let index = 0; index < dictNode.children.length - 1; index += 1) {
39-
const entry = dictNode.children[index];
40-
const nextEntry = dictNode.children[index + 1];
41-
if (entry?.name === 'key' && entry.text === key && nextEntry?.name === 'string') {
42-
return nextEntry.text ?? undefined;
31+
function readXmlPlistString(plist: string, key: string): string | undefined {
32+
let result: string | undefined;
33+
visitXmlPlistEntries(parseXmlDocumentSync(plist), (entryKey, valueNode) => {
34+
if (result !== undefined || entryKey !== key || valueNode.name !== 'string') {
35+
return;
4336
}
44-
}
45-
return undefined;
37+
result = valueNode.text ?? undefined;
38+
});
39+
return result;
4640
}

src/platforms/ios/runner-xctestrun.ts

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
repairMacOsRunnerProductsIfNeeded,
2222
isExpectedRunnerRepairFailure,
2323
} from './runner-macos-products.ts';
24+
import { parseXmlDocumentSync, visitXmlPlistEntries, type XmlNode } from './xml.ts';
2425

2526
const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';
2627
const XCTEST_DEVICE_SET_BASE_NAME = 'XCTestDevices';
@@ -31,6 +32,13 @@ const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner
3132
const XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS = 30_000;
3233
const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100;
3334
const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000;
35+
const XCTESTRUN_PRODUCT_REFERENCE_KEYS = new Set([
36+
'ProductPaths',
37+
'DependentProductPaths',
38+
'TestHostPath',
39+
'TestBundlePath',
40+
'UITargetAppPath',
41+
]);
3442

3543
const runnerXctestrunBuildLocks = new Map<string, Promise<unknown>>();
3644
export const runnerPrepProcesses = new Set<ExecBackgroundResult['child']>();
@@ -1150,45 +1158,28 @@ function collectXctestrunProductReferenceValuesFromTarget(
11501158
}
11511159

11521160
function resolveXctestrunProductReferencesFromXml(contents: string): string[] {
1153-
const arrayPathKeys = ['ProductPaths', 'DependentProductPaths'];
1154-
const stringPathKeys = ['TestHostPath', 'TestBundlePath', 'UITargetAppPath'];
1155-
return Array.from(
1156-
new Set([
1157-
...arrayPathKeys.flatMap((key) => extractPlistArrayStringValues(contents, key)),
1158-
...stringPathKeys.flatMap((key) => extractPlistStringValues(contents, key)),
1159-
]),
1160-
);
1161+
return collectXctestrunXmlProductReferenceValues(parseXmlDocumentSync(contents));
11611162
}
11621163

1163-
function extractPlistStringValues(contents: string, key: string): string[] {
1164-
const pattern = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`, 'g');
1164+
function collectXctestrunXmlProductReferenceValues(nodes: XmlNode[]): string[] {
11651165
const values = new Set<string>();
1166-
let match: RegExpExecArray | null;
1167-
while ((match = pattern.exec(contents)) !== null) {
1168-
const value = match[1]?.trim();
1169-
if (value) {
1170-
values.add(value);
1166+
visitXmlPlistEntries(nodes, (key, valueNode) => {
1167+
if (!XCTESTRUN_PRODUCT_REFERENCE_KEYS.has(key)) {
1168+
return;
11711169
}
1172-
}
1173-
return Array.from(values);
1174-
}
1175-
1176-
function extractPlistArrayStringValues(contents: string, key: string): string[] {
1177-
// Best-effort XML extraction only. Prefer the plutil JSON path on macOS.
1178-
const blockPattern = new RegExp(`<key>${key}</key>\\s*<array>([\\s\\S]*?)</array>`, 'g');
1179-
const stringPattern = /<string>([\s\S]*?)<\/string>/g;
1180-
const values = new Set<string>();
1181-
let match: RegExpExecArray | null;
1182-
while ((match = blockPattern.exec(contents)) !== null) {
1183-
const block = match[1] ?? '';
1184-
let stringMatch: RegExpExecArray | null;
1185-
while ((stringMatch = stringPattern.exec(block)) !== null) {
1186-
const value = stringMatch[1]?.trim();
1187-
if (value) {
1188-
values.add(value);
1170+
if (valueNode.name === 'string' && valueNode.text) {
1171+
values.add(valueNode.text);
1172+
return;
1173+
}
1174+
if (valueNode.name !== 'array') {
1175+
return;
1176+
}
1177+
for (const child of valueNode.children) {
1178+
if (child.name === 'string' && child.text) {
1179+
values.add(child.text);
11891180
}
11901181
}
1191-
}
1182+
});
11921183
return Array.from(values);
11931184
}
11941185

src/platforms/ios/xml.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
1+
import { XMLParser } from 'fast-xml-parser';
2+
13
export type XmlNode = {
24
name: string;
35
attributes: Record<string, string>;
46
text: string | null;
57
children: XmlNode[];
68
};
79

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

10-
export async function parseXmlDocument(xml: string): Promise<XmlNode[]> {
11-
const parser = await loadXmlParser();
12-
return normalizeXmlNodes(parser.parse(xml));
12+
export function parseXmlDocumentSync(xml: string): XmlNode[] {
13+
xmlParser ??= new XMLParser({
14+
ignoreAttributes: false,
15+
attributeNamePrefix: '',
16+
preserveOrder: true,
17+
trimValues: true,
18+
parseTagValue: false,
19+
});
20+
return normalizeXmlNodes(xmlParser.parse(xml));
1321
}
1422

15-
async function loadXmlParser(): Promise<import('fast-xml-parser').XMLParser> {
16-
xmlParserPromise ??= import('fast-xml-parser').then(
17-
({ XMLParser }) =>
18-
new XMLParser({
19-
ignoreAttributes: false,
20-
attributeNamePrefix: '',
21-
preserveOrder: true,
22-
trimValues: true,
23-
parseTagValue: false,
24-
}),
25-
);
26-
return await xmlParserPromise;
23+
export function visitXmlPlistEntries(
24+
nodes: XmlNode[],
25+
visitor: (key: string, valueNode: XmlNode) => void,
26+
): void {
27+
for (const node of nodes) {
28+
if (node.name === 'dict') {
29+
for (let index = 0; index < node.children.length - 1; index += 1) {
30+
const entry = node.children[index];
31+
const nextEntry = node.children[index + 1];
32+
if (entry?.name === 'key' && entry.text && nextEntry) {
33+
visitor(entry.text, nextEntry);
34+
}
35+
}
36+
}
37+
visitXmlPlistEntries(node.children, visitor);
38+
}
2739
}
2840

2941
function normalizeXmlNodes(value: unknown): XmlNode[] {

0 commit comments

Comments
 (0)