@expo/build-tools 18.10.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.
|
@@ -39,11 +39,39 @@ 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>;
|
|
42
71
|
/**
|
|
43
72
|
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* attempt" semantics.
|
|
73
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
74
|
+
* step still needs to produce final_report_path.
|
|
47
75
|
*
|
|
48
76
|
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
49
77
|
*/
|
|
@@ -7,6 +7,8 @@ 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;
|
|
10
12
|
exports.copyLatestAttemptXml = copyLatestAttemptXml;
|
|
11
13
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
12
14
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
@@ -26,11 +28,9 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
|
26
28
|
// Ensure single-element arrays are always arrays
|
|
27
29
|
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
|
28
30
|
});
|
|
29
|
-
|
|
30
|
-
async function parseJUnitFile(filePath) {
|
|
31
|
+
function parseJUnitContent(content) {
|
|
31
32
|
const results = [];
|
|
32
33
|
try {
|
|
33
|
-
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
34
34
|
const parsed = xmlParser.parse(content);
|
|
35
35
|
const testsuites = parsed?.testsuites?.testsuite;
|
|
36
36
|
if (!Array.isArray(testsuites)) {
|
|
@@ -84,10 +84,19 @@ async function parseJUnitFile(filePath) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
catch {
|
|
87
|
-
//
|
|
87
|
+
// Malformed XML — return whatever we collected before the parser bailed.
|
|
88
88
|
}
|
|
89
89
|
return results;
|
|
90
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
|
+
}
|
|
91
100
|
async function parseJUnitTestCases(junitDirectory) {
|
|
92
101
|
let entries;
|
|
93
102
|
try {
|
|
@@ -100,11 +109,8 @@ async function parseJUnitTestCases(junitDirectory) {
|
|
|
100
109
|
if (xmlFiles.length === 0) {
|
|
101
110
|
return [];
|
|
102
111
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
results.push(...(await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile))));
|
|
106
|
-
}
|
|
107
|
-
return results;
|
|
112
|
+
const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
|
|
113
|
+
return perFile.flat();
|
|
108
114
|
}
|
|
109
115
|
const FlowMetadataFileSchema = zod_1.z.object({
|
|
110
116
|
flow_name: zod_1.z.string(),
|
|
@@ -247,32 +253,220 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
247
253
|
}
|
|
248
254
|
return results;
|
|
249
255
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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;
|
|
257
271
|
try {
|
|
258
|
-
|
|
272
|
+
content = await promises_1.default.readFile(args.junitFile, 'utf-8');
|
|
259
273
|
}
|
|
260
|
-
catch {
|
|
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;
|
|
261
304
|
try {
|
|
262
|
-
|
|
305
|
+
entries = await promises_1.default.readdir(args.testsDirectory);
|
|
263
306
|
}
|
|
264
|
-
catch {
|
|
265
|
-
|
|
266
|
-
if (relative.startsWith('..')) {
|
|
267
|
-
return flowFilePath;
|
|
307
|
+
catch {
|
|
308
|
+
entries = [];
|
|
268
309
|
}
|
|
269
|
-
|
|
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);
|
|
270
465
|
}
|
|
271
466
|
/**
|
|
272
467
|
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* attempt" semantics.
|
|
468
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
469
|
+
* step still needs to produce final_report_path.
|
|
276
470
|
*
|
|
277
471
|
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
278
472
|
*/
|
|
@@ -283,7 +477,8 @@ async function copyLatestAttemptXml(args) {
|
|
|
283
477
|
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
|
284
478
|
}
|
|
285
479
|
// Pick the file with the highest attempt index. Files without the marker are
|
|
286
|
-
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
|
480
|
+
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
|
481
|
+
// (same rule as mergeJUnitReports).
|
|
287
482
|
let winner = xmlFiles[0];
|
|
288
483
|
let winnerAttempt = (() => {
|
|
289
484
|
const m = winner.match(ATTEMPT_PATTERN);
|
|
@@ -300,3 +495,24 @@ async function copyLatestAttemptXml(args) {
|
|
|
300
495
|
}
|
|
301
496
|
await promises_1.default.copyFile(path_1.default.join(args.sourceDir, winner), args.outputPath);
|
|
302
497
|
}
|
|
498
|
+
async function relativizePathAsync(flowFilePath, projectRoot) {
|
|
499
|
+
if (!path_1.default.isAbsolute(flowFilePath)) {
|
|
500
|
+
return flowFilePath;
|
|
501
|
+
}
|
|
502
|
+
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
|
503
|
+
let resolvedRoot = projectRoot;
|
|
504
|
+
let resolvedFlow = flowFilePath;
|
|
505
|
+
try {
|
|
506
|
+
resolvedRoot = await promises_1.default.realpath(projectRoot);
|
|
507
|
+
}
|
|
508
|
+
catch { }
|
|
509
|
+
try {
|
|
510
|
+
resolvedFlow = await promises_1.default.realpath(flowFilePath);
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
const relative = path_1.default.relative(resolvedRoot, resolvedFlow);
|
|
514
|
+
if (relative.startsWith('..')) {
|
|
515
|
+
return flowFilePath;
|
|
516
|
+
}
|
|
517
|
+
return relative;
|
|
518
|
+
}
|
|
@@ -22,6 +22,16 @@ function parseInput(schema, value, message) {
|
|
|
22
22
|
}
|
|
23
23
|
return result.data;
|
|
24
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
|
+
}
|
|
25
35
|
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
|
26
36
|
// other declared formats pass an explicit path so downstream upload steps
|
|
27
37
|
// know where to find the result.
|
|
@@ -144,6 +154,10 @@ function createMaestroTestsBuildFunction() {
|
|
|
144
154
|
// numeric err.status → maestro exited non-zero → retry.
|
|
145
155
|
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
|
146
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;
|
|
147
161
|
let lastAttemptExitCode = null;
|
|
148
162
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
149
163
|
const outputPath = outputFormat === 'junit'
|
|
@@ -153,7 +167,7 @@ function createMaestroTestsBuildFunction() {
|
|
|
153
167
|
: null;
|
|
154
168
|
try {
|
|
155
169
|
await (0, turtle_spawn_1.default)('maestro', buildMaestroArgs({
|
|
156
|
-
flow_path:
|
|
170
|
+
flow_path: flowsToRun,
|
|
157
171
|
outputPath,
|
|
158
172
|
output_format: outputFormat,
|
|
159
173
|
shards,
|
|
@@ -176,24 +190,48 @@ function createMaestroTestsBuildFunction() {
|
|
|
176
190
|
if (lastAttemptExitCode === 0 || attempt === retries) {
|
|
177
191
|
break;
|
|
178
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
|
+
}
|
|
179
204
|
logger.info('Test failed, retrying...');
|
|
180
205
|
await (0, retry_1.sleepAsync)(2000);
|
|
181
206
|
}
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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.
|
|
184
210
|
if (finalReportPath !== undefined) {
|
|
185
211
|
try {
|
|
186
|
-
await (0, maestroResultParser_1.
|
|
212
|
+
await (0, maestroResultParser_1.mergeJUnitReports)({
|
|
187
213
|
sourceDir: junitReportDirectory,
|
|
188
214
|
outputPath: finalReportPath,
|
|
189
215
|
});
|
|
190
216
|
}
|
|
191
|
-
catch (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
197
235
|
}
|
|
198
236
|
}
|
|
199
237
|
// The retry loop exits via success (0), numeric status (retryable),
|
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
|
}
|