@expo/build-tools 18.8.1 → 18.10.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,16 @@ 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
+ * Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
44
+ * After the maestro retry loop completes, this produces a single canonical
45
+ * JUnit report at final_report_path matching the bash step's "cp latest
46
+ * attempt" semantics.
47
+ *
48
+ * Throws if sourceDir contains no *.xml files or if the copy fails.
49
+ */
50
+ export declare function copyLatestAttemptXml(args: {
51
+ sourceDir: string;
52
+ outputPath: string;
53
+ }): Promise<void>;
42
54
  export {};
@@ -7,12 +7,15 @@ exports.extractFlowKey = extractFlowKey;
7
7
  exports.parseJUnitTestCases = parseJUnitTestCases;
8
8
  exports.parseFlowMetadata = parseFlowMetadata;
9
9
  exports.parseMaestroResults = parseMaestroResults;
10
+ exports.copyLatestAttemptXml = copyLatestAttemptXml;
10
11
  const fast_xml_parser_1 = require("fast-xml-parser");
11
12
  const promises_1 = __importDefault(require("fs/promises"));
12
13
  const path_1 = __importDefault(require("path"));
13
14
  const zod_1 = require("zod");
14
15
  // Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
15
16
  const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
17
+ // Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
18
+ const ATTEMPT_PATTERN = /attempt-(\d+)/;
16
19
  function extractFlowKey(filename, prefix) {
17
20
  const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
18
21
  return match?.[1] ?? null;
@@ -190,8 +193,6 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
190
193
  }
191
194
  // 3. Merge: JUnit results + ai-*.json metadata
192
195
  const results = [];
193
- // Parse attempt index from filename pattern: *-attempt-N.*
194
- const ATTEMPT_PATTERN = /attempt-(\d+)/;
195
196
  // Group results by flow name
196
197
  const resultsByName = new Map();
197
198
  for (const entry of junitResultsWithSource) {
@@ -267,3 +268,35 @@ async function relativizePathAsync(flowFilePath, projectRoot) {
267
268
  }
268
269
  return relative;
269
270
  }
