@expo/build-tools 18.9.0 → 18.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ios/pod.js +57 -0
- package/dist/steps/easFunctions.js +2 -0
- package/dist/steps/functions/maestroResultParser.d.ts +40 -0
- package/dist/steps/functions/maestroResultParser.js +260 -11
- package/dist/steps/functions/maestroTests.d.ts +2 -0
- package/dist/steps/functions/maestroTests.js +246 -0
- package/package.json +2 -2
package/dist/ios/pod.js
CHANGED
|
@@ -5,11 +5,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.installPods = installPods;
|
|
7
7
|
const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const semver_1 = __importDefault(require("semver"));
|
|
11
|
+
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
|
|
12
|
+
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
|
9
13
|
async function installPods(ctx, { infoCallbackFn }) {
|
|
10
14
|
const iosDir = path_1.default.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
|
11
15
|
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
|
12
16
|
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
|
17
|
+
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
|
13
18
|
return {
|
|
14
19
|
spawnPromise: (0, turtle_spawn_1.default)('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
|
15
20
|
cwd: iosDir,
|
|
@@ -17,6 +22,7 @@ async function installPods(ctx, { infoCallbackFn }) {
|
|
|
17
22
|
env: {
|
|
18
23
|
...ctx.env,
|
|
19
24
|
LANG: 'en_US.UTF-8',
|
|
25
|
+
...precompiledModulesEnv,
|
|
20
26
|
},
|
|
21
27
|
lineTransformer: (line) => {
|
|
22
28
|
if (!line ||
|
|
@@ -31,3 +37,54 @@ async function installPods(ctx, { infoCallbackFn }) {
|
|
|
31
37
|
}),
|
|
32
38
|
};
|
|
33
39
|
}
|
|
40
|
+
async function resolvePrecompiledModulesPodInstallEnvAsync(ctx) {
|
|
41
|
+
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
let expoPackageVersion;
|
|
45
|
+
try {
|
|
46
|
+
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
ctx.logger.info({ err }, 'Failed to detect installed Expo package version; not enabling precompiled modules use.');
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
const validExpoPackageVersion = semver_1.default.valid(expoPackageVersion);
|
|
53
|
+
if (!validExpoPackageVersion) {
|
|
54
|
+
ctx.logger.info(`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`);
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
if (semver_1.default.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
|
58
|
+
ctx.logger.info(`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`);
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
|
|
62
|
+
const env = {
|
|
63
|
+
EXPO_USE_PRECOMPILED_MODULES: '1',
|
|
64
|
+
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
|
|
65
|
+
};
|
|
66
|
+
ctx.logger.info(`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(env)
|
|
67
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
68
|
+
.join('\n')}\nPrecompiled modules pod install environment is configured.`);
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
async function getInstalledExpoPackageVersionAsync(ctx) {
|
|
72
|
+
const { stdout } = await (0, turtle_spawn_1.default)('node', ['--print', "require.resolve('expo/package.json')"], {
|
|
73
|
+
cwd: ctx.getReactNativeProjectDirectory(),
|
|
74
|
+
env: ctx.env,
|
|
75
|
+
stdio: 'pipe',
|
|
76
|
+
});
|
|
77
|
+
const expoPackageJsonPath = stdout.toString().trim();
|
|
78
|
+
return (await fs_extra_1.default.readJson(expoPackageJsonPath)).version;
|
|
79
|
+
}
|
|
80
|
+
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
|
81
|
+
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
|
82
|
+
// return PRECOMPILED_MODULES_BASE_URL;
|
|
83
|
+
// }
|
|
84
|
+
//
|
|
85
|
+
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
|
86
|
+
// return PRECOMPILED_MODULES_BASE_URL.replace(
|
|
87
|
+
// `${parsedUrl.protocol}//${parsedUrl.host}`,
|
|
88
|
+
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
|
89
|
+
// );
|
|
90
|
+
// }
|
|
@@ -33,6 +33,7 @@ const restoreCache_1 = require("./functions/restoreCache");
|
|
|
33
33
|
const parseXcactivitylog_1 = require("./functions/parseXcactivitylog");
|
|
34
34
|
const runFastlane_1 = require("./functions/runFastlane");
|
|
35
35
|
const runGradle_1 = require("./functions/runGradle");
|
|
36
|
+
const maestroTests_1 = require("./functions/maestroTests");
|
|
36
37
|
const saveBuildCache_1 = require("./functions/saveBuildCache");
|
|
37
38
|
const saveCache_1 = require("./functions/saveCache");
|
|
38
39
|
const sendSlackMessage_1 = require("./functions/sendSlackMessage");
|
|
@@ -85,6 +86,7 @@ function getEasFunctions(ctx) {
|
|
|
85
86
|
(0, createSubmissionEntity_1.createSubmissionEntityFunction)(),
|
|
86
87
|
(0, uploadToAsc_1.createUploadToAscBuildFunction)(),
|
|
87
88
|
(0, reportMaestroTestResults_1.createReportMaestroTestResultsFunction)(ctx),
|
|
89
|
+
(0, maestroTests_1.createMaestroTestsBuildFunction)(),
|
|
88
90
|
];
|
|
89
91
|
if (ctx.hasBuildJob()) {
|
|
90
92
|
functions.push(...[
|
|
@@ -39,4 +39,44 @@ type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
|
|
39
39
|
*/
|
|
40
40
|
export declare function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null>;
|
|
41
41
|
export declare function parseMaestroResults(junitDirectory: string, testsDirectory: string, projectRoot: string): Promise<MaestroFlowResult[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
|
44
|
+
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
45
|
+
* then falls back to dumb retry — re-run everything).
|
|
46
|
+
*
|
|
47
|
+
* Mapping: <testcase> only carries `name`, so we recover `flow_file_path`
|
|
48
|
+
* from `ai-${flow_name}.json` under testsDirectory and match it back to
|
|
49
|
+
* inputFlowPaths.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseFailedFlowsFromJUnit(args: {
|
|
52
|
+
junitFile: string;
|
|
53
|
+
testsDirectory: string;
|
|
54
|
+
inputFlowPaths: string[];
|
|
55
|
+
projectRoot: string;
|
|
56
|
+
}): Promise<string[] | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
59
|
+
* attempt's <testcase> per unique flow name (latest determined from the
|
|
60
|
+
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
|
61
|
+
* and writes a merged document to outputPath.
|
|
62
|
+
*
|
|
63
|
+
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
|
64
|
+
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
|
65
|
+
* failure rows around and produce a misleading merged report.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mergeJUnitReports(args: {
|
|
68
|
+
sourceDir: string;
|
|
69
|
+
outputPath: string;
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
73
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
74
|
+
* step still needs to produce final_report_path.
|
|
75
|
+
*
|
|
76
|
+
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
77
|
+
*/
|
|
78
|
+
export declare function copyLatestAttemptXml(args: {
|
|
79
|
+
sourceDir: string;
|
|
80
|
+
outputPath: string;
|
|
81
|
+
}): Promise<void>;
|
|
42
82
|
export {};
|
|
@@ -7,12 +7,17 @@ exports.extractFlowKey = extractFlowKey;
|
|
|
7
7
|
exports.parseJUnitTestCases = parseJUnitTestCases;
|
|
8
8
|
exports.parseFlowMetadata = parseFlowMetadata;
|
|
9
9
|
exports.parseMaestroResults = parseMaestroResults;
|
|
10
|
+
exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
|
|
11
|
+
exports.mergeJUnitReports = mergeJUnitReports;
|
|
12
|
+
exports.copyLatestAttemptXml = copyLatestAttemptXml;
|
|
10
13
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
11
14
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
12
15
|
const path_1 = __importDefault(require("path"));
|
|
13
16
|
const zod_1 = require("zod");
|
|
14
17
|
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
|
15
18
|
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
|
19
|
+
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
|
20
|
+
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
|
16
21
|
function extractFlowKey(filename, prefix) {
|
|
17
22
|
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
|
18
23
|
return match?.[1] ?? null;
|
|
@@ -23,11 +28,9 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
|
23
28
|
// Ensure single-element arrays are always arrays
|
|
24
29
|
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
|
25
30
|
});
|
|
26
|
-
|
|
27
|
-
async function parseJUnitFile(filePath) {
|
|
31
|
+
function parseJUnitContent(content) {
|
|
28
32
|
const results = [];
|
|
29
33
|
try {
|
|
30
|
-
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
31
34
|
const parsed = xmlParser.parse(content);
|
|
32
35
|
const testsuites = parsed?.testsuites?.testsuite;
|
|
33
36
|
if (!Array.isArray(testsuites)) {
|
|
@@ -81,10 +84,19 @@ async function parseJUnitFile(filePath) {
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
catch {
|
|
84
|
-
//
|
|
87
|
+
// Malformed XML — return whatever we collected before the parser bailed.
|
|
85
88
|
}
|
|
86
89
|
return results;
|
|
87
90
|
}
|
|
91
|
+
async function parseJUnitFile(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
94
|
+
return parseJUnitContent(content);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
88
100
|
async function parseJUnitTestCases(junitDirectory) {
|
|
89
101
|
let entries;
|
|
90
102
|
try {
|
|
@@ -97,11 +109,8 @@ async function parseJUnitTestCases(junitDirectory) {
|
|
|
97
109
|
if (xmlFiles.length === 0) {
|
|
98
110
|
return [];
|
|
99
111
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
results.push(...(await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile))));
|
|
103
|
-
}
|
|
104
|
-
return results;
|
|
112
|
+
const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
|
|
113
|
+
return perFile.flat();
|
|
105
114
|
}
|
|
106
115
|
const FlowMetadataFileSchema = zod_1.z.object({
|
|
107
116
|
flow_name: zod_1.z.string(),
|
|
@@ -190,8 +199,6 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
190
199
|
}
|
|
191
200
|
// 3. Merge: JUnit results + ai-*.json metadata
|
|
192
201
|
const results = [];
|
|
193
|
-
// Parse attempt index from filename pattern: *-attempt-N.*
|
|
194
|
-
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
|
195
202
|
// Group results by flow name
|
|
196
203
|
const resultsByName = new Map();
|
|
197
204
|
for (const entry of junitResultsWithSource) {
|
|
@@ -246,6 +253,248 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
246
253
|
}
|
|
247
254
|
return results;
|
|
248
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
|
258
|
+
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
259
|
+
* then falls back to dumb retry — re-run everything).
|
|
260
|
+
*
|
|
261
|
+
* Mapping: <testcase> only carries `name`, so we recover `flow_file_path`
|
|
262
|
+
* from `ai-${flow_name}.json` under testsDirectory and match it back to
|
|
263
|
+
* inputFlowPaths.
|
|
264
|
+
*/
|
|
265
|
+
async function parseFailedFlowsFromJUnit(args) {
|
|
266
|
+
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
|
267
|
+
// with some testcases dropped. Trusting that subset for smart retry would
|
|
268
|
+
// skip retries for cut-off flows; reject any malformed XML and fall back
|
|
269
|
+
// to dumb retry.
|
|
270
|
+
let content;
|
|
271
|
+
try {
|
|
272
|
+
content = await promises_1.default.readFile(args.junitFile, 'utf-8');
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const testcases = parseJUnitContent(content);
|
|
281
|
+
if (testcases.length === 0) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const failing = testcases.filter(tc => tc.status === 'failed');
|
|
285
|
+
if (failing.length === 0) {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
// Two testcases with the same name (pass+fail or fail+fail) make it
|
|
289
|
+
// impossible to map back to a single input flow_path, since the
|
|
290
|
+
// ai-*.json keyed map collapses duplicates. Signal "unknown" → dumb retry.
|
|
291
|
+
const allNameCounts = new Map();
|
|
292
|
+
for (const tc of testcases) {
|
|
293
|
+
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
for (const count of allNameCounts.values()) {
|
|
296
|
+
if (count > 1) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Build flow_name → flow_file_path map from ai-*.json across timestamped
|
|
301
|
+
// subdirectories (same traversal as parseMaestroResults).
|
|
302
|
+
const nameToPath = new Map();
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = await promises_1.default.readdir(args.testsDirectory);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
entries = [];
|
|
309
|
+
}
|
|
310
|
+
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
|
311
|
+
for (const dir of timestampDirs) {
|
|
312
|
+
const dirPath = path_1.default.join(args.testsDirectory, dir);
|
|
313
|
+
let files;
|
|
314
|
+
try {
|
|
315
|
+
files = await promises_1.default.readdir(dirPath);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
for (const file of files) {
|
|
321
|
+
const flowKey = extractFlowKey(file, 'ai');
|
|
322
|
+
if (!flowKey) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const metadata = await parseFlowMetadata(path_1.default.join(dirPath, file));
|
|
326
|
+
if (!metadata) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// Latest timestamp dir wins if the same flow appears in multiple attempts.
|
|
330
|
+
nameToPath.set(metadata.flow_name, metadata.flow_file_path);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const matched = [];
|
|
334
|
+
for (const tc of failing) {
|
|
335
|
+
const abs = nameToPath.get(tc.name);
|
|
336
|
+
if (!abs) {
|
|
337
|
+
return null; // unknown mapping; safer to fall back
|
|
338
|
+
}
|
|
339
|
+
const relative = await relativizePathAsync(abs, args.projectRoot);
|
|
340
|
+
// Accept exact matches and flow files discovered under an input directory
|
|
341
|
+
// (documented usage: `flow_path: ./maestro/flows` discovers nested .yml).
|
|
342
|
+
// Anything outside every input is treated as out-of-scope → dumb retry.
|
|
343
|
+
if (!args.inputFlowPaths.some(input => isPathWithinOrEqual(relative, input))) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
matched.push(relative);
|
|
347
|
+
}
|
|
348
|
+
return matched;
|
|
349
|
+
}
|
|
350
|
+
function isPathWithinOrEqual(child, parent) {
|
|
351
|
+
const rel = path_1.default.relative(parent, child);
|
|
352
|
+
return rel === '' || (!rel.startsWith('..') && !path_1.default.isAbsolute(rel));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
356
|
+
* attempt's <testcase> per unique flow name (latest determined from the
|
|
357
|
+
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
|
358
|
+
* and writes a merged document to outputPath.
|
|
359
|
+
*
|
|
360
|
+
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
|
361
|
+
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
|
362
|
+
* failure rows around and produce a misleading merged report.
|
|
363
|
+
*/
|
|
364
|
+
async function mergeJUnitReports(args) {
|
|
365
|
+
const entries = await promises_1.default.readdir(args.sourceDir);
|
|
366
|
+
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
|
367
|
+
if (xmlFiles.length === 0) {
|
|
368
|
+
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
|
|
369
|
+
}
|
|
370
|
+
const contents = await Promise.all(xmlFiles.map(async (f) => ({
|
|
371
|
+
filename: f,
|
|
372
|
+
content: await promises_1.default.readFile(path_1.default.join(args.sourceDir, f), 'utf-8'),
|
|
373
|
+
})));
|
|
374
|
+
const fileGroups = [];
|
|
375
|
+
for (const { filename, content } of contents) {
|
|
376
|
+
if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
|
|
377
|
+
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
|
|
378
|
+
}
|
|
379
|
+
let parsed;
|
|
380
|
+
try {
|
|
381
|
+
parsed = xmlParser.parse(content);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
|
385
|
+
}
|
|
386
|
+
const testsuites = parsed?.testsuites?.testsuite;
|
|
387
|
+
if (!Array.isArray(testsuites)) {
|
|
388
|
+
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
|
389
|
+
}
|
|
390
|
+
const match = filename.match(ATTEMPT_PATTERN);
|
|
391
|
+
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
392
|
+
const testcasesByName = new Map();
|
|
393
|
+
for (const suite of testsuites) {
|
|
394
|
+
const cases = suite?.testcase;
|
|
395
|
+
if (!Array.isArray(cases)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
for (const tc of cases) {
|
|
399
|
+
const name = tc?.['@_name'];
|
|
400
|
+
if (typeof name !== 'string') {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const group = testcasesByName.get(name) ?? [];
|
|
404
|
+
group.push(tc);
|
|
405
|
+
testcasesByName.set(name, group);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (testcasesByName.size === 0) {
|
|
409
|
+
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
|
410
|
+
}
|
|
411
|
+
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
|
412
|
+
}
|
|
413
|
+
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
|
414
|
+
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
|
415
|
+
// collapse those to a single attribute-less <testsuite>.
|
|
416
|
+
if (fileGroups.length === 1) {
|
|
417
|
+
await promises_1.default.writeFile(args.outputPath, fileGroups[0].content);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// For each unique name, pick the file with the highest attempt index that
|
|
421
|
+
// contains it (ties broken by sorted filename — later wins). Preserve every
|
|
422
|
+
// <testcase> element from the winning file for that name, so same-attempt
|
|
423
|
+
// duplicates survive.
|
|
424
|
+
const nameToWinningFile = new Map();
|
|
425
|
+
for (const group of fileGroups) {
|
|
426
|
+
for (const name of group.testcasesByName.keys()) {
|
|
427
|
+
const current = nameToWinningFile.get(name);
|
|
428
|
+
if (!current || group.attemptIndex >= current.attemptIndex) {
|
|
429
|
+
nameToWinningFile.set(name, group);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
|
434
|
+
// matching the sorted filename list).
|
|
435
|
+
const testcases = [];
|
|
436
|
+
const emitted = new Set();
|
|
437
|
+
for (const group of fileGroups) {
|
|
438
|
+
for (const [name, cases] of group.testcasesByName) {
|
|
439
|
+
if (emitted.has(name)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const winner = nameToWinningFile.get(name);
|
|
443
|
+
if (winner === group) {
|
|
444
|
+
for (const tc of cases) {
|
|
445
|
+
testcases.push(tc);
|
|
446
|
+
}
|
|
447
|
+
emitted.add(name);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const builder = new fast_xml_parser_1.XMLBuilder({
|
|
452
|
+
ignoreAttributes: false,
|
|
453
|
+
attributeNamePrefix: '@_',
|
|
454
|
+
format: true,
|
|
455
|
+
suppressEmptyNode: true,
|
|
456
|
+
});
|
|
457
|
+
const xml = builder.build({
|
|
458
|
+
testsuites: {
|
|
459
|
+
testsuite: {
|
|
460
|
+
testcase: testcases,
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
await promises_1.default.writeFile(args.outputPath, xml);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
468
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
469
|
+
* step still needs to produce final_report_path.
|
|
470
|
+
*
|
|
471
|
+
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
472
|
+
*/
|
|
473
|
+
async function copyLatestAttemptXml(args) {
|
|
474
|
+
const entries = await promises_1.default.readdir(args.sourceDir);
|
|
475
|
+
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
|
476
|
+
if (xmlFiles.length === 0) {
|
|
477
|
+
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
|
478
|
+
}
|
|
479
|
+
// Pick the file with the highest attempt index. Files without the marker are
|
|
480
|
+
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
|
481
|
+
// (same rule as mergeJUnitReports).
|
|
482
|
+
let winner = xmlFiles[0];
|
|
483
|
+
let winnerAttempt = (() => {
|
|
484
|
+
const m = winner.match(ATTEMPT_PATTERN);
|
|
485
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
486
|
+
})();
|
|
487
|
+
for (let i = 1; i < xmlFiles.length; i++) {
|
|
488
|
+
const candidate = xmlFiles[i];
|
|
489
|
+
const match = candidate.match(ATTEMPT_PATTERN);
|
|
490
|
+
const attempt = match ? parseInt(match[1], 10) : 0;
|
|
491
|
+
if (attempt >= winnerAttempt) {
|
|
492
|
+
winner = candidate;
|
|
493
|
+
winnerAttempt = attempt;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await promises_1.default.copyFile(path_1.default.join(args.sourceDir, winner), args.outputPath);
|
|
497
|
+
}
|
|
249
498
|
async function relativizePathAsync(flowFilePath, projectRoot) {
|
|
250
499
|
if (!path_1.default.isAbsolute(flowFilePath)) {
|
|
251
500
|
return flowFilePath;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createMaestroTestsBuildFunction = createMaestroTestsBuildFunction;
|
|
7
|
+
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
8
|
+
const steps_1 = require("@expo/steps");
|
|
9
|
+
const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
10
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const zod_1 = require("zod");
|
|
13
|
+
const maestroResultParser_1 = require("./maestroResultParser");
|
|
14
|
+
const retry_1 = require("../../utils/retry");
|
|
15
|
+
const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
|
|
16
|
+
const RetriesSchema = zod_1.z.number().int().min(0).default(0);
|
|
17
|
+
const ShardsSchema = zod_1.z.number().int().min(1).optional();
|
|
18
|
+
function parseInput(schema, value, message) {
|
|
19
|
+
const result = schema.safeParse(value);
|
|
20
|
+
if (!result.success) {
|
|
21
|
+
throw new eas_build_job_1.UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
|
|
22
|
+
}
|
|
23
|
+
return result.data;
|
|
24
|
+
}
|
|
25
|
+
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
|
26
|
+
// fault, so the post-loop merge should fall through to copy-latest instead
|
|
27
|
+
// of throwing.
|
|
28
|
+
function isFilesystemError(err) {
|
|
29
|
+
if (!err || typeof err !== 'object') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const code = err.code;
|
|
33
|
+
return (code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM');
|
|
34
|
+
}
|
|
35
|
+
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
|
36
|
+
// other declared formats pass an explicit path so downstream upload steps
|
|
37
|
+
// know where to find the result.
|
|
38
|
+
function buildMaestroArgs(args) {
|
|
39
|
+
const out = ['test'];
|
|
40
|
+
if (args.output_format) {
|
|
41
|
+
out.push(`--format=${args.output_format.toUpperCase()}`);
|
|
42
|
+
}
|
|
43
|
+
if (args.outputPath) {
|
|
44
|
+
out.push(`--output=${args.outputPath}`);
|
|
45
|
+
}
|
|
46
|
+
if (args.shards !== undefined) {
|
|
47
|
+
out.push(`--shard-split=${args.shards}`);
|
|
48
|
+
}
|
|
49
|
+
if (args.include_tags) {
|
|
50
|
+
out.push(`--include-tags=${args.include_tags}`);
|
|
51
|
+
}
|
|
52
|
+
if (args.exclude_tags) {
|
|
53
|
+
out.push(`--exclude-tags=${args.exclude_tags}`);
|
|
54
|
+
}
|
|
55
|
+
out.push(...args.flow_path);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function createMaestroTestsBuildFunction() {
|
|
59
|
+
return new steps_1.BuildFunction({
|
|
60
|
+
namespace: 'eas',
|
|
61
|
+
id: 'maestro_tests',
|
|
62
|
+
name: 'Run Maestro Tests',
|
|
63
|
+
__metricsId: 'eas/maestro_tests',
|
|
64
|
+
inputProviders: [
|
|
65
|
+
steps_1.BuildStepInput.createProvider({
|
|
66
|
+
id: 'flow_path',
|
|
67
|
+
required: true,
|
|
68
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.JSON,
|
|
69
|
+
}),
|
|
70
|
+
steps_1.BuildStepInput.createProvider({
|
|
71
|
+
id: 'retries',
|
|
72
|
+
required: false,
|
|
73
|
+
defaultValue: 0,
|
|
74
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
|
|
75
|
+
}),
|
|
76
|
+
steps_1.BuildStepInput.createProvider({
|
|
77
|
+
id: 'shards',
|
|
78
|
+
required: false,
|
|
79
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
|
|
80
|
+
}),
|
|
81
|
+
steps_1.BuildStepInput.createProvider({
|
|
82
|
+
id: 'include_tags',
|
|
83
|
+
required: false,
|
|
84
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
85
|
+
}),
|
|
86
|
+
steps_1.BuildStepInput.createProvider({
|
|
87
|
+
id: 'exclude_tags',
|
|
88
|
+
required: false,
|
|
89
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
90
|
+
}),
|
|
91
|
+
steps_1.BuildStepInput.createProvider({
|
|
92
|
+
id: 'output_format',
|
|
93
|
+
required: false,
|
|
94
|
+
defaultValue: 'junit',
|
|
95
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
96
|
+
}),
|
|
97
|
+
steps_1.BuildStepInput.createProvider({
|
|
98
|
+
id: 'platform',
|
|
99
|
+
required: false,
|
|
100
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
101
|
+
}),
|
|
102
|
+
],
|
|
103
|
+
outputProviders: [
|
|
104
|
+
steps_1.BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
|
105
|
+
steps_1.BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
|
|
106
|
+
steps_1.BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
|
|
107
|
+
],
|
|
108
|
+
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
|
109
|
+
const { logger, global } = stepCtx;
|
|
110
|
+
const platformInput = inputs.platform.value;
|
|
111
|
+
const outputFormat = inputs.output_format.value?.toLowerCase();
|
|
112
|
+
const includeTags = inputs.include_tags.value;
|
|
113
|
+
const excludeTags = inputs.exclude_tags.value;
|
|
114
|
+
const platform = platformInput === 'ios' || platformInput === 'android'
|
|
115
|
+
? platformInput
|
|
116
|
+
: global.runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN
|
|
117
|
+
? 'ios'
|
|
118
|
+
: 'android';
|
|
119
|
+
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
|
|
120
|
+
// this env and writes debug output under $HOME/.maestro/tests; the step
|
|
121
|
+
// must read from the same place or stale files leak across runs.
|
|
122
|
+
const home = env.HOME;
|
|
123
|
+
if (!home) {
|
|
124
|
+
throw new eas_build_job_1.SystemError('HOME env var is not set');
|
|
125
|
+
}
|
|
126
|
+
const testsDirectory = path_1.default.join(home, '.maestro', 'tests');
|
|
127
|
+
const junitReportDirectory = path_1.default.join(testsDirectory, 'junit-reports');
|
|
128
|
+
const finalReportPath = outputFormat === 'junit'
|
|
129
|
+
? path_1.default.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
|
130
|
+
: undefined;
|
|
131
|
+
// Public docs (EAS workflows pre-packaged-jobs) document
|
|
132
|
+
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
|
133
|
+
// the uploaded dir.
|
|
134
|
+
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
|
135
|
+
// Outputs are published BEFORE any throw below so downstream
|
|
136
|
+
// `if: always()` upload steps still see populated values when this
|
|
137
|
+
// step fails early.
|
|
138
|
+
outputs.tests_directory.set(testsDirectory);
|
|
139
|
+
outputs.junit_report_directory.set(junitReportDirectory);
|
|
140
|
+
if (finalReportPath !== undefined) {
|
|
141
|
+
outputs.final_report_path.set(finalReportPath);
|
|
142
|
+
}
|
|
143
|
+
const flowPaths = parseInput(FlowPathSchema, inputs.flow_path.value, 'flow_path must be a non-empty array of non-empty strings.');
|
|
144
|
+
const retries = parseInput(RetriesSchema, inputs.retries.value, 'retries must be a non-negative integer.');
|
|
145
|
+
const shards = parseInput(ShardsSchema, inputs.shards.value, 'shards must be a positive integer.');
|
|
146
|
+
try {
|
|
147
|
+
await promises_1.default.mkdir(junitReportDirectory, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
|
|
151
|
+
}
|
|
152
|
+
// Retry loop. spawn-async error shapes:
|
|
153
|
+
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
|
154
|
+
// numeric err.status → maestro exited non-zero → retry.
|
|
155
|
+
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
|
156
|
+
// downgraded to "tests failed".
|
|
157
|
+
// Smart retry (junit mode): after a failed attempt, subset to the failing
|
|
158
|
+
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
|
159
|
+
// trusted; we then fall through to dumb retry (re-run everything).
|
|
160
|
+
let flowsToRun = flowPaths;
|
|
161
|
+
let lastAttemptExitCode = null;
|
|
162
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
163
|
+
const outputPath = outputFormat === 'junit'
|
|
164
|
+
? path_1.default.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
|
165
|
+
: outputFormat
|
|
166
|
+
? path_1.default.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
|
167
|
+
: null;
|
|
168
|
+
try {
|
|
169
|
+
await (0, turtle_spawn_1.default)('maestro', buildMaestroArgs({
|
|
170
|
+
flow_path: flowsToRun,
|
|
171
|
+
outputPath,
|
|
172
|
+
output_format: outputFormat,
|
|
173
|
+
shards,
|
|
174
|
+
include_tags: includeTags,
|
|
175
|
+
exclude_tags: excludeTags,
|
|
176
|
+
}), { cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal });
|
|
177
|
+
lastAttemptExitCode = 0;
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
|
181
|
+
throw new eas_build_job_1.SystemError('Failed to invoke maestro', { cause: err });
|
|
182
|
+
}
|
|
183
|
+
if (err && typeof err.status === 'number') {
|
|
184
|
+
lastAttemptExitCode = err.status;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
throw new eas_build_job_1.SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (lastAttemptExitCode === 0 || attempt === retries) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
if (outputFormat === 'junit' && outputPath) {
|
|
194
|
+
const failed = await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({
|
|
195
|
+
junitFile: outputPath,
|
|
196
|
+
testsDirectory,
|
|
197
|
+
inputFlowPaths: flowsToRun,
|
|
198
|
+
projectRoot: stepCtx.workingDirectory,
|
|
199
|
+
});
|
|
200
|
+
if (failed !== null && failed.length > 0) {
|
|
201
|
+
flowsToRun = failed;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
logger.info('Test failed, retrying...');
|
|
205
|
+
await (0, retry_1.sleepAsync)(2000);
|
|
206
|
+
}
|
|
207
|
+
// Smart merge first; on data errors (bad XML, missing input) fall back
|
|
208
|
+
// to copy-latest so the caller still gets a single JUnit file.
|
|
209
|
+
// Filesystem errors short-circuit straight to SystemError.
|
|
210
|
+
if (finalReportPath !== undefined) {
|
|
211
|
+
try {
|
|
212
|
+
await (0, maestroResultParser_1.mergeJUnitReports)({
|
|
213
|
+
sourceDir: junitReportDirectory,
|
|
214
|
+
outputPath: finalReportPath,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (mergeErr) {
|
|
218
|
+
if (isFilesystemError(mergeErr)) {
|
|
219
|
+
throw new eas_build_job_1.SystemError('Failed to write final_report_path', { cause: mergeErr });
|
|
220
|
+
}
|
|
221
|
+
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
|
222
|
+
try {
|
|
223
|
+
await (0, maestroResultParser_1.copyLatestAttemptXml)({
|
|
224
|
+
sourceDir: junitReportDirectory,
|
|
225
|
+
outputPath: finalReportPath,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (copyErr) {
|
|
229
|
+
// Swallow: a copy failure here usually means maestro itself failed
|
|
230
|
+
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
|
231
|
+
// the real reason and cancel billing for a user-side failure — let
|
|
232
|
+
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
|
233
|
+
logger.warn(`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// The retry loop exits via success (0), numeric status (retryable),
|
|
238
|
+
// or throw (infra). A non-null non-zero status means the user's tests
|
|
239
|
+
// failed every attempt.
|
|
240
|
+
if (lastAttemptExitCode !== 0) {
|
|
241
|
+
const totalAttempts = retries + 1;
|
|
242
|
+
throw new eas_build_job_1.UserError('ERR_MAESTRO_TESTS_FAILED', `Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/build-tools",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.11.0",
|
|
4
4
|
"bugs": "https://github.com/expo/eas-cli/issues",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Expo <support@expo.io>",
|
|
@@ -98,5 +98,5 @@
|
|
|
98
98
|
"typescript": "^5.5.4",
|
|
99
99
|
"uuid": "^9.0.1"
|
|
100
100
|
},
|
|
101
|
-
"gitHead": "
|
|
101
|
+
"gitHead": "4dec5b3765836aff51febd47938e0c657cf9467e"
|
|
102
102
|
}
|