@expo/build-tools 18.9.0 → 18.11.0

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