@expo/build-tools 20.0.0 → 20.2.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/builders/android.js +6 -0
  2. package/dist/builders/custom.js +23 -0
  3. package/dist/builders/ios.js +6 -0
  4. package/dist/common/installDependencies.d.ts +10 -1
  5. package/dist/common/installDependencies.js +95 -1
  6. package/dist/common/prebuild.js +2 -3
  7. package/dist/{ios → common}/xcpretty.d.ts +1 -0
  8. package/dist/{ios → common}/xcpretty.js +18 -3
  9. package/dist/datadog.d.ts +8 -1
  10. package/dist/datadog.js +23 -13
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/ios/fastlane.js +1 -1
  14. package/dist/ios/pod.js +1 -1
  15. package/dist/runtimeSettings.d.ts +12 -0
  16. package/dist/runtimeSettings.js +120 -0
  17. package/dist/steps/easFunctions.js +1 -1
  18. package/dist/steps/functions/downloadBuild.d.ts +5 -3
  19. package/dist/steps/functions/downloadBuild.js +34 -4
  20. package/dist/steps/functions/findAndUploadBuildArtifacts.js +2 -2
  21. package/dist/steps/functions/installMaestro.js +13 -2
  22. package/dist/steps/functions/maestroResultParser.d.ts +18 -0
  23. package/dist/steps/functions/maestroResultParser.js +132 -3
  24. package/dist/steps/functions/maestroTests.js +26 -13
  25. package/dist/steps/functions/repack.d.ts +3 -1
  26. package/dist/steps/functions/repack.js +15 -1
  27. package/dist/steps/functions/reportMaestroTestResults.js +39 -20
  28. package/dist/steps/functions/restoreBuildCache.js +9 -6
  29. package/dist/steps/functions/startAgentDeviceRemoteSession.d.ts +1 -1
  30. package/dist/steps/functions/startAgentDeviceRemoteSession.js +101 -22
  31. package/dist/steps/functions/startArgentRemoteSession.js +1 -1
  32. package/dist/steps/functions/startIosSimulator.js +12 -0
  33. package/dist/steps/functions/startServeSimRemoteSession.js +1 -1
  34. package/dist/steps/functions/uploadArtifact.js +1 -1
  35. package/dist/steps/utils/ios/fastlane.js +1 -1
  36. package/dist/steps/utils/remoteDeviceRunSession.d.ts +29 -1
  37. package/dist/steps/utils/remoteDeviceRunSession.js +76 -2
  38. package/dist/utils/IosSimulatorUtils.d.ts +4 -0
  39. package/dist/utils/IosSimulatorUtils.js +5 -0
  40. package/dist/utils/expoUpdatesEmbedded.d.ts +3 -0
  41. package/dist/utils/expoUpdatesEmbedded.js +109 -0
  42. package/package.json +5 -5
  43. package/dist/steps/utils/ios/xcpretty.d.ts +0 -15
  44. package/dist/steps/utils/ios/xcpretty.js +0 -92
@@ -23,14 +23,14 @@ function createFindAndUploadBuildArtifactsBuildFunction(ctx) {
23
23
  await uploadApplicationArchivesAsync({ ctx, stepCtx });
24
24
  }
25
25
  catch (err) {
26
- stepCtx.logger.error(`Failed to upload application archives.`, err);
26
+ stepCtx.logger.error({ err }, `Failed to upload application archives.`);
27
27
  firstError ||= err;
28
28
  }
29
29
  try {
30
30
  await uploadBuildArtifacts({ ctx, stepCtx });
31
31
  }
32
32
  catch (err) {
33
- stepCtx.logger.error(`Failed to upload build artifacts.`, err);
33
+ stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
34
34
  firstError ||= err;
35
35
  }
36
36
  if (ctx.job.platform === eas_build_job_1.Platform.IOS) {
@@ -11,6 +11,7 @@ const assert_1 = __importDefault(require("assert"));
11
11
  const fs_1 = __importDefault(require("fs"));
12
12
  const os_1 = __importDefault(require("os"));
13
13
  const path_1 = __importDefault(require("path"));
14
+ const datadog_1 = require("../../datadog");
14
15
  function createInstallMaestroBuildFunction() {
15
16
  return new steps_1.BuildFunction({
16
17
  namespace: 'eas',
@@ -95,12 +96,22 @@ function createInstallMaestroBuildFunction() {
95
96
  }
96
97
  logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
97
98
  outputs.maestro_version.set(maestroVersionResult.value);
99
+ datadog_1.Datadog.distribution('eas.maestro.install', 1, {
100
+ maestro_version: maestroVersionResult.value,
101
+ });
98
102
  },
99
103
  });
100
104
  }
