@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
- * After the maestro retry loop completes, this produces a single canonical
45
- * JUnit report at final_report_path matching the bash step's "cp latest
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
- // Internal helper — not exported. Parses a single JUnit XML file.
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
- // Skip malformed XML files
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 results = [];
104
- for (const xmlFile of xmlFiles) {
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
- async function relativizePathAsync(flowFilePath, projectRoot) {
251
- if (!path_1.default.isAbsolute(flowFilePath)) {
252
- return flowFilePath;
253
- }
254
- // Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
255
- let resolvedRoot = projectRoot;
256
- let resolvedFlow = flowFilePath;
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
- resolvedRoot = await promises_1.default.realpath(projectRoot);
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
- resolvedFlow = await promises_1.default.realpath(flowFilePath);
305
+ entries = await promises_1.default.readdir(args.testsDirectory);
263
306
  }
264
- catch { }
265
- const relative = path_1.default.relative(resolvedRoot, resolvedFlow);
266
- if (relative.startsWith('..')) {
267
- return flowFilePath;
307
+ catch {
308
+ entries = [];
268
309
  }
269
- return relative;
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
- * After the maestro retry loop completes, this produces a single canonical
274
- * JUnit report at final_report_path matching the bash step's "cp latest
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: flowPaths,
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
- // Copy the latest attempt's JUnit to the final report path so downstream
183
- // upload/report steps have a single canonical file.
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.copyLatestAttemptXml)({
212
+ await (0, maestroResultParser_1.mergeJUnitReports)({
187
213
  sourceDir: junitReportDirectory,
188
214
  outputPath: finalReportPath,
189
215
  });
190
216
  }
191
- catch (copyErr) {
192
- // Swallow: a copy failure usually means maestro itself failed early
193
- // (bad YAML wrote no *.xml). Throwing SystemError here would mask
194
- // the real reason and cancel billing for a user-side failure — let
195
- // the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
196
- logger.warn(`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`);
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.10.0",
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": "cacd48bf669342be783037607b02e5f9b1b148c0"
101
+ "gitHead": "4dec5b3765836aff51febd47938e0c657cf9467e"
102
102
  }