@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.
- package/dist/android/gradle.js +1 -1
- package/dist/buildErrors/buildErrorHandlers.d.ts +2 -1
- package/dist/buildErrors/buildErrorHandlers.js +6 -2
- package/dist/buildErrors/detectError.d.ts +1 -1
- package/dist/buildErrors/detectError.js +10 -20
- package/dist/buildErrors/userErrorHandlers.d.ts +2 -2
- package/dist/buildErrors/userErrorHandlers.js +20 -20
- package/dist/builders/android.js +12 -2
- package/dist/builders/custom.js +1 -0
- package/dist/common/installDependencies.js +8 -2
- package/dist/common/setup.js +4 -6
- package/dist/context.d.ts +2 -0
- package/dist/context.js +3 -1
- package/dist/customBuildContext.d.ts +5 -1
- package/dist/customBuildContext.js +22 -0
- package/dist/generic.d.ts +1 -3
- package/dist/generic.js +13 -15
- package/dist/ios/credentials/provisioningProfile.d.ts +2 -1
- package/dist/ios/credentials/provisioningProfile.js +10 -11
- package/dist/steps/functions/downloadArtifact.js +3 -3
- package/dist/steps/functions/downloadBuild.js +2 -2
- package/dist/steps/functions/internalMaestroTest.js +70 -29
- package/dist/steps/functions/maestroResultParser.js +134 -80
- package/dist/steps/functions/readIpaInfo.js +9 -9
- package/dist/steps/functions/reportMaestroTestResults.js +14 -7
- package/dist/steps/functions/restoreCache.js +0 -4
- package/dist/steps/functions/startAndroidEmulator.js +121 -32
- package/dist/steps/functions/uploadToAsc.d.ts +9 -0
- package/dist/steps/functions/uploadToAsc.js +55 -6
- package/dist/steps/utils/android/gradle.js +1 -1
- package/dist/steps/utils/android/gradleConfig.d.ts +3 -0
- package/dist/steps/utils/android/gradleConfig.js +15 -1
- package/dist/steps/utils/ios/AscApiUtils.js +5 -5
- package/dist/steps/utils/ios/credentials/provisioningProfile.d.ts +2 -1
- package/dist/steps/utils/ios/credentials/provisioningProfile.js +10 -11
- package/dist/utils/AndroidEmulatorUtils.d.ts +9 -2
- package/dist/utils/AndroidEmulatorUtils.js +40 -11
- package/dist/utils/stepMetrics.d.ts +2 -2
- package/dist/utils/stepMetrics.js +4 -10
- package/package.json +4 -4
- package/dist/android/gradleConfig.d.ts +0 -3
- package/dist/android/gradleConfig.js +0 -47
- package/dist/templates/EasBuildGradle.d.ts +0 -1
- 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
|
|
64
|
-
if (
|
|
65
|
-
throw new eas_build_job_1.errors.CredentialsDistCertMismatchError(`Provisioning profile and distribution certificate don't match
|
|
66
|
-
Profile's certificate
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
80
|
+
if (error instanceof eas_build_job_1.UserError) {
|
|
81
81
|
throw error;
|
|
82
82
|
}
|
|
83
|
-
throw new
|
|
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
|
|
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
|
|
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
|
-
//
|
|
60
|
-
// the same name
|
|
61
|
-
//
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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]+/))
|