@expo/build-tools 18.1.0 → 18.4.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.
Files changed (44) hide show
  1. package/dist/android/gradle.js +1 -1
  2. package/dist/buildErrors/buildErrorHandlers.d.ts +2 -1
  3. package/dist/buildErrors/buildErrorHandlers.js +6 -2
  4. package/dist/buildErrors/detectError.d.ts +1 -1
  5. package/dist/buildErrors/detectError.js +10 -20
  6. package/dist/buildErrors/userErrorHandlers.d.ts +2 -2
  7. package/dist/buildErrors/userErrorHandlers.js +20 -20
  8. package/dist/builders/android.js +12 -2
  9. package/dist/builders/custom.js +1 -0
  10. package/dist/common/installDependencies.js +8 -2
  11. package/dist/common/setup.js +4 -6
  12. package/dist/context.d.ts +2 -0
  13. package/dist/context.js +3 -1
  14. package/dist/customBuildContext.d.ts +5 -1
  15. package/dist/customBuildContext.js +22 -0
  16. package/dist/generic.d.ts +1 -3
  17. package/dist/generic.js +13 -15
  18. package/dist/ios/credentials/provisioningProfile.d.ts +2 -1
  19. package/dist/ios/credentials/provisioningProfile.js +10 -11
  20. package/dist/steps/functions/downloadArtifact.js +3 -3
  21. package/dist/steps/functions/downloadBuild.js +2 -2
  22. package/dist/steps/functions/internalMaestroTest.js +70 -29
  23. package/dist/steps/functions/maestroResultParser.js +134 -80
  24. package/dist/steps/functions/readIpaInfo.js +9 -9
  25. package/dist/steps/functions/reportMaestroTestResults.js +14 -7
  26. package/dist/steps/functions/restoreCache.js +0 -4
  27. package/dist/steps/functions/startAndroidEmulator.js +121 -32
  28. package/dist/steps/functions/uploadToAsc.d.ts +9 -0
  29. package/dist/steps/functions/uploadToAsc.js +55 -6
  30. package/dist/steps/utils/android/gradle.js +1 -1
  31. package/dist/steps/utils/android/gradleConfig.d.ts +3 -0
  32. package/dist/steps/utils/android/gradleConfig.js +15 -1
  33. package/dist/steps/utils/ios/AscApiUtils.js +5 -5
  34. package/dist/steps/utils/ios/credentials/provisioningProfile.d.ts +2 -1
  35. package/dist/steps/utils/ios/credentials/provisioningProfile.js +10 -11
  36. package/dist/utils/AndroidEmulatorUtils.d.ts +9 -2
  37. package/dist/utils/AndroidEmulatorUtils.js +40 -11
  38. package/dist/utils/stepMetrics.d.ts +2 -2
  39. package/dist/utils/stepMetrics.js +4 -10
  40. package/package.json +4 -4
  41. package/dist/android/gradleConfig.d.ts +0 -3
  42. package/dist/android/gradleConfig.js +0 -47
  43. package/dist/templates/EasBuildGradle.d.ts +0 -1
  44. package/dist/templates/EasBuildGradle.js +0 -57
@@ -35,6 +35,7 @@ class ProvisioningProfile {
35
35
  }
36
36
  profilePath;
37
37
  profileData;