271
+ /**
272
+ * Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
273
+ * After the maestro retry loop completes, this produces a single canonical
274
+ * JUnit report at final_report_path matching the bash step's "cp latest
275
+ * attempt" semantics.
276
+ *
277
+ * Throws if sourceDir contains no *.xml files or if the copy fails.
278
+ */
279
+ async function copyLatestAttemptXml(args) {
280
+ const entries = await promises_1.default.readdir(args.sourceDir);
281
+ const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
282
+ if (xmlFiles.length === 0) {
283
+ throw new Error(`No *.xml files found in ${args.sourceDir}`);
284
+ }
285
+ // Pick the file with the highest attempt index. Files without the marker are
286
+ // treated as attempt 0. Ties are broken by sorted filename — later wins.
287
+ let winner = xmlFiles[0];
288
+ let winnerAttempt = (() => {
289
+ const m = winner.match(ATTEMPT_PATTERN);
290
+ return m ? parseInt(m[1], 10) : 0;
291
+ })();
292
+ for (let i = 1; i < xmlFiles.length; i++) {
293
+ const candidate = xmlFiles[i];
294
+ const match = candidate.match(ATTEMPT_PATTERN);
295
+ const attempt = match ? parseInt(match[1], 10) : 0;
296
+ if (attempt >= winnerAttempt) {
297
+ winner = candidate;
298
+ winnerAttempt = attempt;
299
+ }
300
+ }
301
+ await promises_1.default.copyFile(path_1.default.join(args.sourceDir, winner), args.outputPath);
302
+ }
@@ -0,0 +1,2 @@
1
+ import { BuildFunction } from '@expo/steps';
2
+ export declare function createMaestroTestsBuildFunction(): BuildFunction;
@@ -0,0 +1,208 @@
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
+ // `outputPath: null` means "let maestro pick" (no --output flag). Junit and
26
+ // other declared formats pass an explicit path so downstream upload steps
27
+ // know where to find the result.
28
+ function buildMaestroArgs(args) {
29
+ const out = ['test'];
30
+ if (args.output_format) {
31
+ out.push(`--format=${args.output_format.toUpperCase()}`);
32
+ }
33
+ if (args.outputPath) {
34
+ out.push(`--output=${args.outputPath}`);
35
+ }
36
+ if (args.shards !== undefined) {
37
+ out.push(`--shard-split=${args.shards}`);
38
+ }
39
+ if (args.include_tags) {
40
+ out.push(`--include-tags=${args.include_tags}`);
41
+ }
42
+ if (args.exclude_tags) {
43
+ out.push(`--exclude-tags=${args.exclude_tags}`);
44
+ }
45
+ out.push(...args.flow_path);
46
+ return out;
47
+ }
48
+ function createMaestroTestsBuildFunction() {
49
+ return new steps_1.BuildFunction({
50
+ namespace: 'eas',
51
+ id: 'maestro_tests',
52
+ name: 'Run Maestro Tests',
53
+ __metricsId: 'eas/maestro_tests',
54
+ inputProviders: [
55
+ steps_1.BuildStepInput.createProvider({
56
+ id: 'flow_path',
57
+ required: true,
58
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.JSON,
59
+ }),
60
+ steps_1.BuildStepInput.createProvider({
61
+ id: 'retries',
62
+ required: false,
63
+ defaultValue: 0,
64
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
65
+ }),
66
+ steps_1.BuildStepInput.createProvider({
67
+ id: 'shards',
68
+ required: false,
69
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
70
+ }),
71
+ steps_1.BuildStepInput.createProvider({
72
+ id: 'include_tags',
73
+ required: false,
74
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
75
+ }),
76
+ steps_1.BuildStepInput.createProvider({
77
+ id: 'exclude_tags',
78
+ required: false,
79
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
80
+ }),
81
+ steps_1.BuildStepInput.createProvider({
82
+ id: 'output_format',
83
+ required: false,
84
+ defaultValue: 'junit',
85
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
86
+ }),
87
+ steps_1.BuildStepInput.createProvider({
88
+ id: 'platform',
89
+ required: false,
90
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
91
+ }),
92
+ ],
93
+ outputProviders: [
94
+ steps_1.BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
95
+ steps_1.BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
96
+ steps_1.BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
97
+ ],
98
+ fn: async (stepCtx, { inputs, outputs, env, signal }) => {
99
+ const { logger, global } = stepCtx;
100
+ const platformInput = inputs.platform.value;
101
+ const outputFormat = inputs.output_format.value?.toLowerCase();
102
+ const includeTags = inputs.include_tags.value;
103
+ const excludeTags = inputs.exclude_tags.value;
104
+ const platform = platformInput === 'ios' || platformInput === 'android'
105
+ ? platformInput
106
+ : global.runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN
107
+ ? 'ios'
108
+ : 'android';
109
+ // Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
110
+ // this env and writes debug output under $HOME/.maestro/tests; the step
111
+ // must read from the same place or stale files leak across runs.
112
+ const home = env.HOME;
113
+ if (!home) {
114
+ throw new eas_build_job_1.SystemError('HOME env var is not set');
115
+ }
116
+ const testsDirectory = path_1.default.join(home, '.maestro', 'tests');
117
+ const junitReportDirectory = path_1.default.join(testsDirectory, 'junit-reports');
118
+ const finalReportPath = outputFormat === 'junit'
119
+ ? path_1.default.join(testsDirectory, `${platform}-maestro-junit.xml`)
120
+ : undefined;
121
+ // Public docs (EAS workflows pre-packaged-jobs) document
122
+ // `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
123
+ // the uploaded dir.
124
+ const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
125
+ // Outputs are published BEFORE any throw below so downstream
126
+ // `if: always()` upload steps still see populated values when this
127
+ // step fails early.
128
+ outputs.tests_directory.set(testsDirectory);
129
+ outputs.junit_report_directory.set(junitReportDirectory);
130
+ if (finalReportPath !== undefined) {
131
+ outputs.final_report_path.set(finalReportPath);
132
+ }
133
+ const flowPaths = parseInput(FlowPathSchema, inputs.flow_path.value, 'flow_path must be a non-empty array of non-empty strings.');
134
+ const retries = parseInput(RetriesSchema, inputs.retries.value, 'retries must be a non-negative integer.');
135
+ const shards = parseInput(ShardsSchema, inputs.shards.value, 'shards must be a positive integer.');
136
+ try {
137
+ await promises_1.default.mkdir(junitReportDirectory, { recursive: true });
138
+ }
139
+ catch (err) {
140
+ throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
141
+ }
142
+ // Retry loop. spawn-async error shapes:
143
+ // ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
144
+ // numeric err.status → maestro exited non-zero → retry.
145
+ // else (signal-only, OOM kill, unknown) → infra → SystemError, never
146
+ // downgraded to "tests failed".
147
+ let lastAttemptExitCode = null;
148
+ for (let attempt = 0; attempt <= retries; attempt++) {
149
+ const outputPath = outputFormat === 'junit'
150
+ ? path_1.default.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
151
+ : outputFormat
152
+ ? path_1.default.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
153
+ : null;
154
+ try {
155
+ await (0, turtle_spawn_1.default)('maestro', buildMaestroArgs({
156
+ flow_path: flowPaths,
157
+ outputPath,
158
+ output_format: outputFormat,
159
+ shards,
160
+ include_tags: includeTags,
161
+ exclude_tags: excludeTags,
162
+ }), { cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal });
163
+ lastAttemptExitCode = 0;
164
+ }
165
+ catch (err) {
166
+ if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
167
+ throw new eas_build_job_1.SystemError('Failed to invoke maestro', { cause: err });
168
+ }
169
+ if (err && typeof err.status === 'number') {
170
+ lastAttemptExitCode = err.status;
171
+ }
172
+ else {
173
+ throw new eas_build_job_1.SystemError('Unexpected spawn failure invoking maestro', { cause: err });
174
+ }
175
+ }
176
+ if (lastAttemptExitCode === 0 || attempt === retries) {
177
+ break;
178
+ }
179
+ logger.info('Test failed, retrying...');
180
+ await (0, retry_1.sleepAsync)(2000);
181
+ }
182
+ // Copy the latest attempt's JUnit to the final report path so downstream
183
+ // upload/report steps have a single canonical file.
184
+ if (finalReportPath !== undefined) {
185
+ try {
186
+ await (0, maestroResultParser_1.copyLatestAttemptXml)({
187
+ sourceDir: junitReportDirectory,
188
+ outputPath: finalReportPath,
189
+ });
190
+ }
191
+ catch (copyErr) {
192
+ // Swallow: a copy failure usually means maestro itself failed early
193
+ // (bad YAML wrote no *.xml). Throwing SystemError here would mask
194
+ // the real reason and cancel billing for a user-side failure — let
195
+ // the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
196
+ logger.warn(`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`);
197
+ }
198
+ }
199
+ // The retry loop exits via success (0), numeric status (retryable),
200
+ // or throw (infra). A non-null non-zero status means the user's tests
201
+ // failed every attempt.
202
+ if (lastAttemptExitCode !== 0) {
203
+ const totalAttempts = retries + 1;
204
+ throw new eas_build_job_1.UserError('ERR_MAESTRO_TESTS_FAILED', `Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`);
205
+ }
206
+ },
207
+ });
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "18.8.1",
3
+ "version": "18.10.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -45,7 +45,7 @@
45
45
  "@expo/plist": "^0.2.0",
46
46
  "@expo/results": "^1.0.0",
47
47
  "@expo/spawn-async": "1.7.2",
48
- "@expo/steps": "18.8.0",
48
+ "@expo/steps": "18.9.0",
49
49
  "@expo/template-file": "18.5.0",
50
50
  "@expo/turtle-spawn": "18.5.0",
51
51
  "@expo/xcpretty": "^4.3.1",
@@ -98,5 +98,5 @@
98
98
  "typescript": "^5.5.4",
99
99
  "uuid": "^9.0.1"
100
100
  },
101
- "gitHead": "714aec69c79af71e68b0212cb3e511f6ccef2ae0"
101
+ "gitHead": "cacd48bf669342be783037607b02e5f9b1b148c0"
102
102
  }