Skip to content

Commit ea9e8b4

Browse files
committed
refactor: split runner xctestrun module
1 parent b1d77c6 commit ea9e8b4

6 files changed

Lines changed: 1816 additions & 1676 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { AppError } from '../../../../kernel/errors.ts';
4+
import type { DefinedEnvMap as EnvMap } from '../../../../utils/env-map.ts';
5+
import { runAppleToolCommand } from '../tool-provider.ts';
6+
7+
const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = {
8+
PreferredScreenCaptureFormat: 'screenshots',
9+
SystemAttachmentLifetime: 'keepNever',
10+
UserAttachmentLifetime: 'keepNever',
11+
} as const;
12+
13+
type XctestrunTarget = {
14+
TestBundlePath?: unknown;
15+
EnvironmentVariables?: EnvMap;
16+
UITestEnvironmentVariables?: EnvMap;
17+
UITargetAppEnvironmentVariables?: EnvMap;
18+
TestingEnvironmentVariables?: EnvMap;
19+
[key: string]: unknown;
20+
};
21+
type XctestrunConfig = {
22+
TestTargets?: unknown;
23+
[key: string]: unknown;
24+
};
25+
type XctestrunPlist = {
26+
TestConfigurations?: unknown;
27+
[key: string]: unknown;
28+
};
29+
type XctestrunTargetVisitOptions = {
30+
requireTestBundlePath?: boolean;
31+
};
32+
type XctestrunEnvOptions = {
33+
iosXctestEnvDir?: string;
34+
};
35+
36+
export async function prepareXctestrunWithEnv(
37+
xctestrunPath: string,
38+
envVars: Record<string, string>,
39+
suffix: string,
40+
options: XctestrunEnvOptions = {},
41+
): Promise<{ xctestrunPath: string; jsonPath: string }> {
42+
const configuredEnvDir = options.iosXctestEnvDir?.trim();
43+
const dir = configuredEnvDir ? path.resolve(configuredEnvDir) : path.dirname(xctestrunPath);
44+
fs.mkdirSync(dir, { recursive: true });
45+
const safeSuffix = suffix.replace(/[^a-zA-Z0-9._-]/g, '_');
46+
const tmpJsonPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.json`);
47+
const tmpXctestrunPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.xctestrun`);
48+
const parsed = await readXctestrunPlist(xctestrunPath);
49+
50+
visitXctestrunTargets(parsed, (target) => mergeEnvIntoXctestrunTarget(target, envVars));
51+
// Xcode 26.2 can emit attachment lifetime values that differ from the test plan,
52+
// so normalize the per-session xctestrun immediately before test-without-building.
53+
applyRunnerXctestrunCapturePolicy(parsed);
54+
await writeXctestrunPlist(parsed, tmpJsonPath, tmpXctestrunPath);
55+
56+
return { xctestrunPath: tmpXctestrunPath, jsonPath: tmpJsonPath };
57+
}
58+
59+
async function readXctestrunPlist(xctestrunPath: string): Promise<XctestrunPlist> {
60+
const jsonResult = await runAppleToolCommand(
61+
'plutil',
62+
['-convert', 'json', '-o', '-', xctestrunPath],
63+
{
64+
allowFailure: true,
65+
},
66+
);
67+
if (jsonResult.exitCode !== 0 || !jsonResult.stdout.trim()) {
68+
throw new AppError('COMMAND_FAILED', 'Failed to read xctestrun plist', {
69+
xctestrunPath,
70+
stderr: jsonResult.stderr,
71+
});
72+
}
73+
74+
try {
75+
const raw: unknown = JSON.parse(jsonResult.stdout);
76+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
77+
throw new Error('Root must be an object');
78+
}
79+
return raw as XctestrunPlist;
80+
} catch (err) {
81+
throw new AppError('COMMAND_FAILED', 'Failed to parse xctestrun JSON', {
82+
xctestrunPath,
83+
error: String(err),
84+
});
85+
}
86+
}
87+
88+
async function writeXctestrunPlist(
89+
parsed: XctestrunPlist,
90+
tmpJsonPath: string,
91+
tmpXctestrunPath: string,
92+
): Promise<void> {
93+
fs.writeFileSync(tmpJsonPath, JSON.stringify(parsed, null, 2));
94+
const plistResult = await runAppleToolCommand(
95+
'plutil',
96+
['-convert', 'xml1', '-o', tmpXctestrunPath, tmpJsonPath],
97+
{
98+
allowFailure: true,
99+
},
100+
);
101+
if (plistResult.exitCode !== 0) {
102+
throw new AppError('COMMAND_FAILED', 'Failed to write xctestrun plist', {
103+
tmpXctestrunPath,
104+
stderr: plistResult.stderr,
105+
});
106+
}
107+
}
108+
109+
function mergeEnvIntoXctestrunTarget(
110+
target: XctestrunTarget,
111+
envVars: Record<string, string>,
112+
): void {
113+
target.EnvironmentVariables = { ...(target.EnvironmentVariables ?? {}), ...envVars };
114+
target.UITestEnvironmentVariables = { ...(target.UITestEnvironmentVariables ?? {}), ...envVars };
115+
target.UITargetAppEnvironmentVariables = {
116+
...(target.UITargetAppEnvironmentVariables ?? {}),
117+
...envVars,
118+
};
119+
target.TestingEnvironmentVariables = {
120+
...(target.TestingEnvironmentVariables ?? {}),
121+
...envVars,
122+
};
123+
}
124+
125+
function applyRunnerXctestrunCapturePolicy(parsed: XctestrunPlist): void {
126+
visitXctestrunTargets(
127+
parsed,
128+
(target) => Object.assign(target, RUNNER_XCTESTRUN_CAPTURE_OPTIONS),
129+
{ requireTestBundlePath: true },
130+
);
131+
}
132+
133+
function visitXctestrunTargets(
134+
parsed: XctestrunPlist,
135+
visit: (target: XctestrunTarget) => void,
136+
options: XctestrunTargetVisitOptions = {},
137+
): void {
138+
const configs = parsed.TestConfigurations;
139+
if (Array.isArray(configs)) {
140+
for (const config of configs as XctestrunConfig[]) {
141+
if (!config || typeof config !== 'object') continue;
142+
visitTargets(config.TestTargets, visit, options);
143+
}
144+
}
145+
146+
for (const value of Object.values(parsed)) {
147+
const target = toXctestrunTarget(value, { requireTestBundlePath: true });
148+
if (target) visit(target);
149+
}
150+
}
151+
152+
function visitTargets(
153+
targets: unknown,
154+
visit: (target: XctestrunTarget) => void,
155+
options: XctestrunTargetVisitOptions,
156+
): void {
157+
if (!Array.isArray(targets)) return;
158+
for (const target of targets) {
159+
const parsed = toXctestrunTarget(target, options);
160+
if (parsed) visit(parsed);
161+
}
162+
}
163+
164+
function toXctestrunTarget(
165+
value: unknown,
166+
options: XctestrunTargetVisitOptions,
167+
): XctestrunTarget | null {
168+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
169+
const target = value as XctestrunTarget;
170+
if (options.requireTestBundlePath && !target.TestBundlePath) return null;
171+
return target;
172+
}

0 commit comments

Comments
 (0)