38
+ developerCertificates = [];
38
39
  constructor(ctx, profile, keychainPath, target, certificateCommonName) {
39
40
  this.ctx = ctx;
40
41
  this.profile = profile;
@@ -60,10 +61,11 @@ class ProvisioningProfile {
60
61
  await fs_extra_1.default.remove(this.profilePath);
61
62
  }
62
63
  verifyCertificate(fingerprint) {
63
- const devCertFingerprint = this.genDerCertFingerprint();
64
- if (devCertFingerprint !== fingerprint) {
65
- throw new eas_build_job_1.errors.CredentialsDistCertMismatchError(`Provisioning profile and distribution certificate don't match.
66
- Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`);
64
+ const devCertFingerprints = this.getAllDerCertFingerprints();
65
+ if (!devCertFingerprints.includes(fingerprint)) {
66
+ throw new eas_build_job_1.errors.CredentialsDistCertMismatchError(`Provisioning profile and distribution certificate don't match.\n` +
67
+ `Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
68
+ `distribution certificate fingerprint = ${fingerprint}`);
67
69
  }
68
70
  }
69
71
  async load() {
@@ -87,6 +89,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
87
89
  }
88
90
  const applicationIdentifier = plistData.Entitlements['application-identifier'];
89
91
  const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
92
+ this.developerCertificates = plistData.DeveloperCertificates.map((cert) => Buffer.from(cert, 'base64'));
90
93
  this.profileData = {
91
94
  path: this.profilePath,
92
95
  target: this.target,
@@ -94,7 +97,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
94
97
  teamId: plistData.TeamIdentifier[0],
95
98
  uuid: plistData.UUID,
96
99
  name: plistData.Name,
97
- developerCertificate: Buffer.from(plistData.DeveloperCertificates[0], 'base64'),
100
+ developerCertificate: this.developerCertificates[0],
98
101
  certificateCommonName: this.certificateCommonName,
99
102
  distributionType: this.resolveDistributionType(plistData),
100
103
  };
@@ -110,12 +113,8 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
110
113
  return DistributionType.APP_STORE;
111
114
  }
112
115
  }
113
- genDerCertFingerprint() {
114
- return crypto_1.default
115
- .createHash('sha1')
116
- .update(new Uint8Array(this.data.developerCertificate))
117
- .digest('hex')
118
- .toUpperCase();
116
+ getAllDerCertFingerprints() {
117
+ return this.developerCertificates.map(cert => crypto_1.default.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase());
119
118
  }
120
119
  }
121
120
  exports.default = ProvisioningProfile;
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createDownloadArtifactFunction = createDownloadArtifactFunction;
7
7
  exports.downloadArtifactAsync = downloadArtifactAsync;
8
- const errors_1 = require("@expo/eas-build-job/dist/errors");
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
9
9
  const results_1 = require("@expo/results");
10
10
  const steps_1 = require("@expo/steps");
11
11
  const node_fetch_1 = __importDefault(require("node-fetch"));
@@ -52,11 +52,11 @@ function createDownloadArtifactFunction() {
52
52
  });
53
53
  const interpolationContext = stepsCtx.global.getInterpolationContext();
54
54
  if (!('workflow' in interpolationContext)) {
55
- throw new errors_1.UserFacingError('EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW', 'No workflow found in the interpolation context.');
55
+ throw new eas_build_job_1.UserError('EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW', 'No workflow found in the interpolation context.');
56
56
  }
57
57
  const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
58
58
  if (!robotAccessToken) {
59
- throw new errors_1.UserFacingError('EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN', 'No robot access token found in the job secrets.');
59
+ throw new eas_build_job_1.UserError('EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN', 'No robot access token found in the job secrets.');
60
60
  }
61
61
  const workflowRunId = interpolationContext.workflow.id;
62
62
  const { logger } = stepsCtx;
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createDownloadBuildFunction = createDownloadBuildFunction;
7
7
  exports.downloadBuildAsync = downloadBuildAsync;
8
- const errors_1 = require("@expo/eas-build-job/dist/errors");
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
9
9
  const results_1 = require("@expo/results");
10
10
  const steps_1 = require("@expo/steps");
11
11
  const fast_glob_1 = require("fast-glob");
@@ -98,7 +98,7 @@ async function downloadBuildAsync({ logger, buildId, expoApiServerURL, robotAcce
98
98
  onlyDirectories: false,
99
99
  });
100
100
  if (matchingFiles.length === 0) {
101
- throw new errors_1.UserFacingError('EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES', `No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`);
101
+ throw new eas_build_job_1.UserError('EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES', `No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`);
102
102
  }
103
103
  logger.info(`Found ${matchingFiles.length} matching ${(0, strings_1.pluralize)(matchingFiles.length, 'entry')}:\n${matchingFiles.map(f => `- ${node_path_1.default.relative(extractionDirectory, f)}`).join('\n')}`);
104
104
  return { artifactPath: matchingFiles[0] };
@@ -18,6 +18,7 @@ const zod_1 = require("zod");
18
18
  const AndroidEmulatorUtils_1 = require("../../utils/AndroidEmulatorUtils");
19
19
  const IosSimulatorUtils_1 = require("../../utils/IosSimulatorUtils");
20
20
  const findMaestroPathsFlowsToExecuteAsync_1 = require("../../utils/findMaestroPathsFlowsToExecuteAsync");
21
+ const retry_1 = require("../../utils/retry");
21
22
  const strings_1 = require("../../utils/strings");
22
23
  function createInternalEasMaestroTestFunction(ctx) {
23
24
  return new steps_1.BuildFunction({
@@ -176,14 +177,14 @@ function createInternalEasMaestroTestFunction(ctx) {
176
177
  const failedFlows = [];
177
178
  for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
178
179
  stepCtx.logger.info('');
179
- // If output_format is empty or noop, we won't use this.
180
- const outputPath = node_path_1.default.join(maestroReportsDir, [
181
- `${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
182
- MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
183
- ]
184
- .filter(Boolean)
185
- .join('.'));
186
180
  for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
181
+ // Generate unique report path per attempt (not overwritten on retry)
182
+ const outputPath = node_path_1.default.join(maestroReportsDir, [
183
+ `${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
184
+ MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
185
+ ]
186
+ .filter(Boolean)
187
+ .join('.'));
187
188
  const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}`;
188
189
  // If the test passes, but the recording fails, we don't want to make the test fail,
189
190
  // so we return two separate results.
@@ -226,11 +227,7 @@ function createInternalEasMaestroTestFunction(ctx) {
226
227
  if (logsResult?.ok) {
227
228
  try {
228
229
  const extension = node_path_1.default.extname(logsResult.value.outputPath);
229
- const destinationPath = node_path_1.default.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
230
- await node_fs_1.default.promises.rm(destinationPath, {
231
- force: true,
232
- recursive: true,
233
- });
230
+ const destinationPath = node_path_1.default.join(deviceLogsDir, `flow-${flowIndex}-attempt-${attemptCount}${extension}`);
234
231
  await node_fs_1.default.promises.rename(logsResult.value.outputPath, destinationPath);
235
232
  }
236
233
  catch (err) {
@@ -344,9 +341,11 @@ const MaestroOutputFormatToExtensionMap = {
344
341
  junit: 'xml',
345
342
  html: 'html',
346
343
  };
344
+ const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
345
+ const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
347
346
  async function withCleanDeviceAsync({ platform, sourceDeviceIdentifier, localDeviceName, env, logger, fn, }) {
348
347
  // Clone and start the device
349
- let localDeviceIdentifier;
348
+ let localDeviceIdentifier = null;
350
349
  switch (platform) {
351
350
  case 'ios': {
352
351
  logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
@@ -369,27 +368,69 @@ async function withCleanDeviceAsync({ platform, sourceDeviceIdentifier, localDev
369
368
  break;
370
369
  }
371
370
  case 'android': {
372
- logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
373
- await AndroidEmulatorUtils_1.AndroidEmulatorUtils.cloneAsync({
374
- sourceDeviceName: sourceDeviceIdentifier,
375
- destinationDeviceName: localDeviceName,
376
- env,
371
+ await (0, retry_1.retryAsync)(async (attemptCount) => {
372
+ const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
373
+ const attempt = attemptCount + 1;
374
+ const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
375
+ let serialId = null;
376
+ try {
377
+ logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`);
378
+ await AndroidEmulatorUtils_1.AndroidEmulatorUtils.cloneAsync({
379
+ sourceDeviceName: sourceDeviceIdentifier,
380
+ destinationDeviceName: localDeviceName,
381
+ env,
382
+ logger,
383
+ });
384
+ logger.info(`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`);
385
+ const startResult = await AndroidEmulatorUtils_1.AndroidEmulatorUtils.startAsync({
386
+ deviceName: localDeviceName,
387
+ env,
388
+ });
389
+ serialId = startResult.serialId;
390
+ logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
391
+ await AndroidEmulatorUtils_1.AndroidEmulatorUtils.waitForReadyAsync({
392
+ serialId,
393
+ env,
394
+ timeoutMs,
395
+ logger,
396
+ });
397
+ localDeviceIdentifier = serialId;
398
+ }
399
+ catch (err) {
400
+ logger.warn({ err }, `Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`);
401
+ try {
402
+ if (serialId) {
403
+ await AndroidEmulatorUtils_1.AndroidEmulatorUtils.deleteAsync({
404
+ serialId,
405
+ deviceName: localDeviceName,
406
+ env,
407
+ });
408
+ }
409
+ else {
410
+ await AndroidEmulatorUtils_1.AndroidEmulatorUtils.deleteAsync({
411
+ deviceName: localDeviceName,
412
+ env,
413
+ });
414
+ }
415
+ }
416
+ catch (cleanupErr) {
417
+ logger.warn({ err: cleanupErr }, `Failed to clean up Android Emulator ${localDeviceName}.`);
418
+ }
419
+ throw err;
420
+ }
421
+ }, {
377
422
  logger,
423
+ retryOptions: {
424
+ retries: ANDROID_STARTUP_RETRIES_COUNT,
425
+ retryIntervalMs: 1_000,
426
+ },
378
427
  });
379
- logger.info(`Starting Android Emulator ${localDeviceName}...`);
380
- const { serialId } = await AndroidEmulatorUtils_1.AndroidEmulatorUtils.startAsync({
381
- deviceName: localDeviceName,
382
- env,
383
- });
384
- logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
385
- await AndroidEmulatorUtils_1.AndroidEmulatorUtils.waitForReadyAsync({
386
- serialId,
387
- env,
388
- });
389
- localDeviceIdentifier = serialId;
390
428
  break;
391
429
  }
392
430
  }
431
+ if (!localDeviceIdentifier) {
432
+ throw new Error('Device did not return an identifier after startup.');
433
+ }
393
434
  // Run the function
394
435
  const fnResult = await (0, results_1.asyncResult)(fn({ deviceIdentifier: localDeviceIdentifier }));
395
436
  // Stop the device
@@ -23,6 +23,68 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
23
23
  // Ensure single-element arrays are always arrays
24
24
  isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
25
25
  });
26
+ // Internal helper — not exported. Parses a single JUnit XML file.
27
+ async function parseJUnitFile(filePath) {
28
+ const results = [];
29
+ try {
30
+ const content = await promises_1.default.readFile(filePath, 'utf-8');
31
+ const parsed = xmlParser.parse(content);
32
+ const testsuites = parsed?.testsuites?.testsuite;
33
+ if (!Array.isArray(testsuites)) {
34
+ return results;
35
+ }
36
+ for (const suite of testsuites) {
37
+ const testcases = suite?.testcase;
38
+ if (!Array.isArray(testcases)) {
39
+ continue;
40
+ }
41
+ for (const tc of testcases) {
42
+ const name = tc['@_name'];
43
+ if (!name) {
44
+ continue;
45
+ }
46
+ const timeStr = tc['@_time'];
47
+ const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
48
+ const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
49
+ const status = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
50
+ const failureText = tc.failure != null
51
+ ? typeof tc.failure === 'string'
52
+ ? tc.failure
53
+ : (tc.failure?.['#text'] ?? null)
54
+ : null;
55
+ const errorText = tc.error != null
56
+ ? typeof tc.error === 'string'
57
+ ? tc.error
58
+ : (tc.error?.['#text'] ?? null)
59
+ : null;
60
+ const errorMessage = failureText ?? errorText ?? null;
61
+ const rawProperties = tc.properties?.property ?? [];
62
+ const properties = {};
63
+ for (const prop of rawProperties) {
64
+ const propName = prop['@_name'];
65
+ const value = prop['@_value'];
66
+ if (typeof propName !== 'string' || typeof value !== 'string') {
67
+ continue;
68
+ }
69
+ properties[propName] = value;
70
+ }
71
+ const tagsValue = properties['tags'];
72
+ const tags = tagsValue
73
+ ? tagsValue
74
+ .split(',')
75
+ .map(t => t.trim())
76
+ .filter(Boolean)
77
+ : [];
78
+ delete properties['tags'];
79
+ results.push({ name, status, duration, errorMessage, tags, properties });
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Skip malformed XML files
85
+ }
86
+ return results;
87
+ }
26
88
  async function parseJUnitTestCases(junitDirectory) {
27
89
  let entries;
28
90
  try {
@@ -37,68 +99,7 @@ async function parseJUnitTestCases(junitDirectory) {
37
99
  }
38
100
  const results = [];
39
101
  for (const xmlFile of xmlFiles) {
40
- try {
41
- const content = await promises_1.default.readFile(path_1.default.join(junitDirectory, xmlFile), 'utf-8');
42
- const parsed = xmlParser.parse(content);
43
- const testsuites = parsed?.testsuites?.testsuite;
44
- if (!Array.isArray(testsuites)) {
45
- continue;
46
- }
47
- for (const suite of testsuites) {
48
- const testcases = suite?.testcase;
49
- if (!Array.isArray(testcases)) {
50
- continue;
51
- }
52
- for (const tc of testcases) {
53
- const name = tc['@_name'];
54
- if (!name) {
55
- continue;
56
- }
57
- const timeStr = tc['@_time'];
58
- const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
59
- const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
60
- // Use @_status as primary indicator (more robust than checking <failure> presence)
61
- const status = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
62
- // Extract error message from <failure> or <error> elements
63
- const failureText = tc.failure != null
64
- ? typeof tc.failure === 'string'
65
- ? tc.failure
66
- : (tc.failure?.['#text'] ?? null)
67
- : null;
68
- const errorText = tc.error != null
69
- ? typeof tc.error === 'string'
70
- ? tc.error
71
- : (tc.error?.['#text'] ?? null)
72
- : null;
73
- const errorMessage = failureText ?? errorText ?? null;
74
- // Extract properties
75
- const rawProperties = tc.properties?.property ?? [];
76
- const properties = {};
77
- for (const prop of rawProperties) {
78
- const propName = prop['@_name'];
79
- const value = prop['@_value'];
80
- if (typeof propName !== 'string' || typeof value !== 'string') {
81
- continue;
82
- }
83
- properties[propName] = value;
84
- }
85
- // Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
86
- const tagsValue = properties['tags'];
87
- const tags = tagsValue
88
- ? tagsValue
89
- .split(',')
90
- .map(t => t.trim())
91
- .filter(Boolean)
92
- : [];
93
- delete properties['tags'];
94
- results.push({ name, status, duration, errorMessage, tags, properties });
95
- }
96
- }
97
- }
98
- catch {
99
- // Skip malformed XML files
100
- continue;
101
- }
102
+ results.push(...(await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile))));
102
103
  }
103
104
  return results;
104
105
  }
@@ -130,9 +131,26 @@ async function parseFlowMetadata(filePath) {
130
131
  }
131
132
  }
132
133
  async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot) {
133
- // 1. Parse JUnit XML files (primary source)
134
- const junitResults = await parseJUnitTestCases(junitDirectory);
135
- if (junitResults.length === 0) {
134
+ // 1. Parse JUnit XML files, tracking which file each result came from
135
+ let junitEntries;
136
+ try {
137
+ junitEntries = await promises_1.default.readdir(junitDirectory);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
143
+ if (xmlFiles.length === 0) {
144
+ return [];
145
+ }
146
+ const junitResultsWithSource = [];
147
+ for (const xmlFile of xmlFiles) {
148
+ const fileResults = await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile));
149
+ for (const result of fileResults) {
150
+ junitResultsWithSource.push({ result, sourceFile: xmlFile });
151
+ }
152
+ }
153
+ if (junitResultsWithSource.length === 0) {
136
154
  return [];
137
155
  }
138
156
  // 2. Parse ai-*.json from debug output for flow_file_path + retryCount
@@ -172,23 +190,59 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
172
190
  }
173
191
  // 3. Merge: JUnit results + ai-*.json metadata
174
192
  const results = [];
175
- for (const junit of junitResults) {
176
- const flowFilePath = flowPathMap.get(junit.name);
193
+ // Parse attempt index from filename pattern: *-attempt-N.*
194
+ const ATTEMPT_PATTERN = /attempt-(\d+)/;
195
+ // Group results by flow name
196
+ const resultsByName = new Map();
197
+ for (const entry of junitResultsWithSource) {
198
+ const group = resultsByName.get(entry.result.name) ?? [];
199
+ group.push(entry);
200
+ resultsByName.set(entry.result.name, group);
201
+ }
202
+ for (const [flowName, flowEntries] of resultsByName) {
203
+ const flowFilePath = flowPathMap.get(flowName);
177
204
  const relativePath = flowFilePath
178
205
  ? await relativizePathAsync(flowFilePath, projectRoot)
179
- : junit.name; // fallback: use flow name if ai-*.json not found
180
- const occurrences = flowOccurrences.get(junit.name) ?? 0;
181
- const retryCount = Math.max(0, occurrences - 1);
182
- results.push({
183
- name: junit.name,
184
- path: relativePath,
185
- status: junit.status,
186
- errorMessage: junit.errorMessage,
187
- duration: junit.duration,
188
- retryCount,
189
- tags: junit.tags,
190
- properties: junit.properties,
191
- });
206
+ : flowName;
207
+ if (flowEntries.length === 1) {
208
+ // Single result for this flow — use ai-*.json occurrence count for retryCount
209
+ // (backward compat with old-style single JUnit file that gets overwritten)
210
+ const { result } = flowEntries[0];
211
+ const occurrences = flowOccurrences.get(flowName) ?? 0;
212
+ const retryCount = Math.max(0, occurrences - 1);
213
+ results.push({
214
+ name: flowName,
215
+ path: relativePath,
216
+ status: result.status,
217
+ errorMessage: result.errorMessage,
218
+ duration: result.duration,
219
+ retryCount,
220
+ tags: result.tags,
221
+ properties: result.properties,
222
+ });
223
+ }
224
+ else {
225
+ // Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
226
+ const sorted = flowEntries
227
+ .map(entry => {
228
+ const match = entry.sourceFile.match(ATTEMPT_PATTERN);
229
+ const attemptIndex = match ? parseInt(match[1], 10) : 0;
230
+ return { ...entry, attemptIndex };
231
+ })
232
+ .sort((a, b) => a.attemptIndex - b.attemptIndex);
233
+ for (const { result, attemptIndex } of sorted) {
234
+ results.push({
235
+ name: flowName,
236
+ path: relativePath,
237
+ status: result.status,
238
+ errorMessage: result.errorMessage,
239
+ duration: result.duration,
240
+ retryCount: attemptIndex,
241
+ tags: result.tags,
242
+ properties: result.properties,
243
+ });
244
+ }
245
+ }
192
246
  }
193
247
  return results;
194
248
  }
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createReadIpaInfoBuildFunction = createReadIpaInfoBuildFunction;
7
7
  exports.readIpaInfoAsync = readIpaInfoAsync;
8
- const errors_1 = require("@expo/eas-build-job/dist/errors");
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
9
9
  const steps_1 = require("@expo/steps");
10
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
@@ -45,7 +45,7 @@ function createReadIpaInfoBuildFunction() {
45
45
  const ipaPathInput = zod_1.z.string().parse(inputs.ipa_path.value);
46
46
  const ipaPath = node_path_1.default.resolve(stepCtx.workingDirectory, ipaPathInput);
47
47
  if (!(await fs_extra_1.default.pathExists(ipaPath))) {
48
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
48
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
49
49
  }
50
50
  const ipaInfo = await readIpaInfoAsync(ipaPath);
51
51
  outputs.bundle_identifier.set(ipaInfo.bundleIdentifier);
@@ -60,15 +60,15 @@ async function readIpaInfoAsync(ipaPath) {
60
60
  const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
61
61
  const bundleIdentifier = infoPlist.CFBundleIdentifier;
62
62
  if (typeof bundleIdentifier !== 'string') {
63
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist');
63
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist');
64
64
  }
65
65
  const bundleShortVersion = infoPlist.CFBundleShortVersionString;
66
66
  if (typeof bundleShortVersion !== 'string') {
67
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist');
67
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist');
68
68
  }
69
69
  const bundleVersion = infoPlist.CFBundleVersion;
70
70
  if (typeof bundleVersion !== 'string') {
71
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist');
71
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist');
72
72
  }
73
73
  return {
74
74
  bundleIdentifier,
@@ -77,10 +77,10 @@ async function readIpaInfoAsync(ipaPath) {
77
77
  };
78
78
  }
79
79
  catch (error) {
80
- if (error instanceof errors_1.UserFacingError) {
80
+ if (error instanceof eas_build_job_1.UserError) {
81
81
  throw error;
82
82
  }
83
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_FAILED', `Failed to read IPA info: ${error.message}`, { cause: error });
83
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_FAILED', `Failed to read IPA info: ${error.message}`, { cause: error });
84
84
  }
85
85
  }
86
86
  function parseInfoPlistBuffer(data) {
@@ -89,7 +89,7 @@ function parseInfoPlistBuffer(data) {
89
89
  const parsedBinaryPlists = bplist_parser_1.default.parseBuffer(data);
90
90
  const parsedBinaryPlist = parsedBinaryPlists[0];
91
91
  if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
92
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
92
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
93
93
  }
94
94
  return parsedBinaryPlist;
95
95
  }
@@ -101,7 +101,7 @@ async function readInfoPlistBufferFromIpaAsync(ipaPath) {
101
101
  const entries = Object.values(await zip.entries());
102
102
  const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
103
103
  if (!infoPlistEntry) {
104
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND', `Failed to read IPA info: Could not find Info.plist in ${ipaPath}`);
104
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND', `Failed to read IPA info: Could not find Info.plist in ${ipaPath}`);
105
105
  }
106
106
  return await zip.entryData(infoPlistEntry.name);
107
107
  }
@@ -56,13 +56,20 @@ function createReportMaestroTestResultsFunction(ctx) {
56
56
  logger.info('No maestro test results found, skipping report');
57
57
  return;
58
58
  }
59
- // Maestro allows overriding flow names via config, so different flow files can share
60
- // the same name. JUnit XML only contains names (not file paths), making it impossible
61
- // to map duplicates back to their original flow files. Skip and let the user fix it.
62
- const names = flowResults.map(r => r.name);
63
- const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
64
- if (duplicates.length > 0) {
65
- logger.error(`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(', ')}. Skipping report. Ensure each Maestro flow has a unique name.`);
59
+ // Detect truly conflicting results: same (name, retryCount) pair means different flow files
60
+ // share the same name (Maestro config override), which we can't disambiguate.
61
+ // Same name with different retryCount is expected (per-attempt results from retries).
62
+ const seen = new Set();
63
+ const conflicting = new Set();
64
+ for (const r of flowResults) {
65
+ const key = `${r.name}:${r.retryCount}`;
66
+ if (seen.has(key)) {
67
+ conflicting.add(r.name);
68
+ }
69
+ seen.add(key);
70
+ }
71
+ if (conflicting.size > 0) {
72
+ logger.error(`Duplicate test case names found in JUnit output: ${[...conflicting].join(', ')}. Skipping report. Ensure each Maestro flow has a unique name.`);
66
73
  return;
67
74
  }
68
75
  const testCaseResults = flowResults.flatMap(f => {
@@ -89,10 +89,6 @@ function createRestoreCacheFunction() {
89
89
  fn: async (stepsCtx, { env, inputs, outputs }) => {
90
90
  const { logger } = stepsCtx;
91
91
  try {
92
- if (stepsCtx.global.staticContext.job.platform) {
93
- logger.error('Caches are not supported in build jobs yet.');
94
- return;
95
- }
96
92
  const paths = zod_1.default
97
93
  .array(zod_1.default.string())
98
94
  .parse((inputs.path.value ?? '').split(/[\r\n]+/))