101
105
  async function getMaestroVersion({ env }) {
102
- const maestroVersion = await (0, turtle_spawn_1.default)('maestro', ['--version'], { stdio: 'pipe', env });
103
- return maestroVersion.stdout.trim();
106
+ const { stdout } = await (0, turtle_spawn_1.default)('maestro', ['--version'], { stdio: 'pipe', env });
107
+ // `maestro --version` can print an analytics notice to stdout before the version,
108
+ // e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
109
+ // Take the last version-looking token: the real version is printed after the notice, so
110
+ // this stays correct even if the notice itself contains a version-like string. Keeps the
111
+ // step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
112
+ // the raw output if none is found, so we never fail the build over a version string.
113
+ const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
114
+ return versions?.at(-1) ?? stdout.trim();
104
115
  }
105
116
  async function installMaestro({ global, version, logger, env, }) {
106
117
  logger.info('Fetching install script');
@@ -10,6 +10,7 @@ export interface MaestroFlowResult {
10
10
  }
11
11
  export interface JUnitTestCaseResult {
12
12
  name: string;
13
+ file: string | undefined;
13
14
  status: 'passed' | 'failed';
14
15
  duration: number;
15
16
  errorMessage: string | null;
@@ -17,6 +18,23 @@ export interface JUnitTestCaseResult {
17
18
  properties: Record<string, string>;
18
19
  }
19
20
  export declare function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]>;
21
+ export declare function isFileAttrRun(testcases: JUnitTestCaseResult[]): testcases is (JUnitTestCaseResult & {
22
+ file: string;
23
+ })[];
24
+ export declare function junitFileHasFileAttrs(junitFile: string): Promise<boolean>;
25
+ export declare function parseMaestroResultsFromFileAttrs(junitDirectory: string): Promise<MaestroFlowResult[]>;
26
+ /**
27
+ * Returns the `file=` paths of the failing testcases in the given attempt's
28
+ * JUnit file, or `null` when the result cannot be trusted (caller then falls
29
+ * back to dumb retry — re-run everything; never the legacy scan).
30
+ *
31
+ * Callers verify the report carries `file=` attributes first
32
+ * (junitFileHasFileAttrs).
33
+ */
34
+ export declare function parseFailedFlowsFromFileAttrs(args: {
35
+ junitFile: string;
36
+ workingDirectory: string;
37
+ }): Promise<string[] | null>;
20
38
  export declare function parseMaestroResults(junitDirectory: string, nameToPath: Map<string, string> | null): Promise<MaestroFlowResult[]>;
21
39
  /**
22
40
  * Returns the subset of `inputFlowPaths` whose testcases failed in the given
@@ -4,10 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseJUnitTestCases = parseJUnitTestCases;
7
+ exports.isFileAttrRun = isFileAttrRun;
8
+ exports.junitFileHasFileAttrs = junitFileHasFileAttrs;
9
+ exports.parseMaestroResultsFromFileAttrs = parseMaestroResultsFromFileAttrs;
10
+ exports.parseFailedFlowsFromFileAttrs = parseFailedFlowsFromFileAttrs;
7
11
  exports.parseMaestroResults = parseMaestroResults;
8
12
  exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
9
13
  exports.mergeJUnitReports = mergeJUnitReports;
10
14
  exports.copyLatestAttemptXml = copyLatestAttemptXml;
15
+ const results_1 = require("@expo/results");
11
16
  const fast_xml_parser_1 = require("fast-xml-parser");
12
17
  const promises_1 = __importDefault(require("fs/promises"));
13
18
  const path_1 = __importDefault(require("path"));
@@ -19,6 +24,11 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
19
24
  // Ensure single-element arrays are always arrays
20
25
  isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
21
26
  });
27
+ // A `file=` attribute counts as present only when it is a non-empty string.
28
+ function fileAttrOf(tc) {
29
+ const f = tc?.['@_file'];
30
+ return typeof f === 'string' && f.length > 0 ? f : undefined;
31
+ }
22
32
  function parseJUnitContent(content) {
23
33
  const results = [];
24
34
  try {
@@ -37,6 +47,7 @@ function parseJUnitContent(content) {
37
47
  if (!name) {
38
48
  continue;
39
49
  }
50
+ const file = fileAttrOf(tc);
40
51
  const timeStr = tc['@_time'];
41
52
  const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
42
53
  const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
@@ -70,7 +81,7 @@ function parseJUnitContent(content) {
70
81
  .filter(Boolean)
71
82
  : [];
72
83
  delete properties['tags'];
73
- results.push({ name, status, duration, errorMessage, tags, properties });
84
+ results.push({ name, file, status, duration, errorMessage, tags, properties });
74
85
  }
75
86
  }
76
87
  }
@@ -103,6 +114,119 @@ async function parseJUnitTestCases(junitDirectory) {
103
114
  const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
104
115
  return perFile.flat();
105
116
  }
117
+ // ---------------------------------------------------------------------------
118
+ // Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
119
+ // its flow's own path (all-or-none per run). Callers check the report first
120
+ // (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
121
+ // scan is then never consulted.
122
+ // ---------------------------------------------------------------------------
123
+ function isFileAttrRun(testcases) {
124
+ return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
125
+ }
126
+ // Unreadable or attribute-less reports ⇒ false: the caller then takes the
127
+ // legacy path, whose own guards degrade safely.
128
+ async function junitFileHasFileAttrs(junitFile) {
129
+ return isFileAttrRun(await parseJUnitFile(junitFile));
130
+ }
131
+ async function fileExists(absPath) {
132
+ return (await (0, results_1.asyncResult)(promises_1.default.stat(absPath))).ok;
133
+ }
134
+ // Group by `file=` so two same-named flows in different files stay separate.
135
+ async function parseMaestroResultsFromFileAttrs(junitDirectory) {
136
+ let junitEntries;
137
+ try {
138
+ junitEntries = await promises_1.default.readdir(junitDirectory);
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
144
+ const entries = [];
145
+ for (const xmlFile of xmlFiles) {
146
+ const fileResults = await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile));
147
+ for (const result of fileResults) {
148
+ entries.push({ result, sourceFile: xmlFile });
149
+ }
150
+ }
151
+ const byPath = new Map();
152
+ for (const entry of entries) {
153
+ // Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
154
+ // skip (rather than emit an undefined path) if that contract is violated.
155
+ const flowPath = entry.result.file;
156
+ if (flowPath === undefined) {
157
+ continue;
158
+ }
159
+ const group = byPath.get(flowPath) ?? [];
160
+ group.push(entry);
161
+ byPath.set(flowPath, group);
162
+ }
163
+ const results = [];
164
+ for (const [flowPath, group] of byPath) {
165
+ // retryCount is derived from each entry's source filename (`attempt-N`);
166
+ // a single entry from `report.xml` (no marker) collapses to attempt 0.
167
+ const sorted = group
168
+ .map(entry => {
169
+ const match = entry.sourceFile.match(ATTEMPT_PATTERN);
170
+ const attemptIndex = match ? parseInt(match[1], 10) : 0;
171
+ return { ...entry, attemptIndex };
172
+ })
173
+ .sort((a, b) => a.attemptIndex - b.attemptIndex);
174
+ for (const { result, attemptIndex } of sorted) {
175
+ results.push({
176
+ name: result.name,
177
+ path: flowPath,
178
+ status: result.status,
179
+ errorMessage: result.errorMessage,
180
+ duration: result.duration,
181
+ retryCount: attemptIndex,
182
+ tags: result.tags,
183
+ properties: result.properties,
184
+ });
185
+ }
186
+ }
187
+ return results;
188
+ }
189
+ /**
190
+ * Returns the `file=` paths of the failing testcases in the given attempt's
191
+ * JUnit file, or `null` when the result cannot be trusted (caller then falls
192
+ * back to dumb retry — re-run everything; never the legacy scan).
193
+ *
194
+ * Callers verify the report carries `file=` attributes first
195
+ * (junitFileHasFileAttrs).
196
+ */
197
+ async function parseFailedFlowsFromFileAttrs(args) {
198
+ // Same hardening as the legacy parser: fast-xml-parser is lenient — a
199
+ // truncated XML could drop testcases, and trusting that partial failing set
200
+ // would skip retries for cut-off flows.
201
+ let content;
202
+ try {
203
+ content = await promises_1.default.readFile(args.junitFile, 'utf-8');
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
209
+ return null;
210
+ }
211
+ const testcases = parseJUnitContent(content);
212
+ if (!isFileAttrRun(testcases)) {
213
+ return null;
214
+ }
215
+ const failing = testcases.filter(tc => tc.status === 'failed');
216
+ if (failing.length === 0) {
217
+ return [];
218
+ }
219
+ // Each failing testcase is self-identifying via `file=`, so duplicate flow
220
+ // names retry correctly. path.resolve (not join): `file=` may be absolute
221
+ // when the flow lives outside the working directory.
222
+ const paths = [...new Set(failing.map(tc => tc.file))];
223
+ for (const p of paths) {
224
+ if (!(await fileExists(path_1.default.resolve(args.workingDirectory, p)))) {
225
+ return null;
226
+ }
227
+ }
228
+ return paths;
229
+ }
106
230
  async function parseMaestroResults(junitDirectory, nameToPath) {
107
231
  let junitEntries;
108
232
  try {
@@ -251,6 +375,10 @@ async function mergeJUnitReports(args) {
251
375
  }
252
376
  const match = filename.match(ATTEMPT_PATTERN);
253
377
  const attemptIndex = match ? parseInt(match[1], 10) : 0;
378
+ // Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
379
+ // different files merge separately. Legacy reports keep name keying.
380
+ const allCases = testsuites.flatMap((suite) => Array.isArray(suite?.testcase) ? suite.testcase : []);
381
+ const useFileKeys = allCases.length > 0 && allCases.every((tc) => fileAttrOf(tc) !== undefined);
254
382
  const testcasesByName = new Map();
255
383
  for (const suite of testsuites) {
256
384
  const cases = suite?.testcase;
@@ -262,9 +390,10 @@ async function mergeJUnitReports(args) {
262
390
  if (typeof name !== 'string') {
263
391
  continue;
264
392
  }
265
- const group = testcasesByName.get(name) ?? [];
393
+ const key = useFileKeys ? fileAttrOf(tc) : name;
394
+ const group = testcasesByName.get(key) ?? [];
266
395
  group.push(tc);
267
- testcasesByName.set(name, group);
396
+ testcasesByName.set(key, group);
268
397
  }
269
398
  }
270
399
  if (testcasesByName.size === 0) {
@@ -157,20 +157,17 @@ function createMaestroTestsBuildFunction() {
157
157
  catch (err) {
158
158
  throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
159
159
  }
160
- // null duplicate flow names; retry-failed-only disabled, fall through to
161
- // dumb retry (re-run everything) on failure.
162
- const nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
163
- inputFlowPaths: flowPaths,
164
- projectRoot: stepCtx.workingDirectory,
165
- logger,
166
- });
160
+ // Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
161
+ // flow scan is built lazily in the retry branch below and memoized so
162
+ // retries share one scan. Never runs when the report has `file=`.
163
+ let nameToPathPromise;
167
164
  // Retry loop. spawn-async error shapes:
168
165
  // ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
169
166
  // numeric err.status → maestro exited non-zero → retry.
170
167
  // else (signal-only, OOM kill, unknown) → infra → SystemError, never
171
168
  // downgraded to "tests failed".
172
169
  // Retry-failed-only (junit mode): after a failed attempt, subset to the failing
173
- // flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
170
+ // flows. The failed-flow parsers return null when the JUnit cannot be
174
171
  // trusted; we then fall through to dumb retry (re-run everything).
175
172
  let flowsToRun = flowPaths;
176
173
  let lastAttemptExitCode = null;
@@ -213,11 +210,27 @@ function createMaestroTestsBuildFunction() {
213
210
  if (lastAttemptExitCode === 0 || attempt === retries) {
214
211
  break;
215
212
  }
216
- if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
217
- const failed = await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({
218
- junitFile: outputPath,
219
- nameToPath,
220
- });
213
+ if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
214
+ let failed;
215
+ if (await (0, maestroResultParser_1.junitFileHasFileAttrs)(outputPath)) {
216
+ failed = await (0, maestroResultParser_1.parseFailedFlowsFromFileAttrs)({
217
+ junitFile: outputPath,
218
+ workingDirectory: stepCtx.workingDirectory,
219
+ });
220
+ }
221
+ else {
222
+ // Legacy (Maestro < 2.6.0): map failed testcase names back to flow
223
+ // paths via the flow-file scan. DELETE this arm once the fleet is
224
+ // on >= 2.6.0.
225
+ const nameToPath = await (nameToPathPromise ??= (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
226
+ inputFlowPaths: flowPaths,
227
+ projectRoot: stepCtx.workingDirectory,
228
+ logger,
229
+ }));
230
+ failed = nameToPath
231
+ ? await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({ junitFile: outputPath, nameToPath })
232
+ : null;
233
+ }
221
234
  if (failed !== null && failed.length > 0) {
222
235
  flowsToRun = failed;
223
236
  logger.info(`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`);
@@ -13,7 +13,9 @@ export declare function resolveAndroidSigningOptionsAsync({ job, tmpDir, }: {
13
13
  /**
14
14
  * Resolves iOS signing options from the job secrets.
15
15
  */
16
- export declare function resolveIosSigningOptionsAsync({ job, logger, }: {
16
+ export declare function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }: {
17
17
  job: Job;
18
18
  logger: bunyan;
19
+ useAppEntitlements?: boolean;
20
+ entitlementsPath?: string;
19
21
  }): Promise<IosSigningOptions | undefined>;
@@ -48,6 +48,16 @@ function createRepackBuildFunction() {
48
48
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
49
49
  required: false,
50
50
  }),
51
+ steps_1.BuildStepInput.createProvider({
52
+ id: 'ios_signing_use_source_app_entitlements',
53
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
54
+ required: false,
55
+ }),
56
+ steps_1.BuildStepInput.createProvider({
57
+ id: 'ios_signing_app_entitlements_path',
58
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
59
+ required: false,
60
+ }),
51
61
  steps_1.BuildStepInput.createProvider({
52
62
  id: 'repack_version',
53
63
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
@@ -116,6 +126,8 @@ function createRepackBuildFunction() {
116
126
  iosSigningOptions: await resolveIosSigningOptionsAsync({
117
127
  job: stepsCtx.global.staticContext.job,
118
128
  logger: stepsCtx.logger,
129
+ useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value,
130
+ entitlementsPath: inputs.ios_signing_app_entitlements_path.value,
119
131
  }),
120
132
  logger: stepsCtx.logger,
121
133
  spawnAsync: repackSpawnAsync,
@@ -219,7 +231,7 @@ async function resolveAndroidSigningOptionsAsync({ job, tmpDir, }) {
219
231
  /**
220
232
  * Resolves iOS signing options from the job secrets.
221
233
  */
222
- async function resolveIosSigningOptionsAsync({ job, logger, }) {
234
+ async function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }) {
223
235
  const iosJob = job;
224
236
  const buildCredentials = iosJob.secrets?.buildCredentials;
225
237
  if (iosJob.simulator || buildCredentials == null) {
@@ -235,5 +247,7 @@ async function resolveIosSigningOptionsAsync({ job, logger, }) {
235
247
  provisioningProfile,
236
248
  keychainPath: credentials.keychainPath,
237
249
  signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
250
+ useAppEntitlements,
251
+ entitlementsPath,
238
252
  };
239
253
  }
@@ -57,41 +57,60 @@ function createReportMaestroTestResultsFunction(ctx) {
57
57
  logger.info('No JUnit directory provided, skipping test results report');
58
58
  return;
59
59
  }
60
- const flowPathRaw = inputs.flow_path.value;
61
- let nameToPath = null;
62
- if (flowPathRaw !== undefined) {
63
- const parsed = FlowPathSchema.safeParse(flowPathRaw);
64
- if (parsed.success) {
65
- nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
66
- inputFlowPaths: parsed.data,
67
- projectRoot: stepsCtx.workingDirectory,
68
- logger,
69
- });
60
+ try {
61
+ // Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
62
+ // `file=` attribute; older reports need the legacy flow-file scan to
63
+ // map testcase names back to paths.
64
+ const usedFileAttrs = (0, maestroResultParser_1.isFileAttrRun)(await (0, maestroResultParser_1.parseJUnitTestCases)(junitDirectory));
65
+ let flowResults;
66
+ if (usedFileAttrs) {
67
+ flowResults = await (0, maestroResultParser_1.parseMaestroResultsFromFileAttrs)(junitDirectory);
70
68
  }
71
69
  else {
72
- logger.warn('Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).');
70
+ // Legacy (Maestro < 2.6.0) DELETE this arm once the fleet is on >= 2.6.0.
71
+ const flowPathRaw = inputs.flow_path.value;
72
+ let nameToPath = null;
73
+ if (flowPathRaw !== undefined) {
74
+ const parsed = FlowPathSchema.safeParse(flowPathRaw);
75
+ if (parsed.success) {
76
+ nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
77
+ inputFlowPaths: parsed.data,
78
+ projectRoot: stepsCtx.workingDirectory,
79
+ logger,
80
+ });
81
+ }
82
+ else {
83
+ logger.warn('Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).');
84
+ }
85
+ }
86
+ flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
73
87
  }
74
- }
75
- try {
76
- const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
77
88
  if (flowResults.length === 0) {
78
89
  logger.info('No maestro test results found, skipping report');
79
90
  return;
80
91
  }
81
- // Detect truly conflicting results: same (name, retryCount) pair means different flow files
82
- // share the same name (Maestro config override), which we can't disambiguate.
83
- // Same name with different retryCount is expected (per-attempt results from retries).
92
+ // Detect truly conflicting results: the same (path, retryCount) twice means two
93
+ // flow files resolved to the same path, which we can't disambiguate and the API
94
+ // rejects as a duplicate. Same path with different retryCount is expected
95
+ // (per-attempt results from retries).
84
96
  const seen = new Set();
85
97
  const conflicting = new Set();
86
98
  for (const r of flowResults) {
87
- const key = `${r.name}:${r.retryCount}`;
99
+ const key = `${r.path}:${r.retryCount}`;
88
100
  if (seen.has(key)) {
89
- conflicting.add(r.name);
101
+ conflicting.add(r.path);
90
102
  }
91
103
  seen.add(key);
92
104
  }
93
105
  if (conflicting.size > 0) {
94
- logger.error(`Duplicate test case names found in JUnit output: ${[...conflicting].join(', ')}. Skipping report. Ensure each Maestro flow has a unique name.`);
106
+ const conflictList = [...conflicting].join(', ');
107
+ logger.error(usedFileAttrs
108
+ ? `The same flow file was reported more than once in a single attempt: ` +
109
+ `${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
110
+ `or leftover XML files in the JUnit report directory.`
111
+ : `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
112
+ `Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
113
+ `then identified by file path, so duplicate names are fine).`);
95
114
  return;
96
115
  }
97
116
  const testCaseResults = flowResults.flatMap(f => {
@@ -159,12 +159,15 @@ async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets,
159
159
  return;
160
160
  }
161
161
  try {
162
- const gradlePropertiesPath = path_1.default.join(workingDirectory, 'android', 'gradle.properties');
163
- const gradlePropertiesContent = await fs_1.default.promises.readFile(gradlePropertiesPath, 'utf-8');
164
- await fs_1.default.promises.writeFile(gradlePropertiesPath, `${gradlePropertiesContent}\n\norg.gradle.caching=true\n`);
165
- // Configure cache cleanup via init script (works with both Gradle 8 and 9,
166
- // org.gradle.cache.cleanup property was removed in Gradle 9)
167
- const initScriptDir = path_1.default.join(os_1.default.homedir(), '.gradle', 'init.d');
162
+ // Enable build cache via user-level gradle.properties.
163
+ // This avoids mutating android/gradle.properties which affects fingerprinting.
164
+ const gradleUserHome = path_1.default.join(os_1.default.homedir(), '.gradle');
165
+ await fs_1.default.promises.mkdir(gradleUserHome, { recursive: true });
166
+ const userGradlePropertiesPath = path_1.default.join(gradleUserHome, 'gradle.properties');
167
+ await fs_1.default.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
168
+ // Configure cache cleanup via init script.
169
+ // Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
170
+ const initScriptDir = path_1.default.join(gradleUserHome, 'init.d');
168
171
  await fs_1.default.promises.mkdir(initScriptDir, { recursive: true });
169
172
  await fs_1.default.promises.writeFile(path_1.default.join(initScriptDir, 'eas-cache-cleanup.gradle'), [
170
173
  'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
@@ -1,3 +1,3 @@
1
1
  import { BuildFunction } from '@expo/steps';
2
- import { CustomBuildContext } from '../../customBuildContext';
2
+ import { type CustomBuildContext } from '../../customBuildContext';
3
3
  export declare function createStartAgentDeviceRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;