Skip to content

Commit 7589bc7

Browse files
hannojgV3RON
andauthored
feat: xctest build as external command (#117)
## Description <!-- Provide a general summary of your changes --> Adds a `harness xctest build` command: <img width="799" height="975" alt="Screenshot 2026-05-12 at 12 22 12" src="https://github.com/user-attachments/assets/9640246e-f1ef-43d3-a46a-3550edc28ac7" /> This allows for building the xctest project outside of harness ## Related Issue <!--- This project only accepts pull requests related to open issues --> <!--- If suggesting a new feature or change, please discuss it in an issue first --> <!--- If fixing a bug, there should be an issue describing it with steps to reproduce --> <!--- Please link to the issue here: --> / ## Context <!-- Why this feature was implemented in this particular way? --> <!-- Is there anything reviewer needs to know before conducting code review? --> Together with: - #116 this is the command that helps building the xctest externally to inject it later. ## Testing <!-- Please describe how you tested your changes --> tested the cli locally + added tests (AI) --------- Co-authored-by: Szymon Chmal <szymon@chmal.it>
1 parent bd9b08e commit 7589bc7

18 files changed

Lines changed: 1174 additions & 212 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: patch
3+
---
4+
5+
Harness now lets you build the iOS XCTest agent binary directly from the CLI, without starting a Harness test run. This makes it easier to prepare the agent artifact ahead of time for external injection workflows.

actions/shared/index.cjs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,8 @@ function getErrorMap() {
633633

634634
// ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js
635635
var makeIssue = (params) => {
636-
const { data, path: path6, errorMaps, issueData } = params;
637-
const fullPath = [...path6, ...issueData.path || []];
636+
const { data, path: path7, errorMaps, issueData } = params;
637+
const fullPath = [...path7, ...issueData.path || []];
638638
const fullIssue = {
639639
...issueData,
640640
path: fullPath
@@ -750,11 +750,11 @@ var errorUtil;
750750

751751
// ../../node_modules/zod/dist/esm/v3/types.js
752752
var ParseInputLazyPath = class {
753-
constructor(parent, value, path6, key) {
753+
constructor(parent, value, path7, key) {
754754
this._cachedPath = [];
755755
this.parent = parent;
756756
this.data = value;
757-
this._path = path6;
757+
this._path = path7;
758758
this._key = key;
759759
}
760760
get path() {
@@ -4350,6 +4350,10 @@ var import_node_fs4 = __toESM(require("fs"), 1);
43504350
var import_node_path4 = __toESM(require("path"), 1);
43514351
var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports");
43524352

4353+
// ../tools/dist/harness-artifacts.js
4354+
var import_node_fs5 = __toESM(require("fs"), 1);
4355+
var import_node_path5 = __toESM(require("path"), 1);
4356+
43534357
// ../plugins/dist/utils.js
43544358
var isHookTree = (value) => {
43554359
if (value == null || typeof value !== "object" || Array.isArray(value)) {
@@ -4396,6 +4400,7 @@ var RunnerSchema = external_exports.object({
43964400
name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"),
43974401
config: external_exports.record(external_exports.any()),
43984402
runner: external_exports.string(),
4403+
cli: external_exports.string().optional(),
43994404
platformId: external_exports.string()
44004405
});
44014406
var PluginSchema = external_exports.custom((value) => isHarnessPlugin(value), "Invalid Harness plugin");
@@ -4502,16 +4507,16 @@ var ConfigLoadError = class extends HarnessError {
45024507
};
45034508

45044509
// ../config/dist/reader.js
4505-
var import_node_path5 = __toESM(require("path"), 1);
4506-
var import_node_fs5 = __toESM(require("fs"), 1);
4510+
var import_node_path6 = __toESM(require("path"), 1);
4511+
var import_node_fs6 = __toESM(require("fs"), 1);
45074512
var import_node_module2 = require("module");
45084513
var import_meta = {};
45094514
var extensions = [".js", ".mjs", ".cjs", ".json"];
45104515
var importUp = async (dir, name) => {
4511-
const filePath = import_node_path5.default.join(dir, name);
4516+
const filePath = import_node_path6.default.join(dir, name);
45124517
for (const ext of extensions) {
45134518
const filePathWithExt = `${filePath}${ext}`;
4514-
if (import_node_fs5.default.existsSync(filePathWithExt)) {
4519+
if (import_node_fs6.default.existsSync(filePathWithExt)) {
45154520
let rawConfig;
45164521
try {
45174522
if (ext === ".mjs") {
@@ -4529,16 +4534,16 @@ var importUp = async (dir, name) => {
45294534
} catch (error) {
45304535
if (error instanceof ZodError) {
45314536
const validationErrors = error.errors.map((err) => {
4532-
const path6 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : "";
4533-
return `${err.message}${path6}`;
4537+
const path7 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : "";
4538+
return `${err.message}${path7}`;
45344539
});
45354540
throw new ConfigValidationError(filePathWithExt, validationErrors);
45364541
}
45374542
throw error;
45384543
}
45394544
}
45404545
}
4541-
const parentDir = import_node_path5.default.dirname(dir);
4546+
const parentDir = import_node_path6.default.dirname(dir);
45424547
if (parentDir === dir) {
45434548
throw new ConfigNotFoundError(dir);
45444549
}
@@ -4553,8 +4558,8 @@ var getConfig = async (dir) => {
45534558
};
45544559

45554560
// src/shared/index.ts
4556-
var import_node_path6 = __toESM(require("path"));
4557-
var import_node_fs6 = __toESM(require("fs"));
4561+
var import_node_path7 = __toESM(require("path"));
4562+
var import_node_fs7 = __toESM(require("fs"));
45584563
var getHostAndroidSystemImageArch = () => {
45594564
switch (process.arch) {
45604565
case "arm64":
@@ -4622,7 +4627,7 @@ var run = async () => {
46224627
if (!runnerInput) {
46234628
throw new Error("Runner input is required");
46244629
}
4625-
const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd();
4630+
const projectRoot = projectRootInput ? import_node_path7.default.resolve(projectRootInput) : process.cwd();
46264631
console.info(`Loading React Native Harness config from: ${projectRoot}`);
46274632
const { config, projectRoot: resolvedProjectRoot } = await getConfig(
46284633
projectRoot
@@ -4636,13 +4641,13 @@ var run = async () => {
46364641
throw new Error("GITHUB_OUTPUT environment variable is not set");
46374642
}
46384643
const resolvedRunner = getResolvedRunner(runner);
4639-
const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || ".";
4644+
const relativeProjectRoot = import_node_path7.default.relative(process.cwd(), resolvedProjectRoot) || ".";
46404645
const output = `config=${JSON.stringify(
46414646
resolvedRunner
46424647
)}
46434648
projectRoot=${relativeProjectRoot}
46444649
`;
4645-
import_node_fs6.default.appendFileSync(githubOutput, output);
4650+
import_node_fs7.default.appendFileSync(githubOutput, output);
46464651
} catch (error) {
46474652
if (error instanceof Error) {
46484653
console.error(error.message);
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import type { Config } from '@react-native-harness/config';
3+
import {
4+
discoverPlatformCommands,
5+
runPlatformCommand,
6+
} from '../platform-commands.js';
7+
8+
const createCommandModuleUrl = (body: string) =>
9+
`data:text/javascript,${encodeURIComponent(body)}`;
10+
11+
const globalState = globalThis as typeof globalThis & {
12+
__platformCommandCall?: unknown;
13+
};
14+
15+
describe('platform CLI command discovery', () => {
16+
afterEach(() => {
17+
delete globalState.__platformCommandCall;
18+
});
19+
20+
it('runs a discovered platform command', async () => {
21+
const moduleUrl = createCommandModuleUrl(`
22+
export const commands = [{
23+
name: 'xctest',
24+
async run(args, context) {
25+
globalThis.__platformCommandCall = { args, context };
26+
}
27+
}];
28+
`);
29+
const loadConfig = vi.fn(async () => ({
30+
projectRoot: '/tmp/project',
31+
config: {
32+
entryPoint: 'index.js',
33+
appRegistryComponentName: 'App',
34+
runners: [
35+
{
36+
name: 'ios',
37+
config: {},
38+
runner: '/virtual/runner.js',
39+
cli: moduleUrl,
40+
platformId: 'ios',
41+
},
42+
],
43+
plugins: [],
44+
metroPort: 8081,
45+
webSocketPort: undefined,
46+
bridgeTimeout: 60000,
47+
platformReadyTimeout: 300000,
48+
bundleStartTimeout: 60000,
49+
maxAppRestarts: 2,
50+
resetEnvironmentBetweenTestFiles: true,
51+
unstable__skipAlreadyIncludedModules: false,
52+
unstable__enableMetroCache: false,
53+
permissions: false,
54+
detectNativeCrashes: true,
55+
crashDetectionInterval: 500,
56+
disableViewFlattening: false,
57+
forwardClientLogs: false,
58+
} satisfies Config,
59+
}));
60+
61+
expect(
62+
await runPlatformCommand({
63+
argv: ['xctest', 'build', '--destination', 'simulator'],
64+
cwd: '/tmp/project',
65+
loadConfig,
66+
})
67+
).toBe(true);
68+
expect(globalState.__platformCommandCall).toEqual({
69+
args: ['build', '--destination', 'simulator'],
70+
context: {
71+
cwd: '/tmp/project',
72+
projectRoot: '/tmp/project',
73+
},
74+
});
75+
});
76+
77+
it('deduplicates platform CLI modules across runners', async () => {
78+
const moduleUrl = createCommandModuleUrl(`
79+
export const commands = [{
80+
name: 'xctest',
81+
async run() {}
82+
}];
83+
`);
84+
const loadConfig = vi.fn(async () => ({
85+
projectRoot: '/tmp/project',
86+
config: {
87+
entryPoint: 'index.js',
88+
appRegistryComponentName: 'App',
89+
runners: [
90+
{
91+
name: 'ios-sim',
92+
config: {},
93+
runner: '/virtual/ios-sim-runner.js',
94+
cli: moduleUrl,
95+
platformId: 'ios',
96+
},
97+
{
98+
name: 'ios-device',
99+
config: {},
100+
runner: '/virtual/ios-device-runner.js',
101+
cli: moduleUrl,
102+
platformId: 'ios',
103+
},
104+
],
105+
plugins: [],
106+
metroPort: 8081,
107+
webSocketPort: undefined,
108+
bridgeTimeout: 60000,
109+
platformReadyTimeout: 300000,
110+
bundleStartTimeout: 60000,
111+
maxAppRestarts: 2,
112+
resetEnvironmentBetweenTestFiles: true,
113+
unstable__skipAlreadyIncludedModules: false,
114+
unstable__enableMetroCache: false,
115+
permissions: false,
116+
detectNativeCrashes: true,
117+
crashDetectionInterval: 500,
118+
disableViewFlattening: false,
119+
forwardClientLogs: false,
120+
} satisfies Config,
121+
}));
122+
123+
const discoveredCommands = await discoverPlatformCommands({
124+
cwd: '/tmp/project',
125+
loadConfig,
126+
});
127+
128+
expect(discoveredCommands?.commands).toHaveLength(1);
129+
});
130+
131+
it('returns false when no platform command matches', async () => {
132+
const loadConfig = vi.fn(async () => ({
133+
projectRoot: '/tmp/project',
134+
config: {
135+
entryPoint: 'index.js',
136+
appRegistryComponentName: 'App',
137+
runners: [
138+
{
139+
name: 'android',
140+
config: {},
141+
runner: '/virtual/android-runner.js',
142+
platformId: 'android',
143+
},
144+
],
145+
plugins: [],
146+
metroPort: 8081,
147+
webSocketPort: undefined,
148+
bridgeTimeout: 60000,
149+
platformReadyTimeout: 300000,
150+
bundleStartTimeout: 60000,
151+
maxAppRestarts: 2,
152+
resetEnvironmentBetweenTestFiles: true,
153+
unstable__skipAlreadyIncludedModules: false,
154+
unstable__enableMetroCache: false,
155+
permissions: false,
156+
detectNativeCrashes: true,
157+
crashDetectionInterval: 500,
158+
disableViewFlattening: false,
159+
forwardClientLogs: false,
160+
} satisfies Config,
161+
}));
162+
163+
await expect(
164+
runPlatformCommand({
165+
argv: ['xctest', 'build'],
166+
cwd: '/tmp/project',
167+
loadConfig,
168+
})
169+
).resolves.toBe(false);
170+
});
171+
172+
it('throws when two platform modules define the same command', async () => {
173+
const firstModuleUrl = createCommandModuleUrl(`
174+
export const commands = [{
175+
name: 'xctest',
176+
async run() {}
177+
}];
178+
`);
179+
const secondModuleUrl = createCommandModuleUrl(`
180+
// second module
181+
export const commands = [{
182+
name: 'xctest',
183+
async run() {}
184+
}];
185+
`);
186+
const loadConfig = vi.fn(async () => ({
187+
projectRoot: '/tmp/project',
188+
config: {
189+
entryPoint: 'index.js',
190+
appRegistryComponentName: 'App',
191+
runners: [
192+
{
193+
name: 'ios',
194+
config: {},
195+
runner: '/virtual/ios-runner.js',
196+
cli: firstModuleUrl,
197+
platformId: 'ios',
198+
},
199+
{
200+
name: 'android',
201+
config: {},
202+
runner: '/virtual/android-runner.js',
203+
cli: secondModuleUrl,
204+
platformId: 'android',
205+
},
206+
],
207+
plugins: [],
208+
metroPort: 8081,
209+
webSocketPort: undefined,
210+
bridgeTimeout: 60000,
211+
platformReadyTimeout: 300000,
212+
bundleStartTimeout: 60000,
213+
maxAppRestarts: 2,
214+
resetEnvironmentBetweenTestFiles: true,
215+
unstable__skipAlreadyIncludedModules: false,
216+
unstable__enableMetroCache: false,
217+
permissions: false,
218+
detectNativeCrashes: true,
219+
crashDetectionInterval: 500,
220+
disableViewFlattening: false,
221+
forwardClientLogs: false,
222+
} satisfies Config,
223+
}));
224+
225+
await expect(
226+
discoverPlatformCommands({
227+
cwd: '/tmp/project',
228+
loadConfig,
229+
})
230+
).rejects.toThrow("Duplicate platform CLI command 'xctest'");
231+
});
232+
});

0 commit comments

Comments
 (0)