@expo/build-tools 18.11.0 → 18.12.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.
@@ -28,33 +28,38 @@ async function runEasBuildInternalAsync({ job, logger, env, cwd, projectRootOver
28
28
  else if (githubTriggerOptions?.autoSubmit) {
29
29
  autoSubmitArgs.push('--auto-submit');
30
30
  }
31
- const result = await (0, turtle_spawn_1.default)(cmd, [
32
- ...args,
33
- 'build:internal',
34
- '--platform',
35
- job.platform,
36
- '--profile',
37
- buildProfile,
38
- ...autoSubmitArgs,
39
- ], {
40
- cwd,
41
- env: {
42
- ...env,
43
- EXPO_TOKEN: (0, nullthrows_1.default)(job.secrets, 'Secrets must be defined for non-custom builds')
44
- .robotAccessToken,
45
- ...extraEnv,
46
- EAS_PROJECT_ROOT: projectRootOverride,
47
- },
48
- logger,
49
- // This prevents printing stdout with job secrets and credentials to logs.
50
- mode: logger_1.PipeMode.STDERR_ONLY_AS_STDOUT,
51
- });
52
- const stdout = result.stdout.toString();
53
- const parsed = JSON.parse(stdout);
54
- return validateEasBuildInternalResult({
55
- result: parsed,
56
- oldJob: job,
57
- });
31
+ try {
32
+ const result = await (0, turtle_spawn_1.default)(cmd, [
33
+ ...args,
34
+ 'build:internal',
35
+ '--platform',
36
+ job.platform,
37
+ '--profile',
38
+ buildProfile,
39
+ ...autoSubmitArgs,
40
+ ], {
41
+ cwd,
42
+ env: {
43
+ ...env,
44
+ EXPO_TOKEN: (0, nullthrows_1.default)(job.secrets, 'Secrets must be defined for non-custom builds')
45
+ .robotAccessToken,
46
+ ...extraEnv,
47
+ EAS_PROJECT_ROOT: projectRootOverride,
48
+ },
49
+ logger,
50
+ // This prevents printing stdout with job secrets and credentials to logs.
51
+ mode: logger_1.PipeMode.STDERR_ONLY_AS_STDOUT,
52
+ });
53
+ const stdout = result.stdout.toString();
54
+ const parsed = JSON.parse(stdout);
55
+ return validateEasBuildInternalResult({
56
+ result: parsed,
57
+ oldJob: job,
58
+ });
59
+ }
60
+ catch (err) {
61
+ throw new Error('Failed to run eas build:internal');
62
+ }
58
63
  }
59
64
  async function resolveEnvFromBuildProfileAsync(ctx, { cwd }) {
60
65
  const { cmd, args, extraEnv } = await (0, easCli_1.resolveEasCommandPrefixAndEnvAsync)();
package/dist/ios/pod.js CHANGED
@@ -8,8 +8,8 @@ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
8
8
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const path_1 = __importDefault(require("path"));
10
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/';
11
+ const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
12
+ const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
13
13
  async function installPods(ctx, { infoCallbackFn }) {
14
14
  const iosDir = path_1.default.join(ctx.getReactNativeProjectDirectory(), 'ios');
15
15
  const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -58,10 +58,9 @@ async function resolvePrecompiledModulesPodInstallEnvAsync(ctx) {
58
58
  ctx.logger.info(`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`);
59
59
  return {};
60
60
  }
61
- // Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
62
61
  const env = {
63
62
  EXPO_USE_PRECOMPILED_MODULES: '1',
64
- // EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
63
+ EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
65
64
  };
66
65
  ctx.logger.info(`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(env)
67
66
  .map(([key, value]) => `${key}=${value}`)
@@ -77,14 +76,10 @@ async function getInstalledExpoPackageVersionAsync(ctx) {
77
76
  const expoPackageJsonPath = stdout.toString().trim();
78
77
  return (await fs_extra_1.default.readJson(expoPackageJsonPath)).version;
79
78
  }
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
- // }
79
+ function getPrecompiledModulesBaseUrl(ctx) {
80
+ if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
81
+ return PRECOMPILED_MODULES_BASE_URL;
82
+ }
83
+ const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
84
+ return PRECOMPILED_MODULES_BASE_URL.replace(`${parsedUrl.protocol}//${parsedUrl.host}`, `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`);
85
+ }
@@ -75,7 +75,7 @@ function getEasFunctions(ctx) {
75
75
  (0, generateGymfileFromTemplate_1.generateGymfileFromTemplateFunction)(),
76
76
  (0, runFastlane_1.runFastlaneFunction)(),
77
77
  (0, parseXcactivitylog_1.parseXcactivitylogFunction)(),
78
- (0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(),
78
+ (0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(ctx),
79
79
  (0, startAndroidEmulator_1.createStartAndroidEmulatorBuildFunction)(),
80
80
  (0, startCuttlefishDevice_1.createStartCuttlefishDeviceBuildFunction)(),
81
81
  (0, startIosSimulator_1.createStartIosSimulatorBuildFunction)(),
@@ -0,0 +1,9 @@
1
+ import type { bunyan } from '@expo/logger';
2
+ export interface BuildFlowNameToPathMapArgs {
3
+ inputFlowPaths: string[];
4
+ projectRoot: string;
5
+ logger: bunyan;
6
+ }
7
+ export declare function buildFlowNameToPathMap(args: BuildFlowNameToPathMapArgs): Promise<Map<string, string> | null>;
8
+ export declare function walkDir(dir: string, visited: Set<string>, out: string[], logger: bunyan): Promise<void>;
9
+ export declare function readFlowName(absFile: string): Promise<string>;
@@ -0,0 +1,163 @@
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.buildFlowNameToPathMap = buildFlowNameToPathMap;
7
+ exports.walkDir = walkDir;
8
+ exports.readFlowName = readFlowName;
9
+ const results_1 = require("@expo/results");
10
+ const promises_1 = __importDefault(require("fs/promises"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const yaml_1 = __importDefault(require("yaml"));
13
+ const YAML_EXT = /\.ya?ml$/i;
14
+ const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
15
+ async function buildFlowNameToPathMap(args) {
16
+ try {
17
+ const flows = await discoverFlows(args);
18
+ return dedupAndDetectDuplicates(flows, args.logger);
19
+ }
20
+ catch (err) {
21
+ args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
22
+ return null;
23
+ }
24
+ }
25
+ async function discoverFlows({ inputFlowPaths, projectRoot, logger, }) {
26
+ const realRootResult = await (0, results_1.asyncResult)(promises_1.default.realpath(projectRoot));
27
+ const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
28
+ const fileLists = await Promise.all(inputFlowPaths.map(async (input) => {
29
+ const abs = path_1.default.resolve(projectRoot, input);
30
+ const statResult = await (0, results_1.asyncResult)(promises_1.default.stat(abs));
31
+ if (!statResult.ok) {
32
+ logger.warn(`flow_path entry "${input}" not found, skipping`);
33
+ return [];
34
+ }
35
+ const stat = statResult.value;
36
+ if (stat.isFile() && YAML_EXT.test(abs)) {
37
+ return [abs];
38
+ }
39
+ if (stat.isDirectory()) {
40
+ const out = [];
41
+ await walkDir(abs, new Set(), out, logger);
42
+ return out;
43
+ }
44
+ return [];
45
+ }));
46
+ return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
47
+ }
48
+ async function makeEntry(absFile, realRoot) {
49
+ const [name, realFileResult] = await Promise.all([
50
+ readFlowName(absFile),
51
+ (0, results_1.asyncResult)(promises_1.default.realpath(absFile)),
52
+ ]);
53
+ const realFile = realFileResult.ok ? realFileResult.value : absFile;
54
+ const rel = path_1.default.relative(realRoot, realFile);
55
+ const value = rel.startsWith('..') || path_1.default.isAbsolute(rel) ? absFile : rel;
56
+ return { name, path: value, realpath: realFile };
57
+ }
58
+ function dedupAndDetectDuplicates(flows, logger) {
59
+ const byKey = new Map();
60
+ for (const f of flows) {
61
+ if (!byKey.has(f.realpath)) {
62
+ byKey.set(f.realpath, f);
63
+ }
64
+ }
65
+ const unique = [...byKey.values()];
66
+ const nameToPath = new Map();
67
+ const duplicates = new Map();
68
+ for (const f of unique) {
69
+ if (nameToPath.has(f.name)) {
70
+ const existing = nameToPath.get(f.name);
71
+ const list = duplicates.get(f.name) ?? [existing];
72
+ list.push(f.path);
73
+ duplicates.set(f.name, list);
74
+ }
75
+ nameToPath.set(f.name, f.path);
76
+ }
77
+ if (duplicates.size > 0) {
78
+ for (const [name, paths] of duplicates) {
79
+ logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
80
+ }
81
+ logger.warn('Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
82
+ 'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.');
83
+ return null;
84
+ }
85
+ return nameToPath;
86
+ }
87
+ async function walkDir(dir, visited, out, logger) {
88
+ // Cycle protection: dedup on realpath (fall back to the original path when
89
+ // realpath fails so we still make progress on missing or restricted dirs).
90
+ const realResult = await (0, results_1.asyncResult)(promises_1.default.realpath(dir));
91
+ const real = realResult.ok ? realResult.value : dir;
92
+ if (visited.has(real)) {
93
+ return;
94
+ }
95
+ visited.add(real);
96
+ let entries;
97
+ try {
98
+ entries = await promises_1.default.readdir(dir, { withFileTypes: true });
99
+ }
100
+ catch (err) {
101
+ logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
102
+ return;
103
+ }
104
+ entries.sort((a, b) => a.name.localeCompare(b.name));
105
+ for (const entry of entries) {
106
+ const full = path_1.default.join(dir, entry.name);
107
+ if (entry.isSymbolicLink()) {
108
+ const tgtResult = await (0, results_1.asyncResult)(promises_1.default.stat(full));
109
+ if (!tgtResult.ok) {
110
+ continue;
111
+ }
112
+ const tgt = tgtResult.value;
113
+ if (tgt.isDirectory()) {
114
+ await walkDir(full, visited, out, logger);
115
+ }
116
+ else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
117
+ out.push(full);
118
+ }
119
+ }
120
+ else if (entry.isDirectory()) {
121
+ await walkDir(full, visited, out, logger);
122
+ }
123
+ else if (entry.isFile()) {
124
+ if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
125
+ out.push(full);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ async function readFlowName(absFile) {
131
+ const ext = path_1.default.extname(absFile);
132
+ const fallback = path_1.default.basename(absFile, ext);
133
+ let content;
134
+ try {
135
+ content = await promises_1.default.readFile(absFile, 'utf-8');
136
+ }
137
+ catch {
138
+ return fallback;
139
+ }
140
+ let firstDoc;
141
+ try {
142
+ const docs = yaml_1.default.parseAllDocuments(content);
143
+ firstDoc = docs[0];
144
+ }
145
+ catch {
146
+ return fallback;
147
+ }
148
+ if (!firstDoc || firstDoc.errors.length > 0) {
149
+ return fallback;
150
+ }
151
+ const parsed = firstDoc.toJS();
152
+ const name = parsed?.name;
153
+ if (typeof name === 'string' && name.length > 0) {
154
+ return name;
155
+ }
156
+ return fallback;
157
+ }
158
+ function isYamlFile(name) {
159
+ return YAML_EXT.test(name);
160
+ }
161
+ function isWorkspaceConfig(name) {
162
+ return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
163
+ }
@@ -1,4 +1,3 @@
1
- import { z } from 'zod';
2
1
  export interface MaestroFlowResult {
3
2
  name: string;
4
3
  path: string;
@@ -9,7 +8,6 @@ export interface MaestroFlowResult {
9
8
  tags: string[];
10
9
  properties: Record<string, string>;
11
10
  }
12
- export declare function extractFlowKey(filename: string, prefix: string): string | null;
13
11
  export interface JUnitTestCaseResult {
14
12
  name: string;
15
13
  status: 'passed' | 'failed';
@@ -19,40 +17,19 @@ export interface JUnitTestCaseResult {
19
17
  properties: Record<string, string>;
20
18
  }
21
19
  export declare function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]>;
22
- declare const FlowMetadataFileSchema: z.ZodObject<{
23
- flow_name: z.ZodString;
24
- flow_file_path: z.ZodString;
25
- }, z.core.$strip>;
26
- type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
27
- /**
28
- * Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
29
- *
30
- * The file contains:
31
- * - `flow_name`: derived from the YAML `config.name` field if present, otherwise
32
- * the flow filename without extension.
33
- * See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
34
- * - `flow_file_path`: absolute path to the original flow YAML file.
35
- * - `outputs`: screenshot defect data (unused here).
36
- *
37
- * Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
38
- * See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
39
- */
40
- export declare function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null>;
41
- export declare function parseMaestroResults(junitDirectory: string, testsDirectory: string, projectRoot: string): Promise<MaestroFlowResult[]>;
20
+ export declare function parseMaestroResults(junitDirectory: string, nameToPath: Map<string, string> | null): Promise<MaestroFlowResult[]>;
42
21
  /**
43
22
  * Returns the subset of `inputFlowPaths` whose testcases failed in the given
44
23
  * attempt's JUnit file, or `null` when the result cannot be trusted (caller
45
24
  * then falls back to dumb retry — re-run everything).
46
25
  *
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.
26
+ * Caller passes a precomputed `nameToPath` map built from the user's
27
+ * `inputFlowPaths`. We translate failed testcase names to flow paths via
28
+ * that map; unknown names → null.
50
29
  */
51
30
  export declare function parseFailedFlowsFromJUnit(args: {
52
31
  junitFile: string;
53
- testsDirectory: string;
54
- inputFlowPaths: string[];
55
- projectRoot: string;
32
+ nameToPath: Map<string, string>;
56
33
  }): Promise<string[] | null>;
57
34
  /**
58
35
  * Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
@@ -79,4 +56,3 @@ export declare function copyLatestAttemptXml(args: {
79
56
  sourceDir: string;
80
57
  outputPath: string;
81
58
  }): Promise<void>;
82
- export {};
@@ -3,9 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.extractFlowKey = extractFlowKey;
7
6
  exports.parseJUnitTestCases = parseJUnitTestCases;
8
- exports.parseFlowMetadata = parseFlowMetadata;
9
7
  exports.parseMaestroResults = parseMaestroResults;
10
8
  exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
11
9
  exports.mergeJUnitReports = mergeJUnitReports;
@@ -13,15 +11,8 @@ exports.copyLatestAttemptXml = copyLatestAttemptXml;
13
11
  const fast_xml_parser_1 = require("fast-xml-parser");
14
12
  const promises_1 = __importDefault(require("fs/promises"));
15
13
  const path_1 = __importDefault(require("path"));
16
- const zod_1 = require("zod");
17
- // Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
18
- const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
19
14
  // Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
20
15
  const ATTEMPT_PATTERN = /attempt-(\d+)/;
21
- function extractFlowKey(filename, prefix) {
22
- const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
23
- return match?.[1] ?? null;
24
- }
25
16
  const xmlParser = new fast_xml_parser_1.XMLParser({
26
17
  ignoreAttributes: false,
27
18
  attributeNamePrefix: '@_',
@@ -112,35 +103,7 @@ async function parseJUnitTestCases(junitDirectory) {
112
103
  const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
113
104
  return perFile.flat();
114
105
  }
115
- const FlowMetadataFileSchema = zod_1.z.object({
116
- flow_name: zod_1.z.string(),
117
- flow_file_path: zod_1.z.string(),
118
- });
119
- /**
120
- * Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
121
- *
122
- * The file contains:
123
- * - `flow_name`: derived from the YAML `config.name` field if present, otherwise
124
- * the flow filename without extension.
125
- * See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
126
- * - `flow_file_path`: absolute path to the original flow YAML file.
127
- * - `outputs`: screenshot defect data (unused here).
128
- *
129
- * Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
130
- * See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
131
- */
132
- async function parseFlowMetadata(filePath) {
133
- try {
134
- const content = await promises_1.default.readFile(filePath, 'utf-8');
135
- const data = JSON.parse(content);
136
- return FlowMetadataFileSchema.parse(data);
137
- }
138
- catch {
139
- return null;
140
- }
141
- }
142
- async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot) {
143
- // 1. Parse JUnit XML files, tracking which file each result came from
106
+ async function parseMaestroResults(junitDirectory, nameToPath) {
144
107
  let junitEntries;
145
108
  try {
146
109
  junitEntries = await promises_1.default.readdir(junitDirectory);
@@ -162,44 +125,7 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
162
125
  if (junitResultsWithSource.length === 0) {
163
126
  return [];
164
127
  }
165
- // 2. Parse ai-*.json from debug output for flow_file_path + retryCount
166
- const flowPathMap = new Map(); // flowName → flowFilePath
167
- const flowOccurrences = new Map(); // flowName → count
168
- let entries;
169
- try {
170
- entries = await promises_1.default.readdir(testsDirectory);
171
- }
172
- catch {
173
- entries = [];
174
- }
175
- const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
176
- for (const dir of timestampDirs) {
177
- const dirPath = path_1.default.join(testsDirectory, dir);
178
- let files;
179
- try {
180
- files = await promises_1.default.readdir(dirPath);
181
- }
182
- catch {
183
- continue;
184
- }
185
- for (const file of files) {
186
- const flowKey = extractFlowKey(file, 'ai');
187
- if (!flowKey) {
188
- continue;
189
- }
190
- const metadata = await parseFlowMetadata(path_1.default.join(dirPath, file));
191
- if (!metadata) {
192
- continue;
193
- }
194
- // Track latest path (last timestamp dir wins)
195
- flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
196
- // Count occurrences for retryCount
197
- flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
198
- }
199
- }
200
- // 3. Merge: JUnit results + ai-*.json metadata
201
128
  const results = [];
202
- // Group results by flow name
203
129
  const resultsByName = new Map();
204
130
  for (const entry of junitResultsWithSource) {
205
131
  const group = resultsByName.get(entry.result.name) ?? [];
@@ -207,49 +133,28 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
207
133
  resultsByName.set(entry.result.name, group);
208
134
  }
209
135
  for (const [flowName, flowEntries] of resultsByName) {
210
- const flowFilePath = flowPathMap.get(flowName);
211
- const relativePath = flowFilePath
212
- ? await relativizePathAsync(flowFilePath, projectRoot)
213
- : flowName;
214
- if (flowEntries.length === 1) {
215
- // Single result for this flow — use ai-*.json occurrence count for retryCount
216
- // (backward compat with old-style single JUnit file that gets overwritten)
217
- const { result } = flowEntries[0];
218
- const occurrences = flowOccurrences.get(flowName) ?? 0;
219
- const retryCount = Math.max(0, occurrences - 1);
136
+ const flowPath = nameToPath?.get(flowName) ?? flowName;
137
+ // retryCount is derived from each entry's source filename (`attempt-N`);
138
+ // a single entry from `report.xml` (no marker) collapses to attempt 0.
139
+ const sorted = flowEntries
140
+ .map(entry => {
141
+ const match = entry.sourceFile.match(ATTEMPT_PATTERN);
142
+ const attemptIndex = match ? parseInt(match[1], 10) : 0;
143
+ return { ...entry, attemptIndex };
144
+ })
145
+ .sort((a, b) => a.attemptIndex - b.attemptIndex);
146
+ for (const { result, attemptIndex } of sorted) {
220
147
  results.push({
221
148
  name: flowName,
222
- path: relativePath,
149
+ path: flowPath,
223
150
  status: result.status,
224
151
  errorMessage: result.errorMessage,
225
152
  duration: result.duration,
226
- retryCount,
153
+ retryCount: attemptIndex,
227
154
  tags: result.tags,
228
155
  properties: result.properties,
229
156
  });
230
157
  }
231
- else {
232
- // Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
233
- const sorted = flowEntries
234
- .map(entry => {
235
- const match = entry.sourceFile.match(ATTEMPT_PATTERN);
236
- const attemptIndex = match ? parseInt(match[1], 10) : 0;
237
- return { ...entry, attemptIndex };
238
- })
239
- .sort((a, b) => a.attemptIndex - b.attemptIndex);
240
- for (const { result, attemptIndex } of sorted) {
241
- results.push({
242
- name: flowName,
243
- path: relativePath,
244
- status: result.status,
245
- errorMessage: result.errorMessage,
246
- duration: result.duration,
247
- retryCount: attemptIndex,
248
- tags: result.tags,
249
- properties: result.properties,
250
- });
251
- }
252
- }
253
158
  }
254
159
  return results;
255
160
  }
@@ -258,13 +163,13 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
258
163
  * attempt's JUnit file, or `null` when the result cannot be trusted (caller
259
164
  * then falls back to dumb retry — re-run everything).
260
165
  *
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.
166
+ * Caller passes a precomputed `nameToPath` map built from the user's
167
+ * `inputFlowPaths`. We translate failed testcase names to flow paths via
168
+ * that map; unknown names → null.
264
169
  */
265
170
  async function parseFailedFlowsFromJUnit(args) {
266
171
  // fast-xml-parser is lenient — truncated XML can produce a partial parse
267
- // with some testcases dropped. Trusting that subset for smart retry would
172
+ // with some testcases dropped. Trusting that subset for retry-failed-only would
268
173
  // skip retries for cut-off flows; reject any malformed XML and fall back
269
174
  // to dumb retry.
270
175
  let content;
@@ -286,8 +191,9 @@ async function parseFailedFlowsFromJUnit(args) {
286
191
  return [];
287
192
  }
288
193
  // 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.
194
+ // impossible to map back to a single input flow_path. Signal "unknown".
195
+ // Complementary to the map-build duplicate check (which catches the case
196
+ // where tag filter hides duplication from JUnit).
291
197
  const allNameCounts = new Map();
292
198
  for (const tc of testcases) {
293
199
  allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
@@ -297,60 +203,16 @@ async function parseFailedFlowsFromJUnit(args) {
297
203
  return null;
298
204
  }
299
205
  }
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
206
  const matched = [];
334
207
  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))) {
208
+ const p = args.nameToPath.get(tc.name);
209
+ if (p === undefined) {
344
210
  return null;
345
211
  }
346
- matched.push(relative);
212
+ matched.push(p);
347
213
  }
348
214
  return matched;
349
215
  }
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
216
  /**
355
217
  * Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
356
218
  * attempt's <testcase> per unique flow name (latest determined from the
@@ -495,24 +357,3 @@ async function copyLatestAttemptXml(args) {
495
357
  }
496
358
  await promises_1.default.copyFile(path_1.default.join(args.sourceDir, winner), args.outputPath);
497
359
  }
498
- async function relativizePathAsync(flowFilePath, projectRoot) {
499
- if (!path_1.default.isAbsolute(flowFilePath)) {
500
- return flowFilePath;
501
- }
502
- // Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
503
- let resolvedRoot = projectRoot;
504
- let resolvedFlow = flowFilePath;
505
- try {
506
- resolvedRoot = await promises_1.default.realpath(projectRoot);
507
- }
508
- catch { }
509
- try {
510
- resolvedFlow = await promises_1.default.realpath(flowFilePath);
511
- }
512
- catch { }
513
- const relative = path_1.default.relative(resolvedRoot, resolvedFlow);
514
- if (relative.startsWith('..')) {
515
- return flowFilePath;
516
- }
517
- return relative;
518
- }
@@ -10,6 +10,7 @@ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
10
10
  const promises_1 = __importDefault(require("fs/promises"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const zod_1 = require("zod");
13
+ const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
13
14
  const maestroResultParser_1 = require("./maestroResultParser");
14
15
  const retry_1 = require("../../utils/retry");
15
16
  const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
@@ -73,6 +74,12 @@ function createMaestroTestsBuildFunction() {
73
74
  defaultValue: 0,
74
75
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
75
76
  }),
77
+ steps_1.BuildStepInput.createProvider({
78
+ id: 'retry_failed_only',
79
+ required: false,
80
+ defaultValue: true,
81
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
82
+ }),
76
83
  steps_1.BuildStepInput.createProvider({
77
84
  id: 'shards',
78
85
  required: false,
@@ -143,37 +150,53 @@ function createMaestroTestsBuildFunction() {
143
150
  const flowPaths = parseInput(FlowPathSchema, inputs.flow_path.value, 'flow_path must be a non-empty array of non-empty strings.');
144
151
  const retries = parseInput(RetriesSchema, inputs.retries.value, 'retries must be a non-negative integer.');
145
152
  const shards = parseInput(ShardsSchema, inputs.shards.value, 'shards must be a positive integer.');
153
+ const retryFailedOnly = inputs.retry_failed_only.value;
146
154
  try {
147
155
  await promises_1.default.mkdir(junitReportDirectory, { recursive: true });
148
156
  }
149
157
  catch (err) {
150
158
  throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
151
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
+ });
152
167
  // Retry loop. spawn-async error shapes:
153
168
  // ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
154
169
  // numeric err.status → maestro exited non-zero → retry.
155
170
  // else (signal-only, OOM kill, unknown) → infra → SystemError, never
156
171
  // downgraded to "tests failed".
157
- // Smart retry (junit mode): after a failed attempt, subset to the failing
172
+ // Retry-failed-only (junit mode): after a failed attempt, subset to the failing
158
173
  // flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
159
174
  // trusted; we then fall through to dumb retry (re-run everything).
160
175
  let flowsToRun = flowPaths;
161
176
  let lastAttemptExitCode = null;
177
+ const totalAttempts = retries + 1;
162
178
  for (let attempt = 0; attempt <= retries; attempt++) {
163
179
  const outputPath = outputFormat === 'junit'
164
180
  ? path_1.default.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
165
181
  : outputFormat
166
182
  ? path_1.default.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
167
183
  : null;
184
+ const maestroArgs = buildMaestroArgs({
185
+ flow_path: flowsToRun,
186
+ outputPath,
187
+ output_format: outputFormat,
188
+ shards,
189
+ include_tags: includeTags,
190
+ exclude_tags: excludeTags,
191
+ });
192
+ logger.info(`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`);
168
193
  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 });
194
+ await (0, turtle_spawn_1.default)('maestro', maestroArgs, {
195
+ cwd: stepCtx.workingDirectory,
196
+ env: spawnEnv,
197
+ logger,
198
+ signal,
199
+ });
177
200
  lastAttemptExitCode = 0;
178
201
  }
179
202
  catch (err) {
@@ -190,18 +213,24 @@ function createMaestroTestsBuildFunction() {
190
213
  if (lastAttemptExitCode === 0 || attempt === retries) {
191
214
  break;
192
215
  }
193
- if (outputFormat === 'junit' && outputPath) {
216
+ if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
194
217
  const failed = await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({
195
218
  junitFile: outputPath,
196
- testsDirectory,
197
- inputFlowPaths: flowsToRun,
198
- projectRoot: stepCtx.workingDirectory,
219
+ nameToPath,
199
220
  });
200
221
  if (failed !== null && failed.length > 0) {
201
222
  flowsToRun = failed;
223
+ logger.info(`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`);
202
224
  }
225
+ else {
226
+ flowsToRun = flowPaths;
227
+ logger.info('Test failed; could not determine failed subset, retrying all flows');
228
+ }
229
+ }
230
+ else {
231
+ flowsToRun = flowPaths;
232
+ logger.info('Test failed, retrying all flows');
203
233
  }
204
- logger.info('Test failed, retrying...');
205
234
  await (0, retry_1.sleepAsync)(2000);
206
235
  }
207
236
  // Smart merge first; on data errors (bad XML, missing input) fall back
@@ -238,7 +267,6 @@ function createMaestroTestsBuildFunction() {
238
267
  // or throw (infra). A non-null non-zero status means the user's tests
239
268
  // failed every attempt.
240
269
  if (lastAttemptExitCode !== 0) {
241
- const totalAttempts = retries + 1;
242
270
  throw new eas_build_job_1.UserError('ERR_MAESTRO_TESTS_FAILED', `Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`);
243
271
  }
244
272
  },
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createReportMaestroTestResultsFunction = createReportMaestroTestResultsFunction;
4
4
  const steps_1 = require("@expo/steps");
5
5
  const gql_tada_1 = require("gql.tada");
6
+ const zod_1 = require("zod");
7
+ const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
6
8
  const maestroResultParser_1 = require("./maestroResultParser");
9
+ const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
7
10
  const CREATE_MUTATION = (0, gql_tada_1.graphql)(`
8
11
  mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
9
12
  workflowDeviceTestCaseResult {
@@ -36,6 +39,11 @@ function createReportMaestroTestResultsFunction(ctx) {
36
39
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
37
40
  defaultValue: '${{ env.HOME }}/.maestro/tests',
38
41
  }),
42
+ steps_1.BuildStepInput.createProvider({
43
+ id: 'flow_path',
44
+ required: false,
45
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.JSON,
46
+ }),
39
47
  ],
40
48
  fn: async (stepsCtx, { inputs }) => {
41
49
  const { logger } = stepsCtx;
@@ -49,9 +57,23 @@ function createReportMaestroTestResultsFunction(ctx) {
49
57
  logger.info('No JUnit directory provided, skipping test results report');
50
58
  return;
51
59
  }
52
- const testsDirectory = inputs.tests_directory.value;
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
+ });
70
+ }
71
+ else {
72
+ logger.warn('Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).');
73
+ }
74
+ }
53
75
  try {
54
- const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, testsDirectory, stepsCtx.workingDirectory);
76
+ const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
55
77
  if (flowResults.length === 0) {
56
78
  logger.info('No maestro test results found, skipping report');
57
79
  return;
@@ -214,7 +214,25 @@ async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets,
214
214
  verbose: env.EXPO_DEBUG === '1',
215
215
  logger,
216
216
  });
217
- logger.info(`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`);
217
+ const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
218
+ logger.info(`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`);
219
+ try {
220
+ await (0, turtleFetch_1.turtleFetch)(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
221
+ json: {
222
+ buildId: jobId,
223
+ message: `Gradle cache restored (${hitType})`,
224
+ tags: {
225
+ event: 'gradle_cache_restored',
226
+ cache_hit_type: hitType,
227
+ },
228
+ },
229
+ headers: {
230
+ Authorization: `Bearer ${robotAccessToken}`,
231
+ },
232
+ shouldThrowOnNotOk: false,
233
+ });
234
+ }
235
+ catch { }
218
236
  }
219
237
  catch (err) {
220
238
  if (err instanceof turtleFetch_1.TurtleFetchError && err.response?.status === 404) {
@@ -1,2 +1,3 @@
1
1
  import { BuildFunction } from '@expo/steps';
2
- export declare function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction;
2
+ import { CustomBuildContext } from '../../customBuildContext';
3
+ export declare function createStartAgentDeviceRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
@@ -6,11 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createStartAgentDeviceRemoteSessionBuildFunction = createStartAgentDeviceRemoteSessionBuildFunction;
7
7
  const steps_1 = require("@expo/steps");
8
8
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
9
+ const gql_tada_1 = require("gql.tada");
9
10
  const node_child_process_1 = __importDefault(require("node:child_process"));
10
11
  const node_fs_1 = __importDefault(require("node:fs"));
11
12
  const node_os_1 = __importDefault(require("node:os"));
12
13
  const node_path_1 = __importDefault(require("node:path"));
13
14
  const retry_1 = require("../../utils/retry");
15
+ const eas_build_job_1 = require("@expo/eas-build-job");
14
16
  const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
15
17
  const SRC_DIR = '/tmp/agent-device-src';
16
18
  const RUN_DIR = '/tmp/agent-device';
@@ -18,8 +20,22 @@ const DAEMON_LOG = node_path_1.default.join(RUN_DIR, 'daemon.log');
18
20
  const TUNNEL_LOG = node_path_1.default.join(RUN_DIR, 'cloudflared.log');
19
21
  const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
20
22
  const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
23
+ const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
21
24
  const STARTUP_TIMEOUT_MS = 60_000;
22
- function createStartAgentDeviceRemoteSessionBuildFunction() {
25
+ const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
26
+ mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
27
+ deviceRunSession {
28
+ startDeviceRunSession(
29
+ deviceRunSessionId: $deviceRunSessionId
30
+ remoteConfig: $remoteConfig
31
+ ) {
32
+ id
33
+ status
34
+ }
35
+ }
36
+ }
37
+ `);
38
+ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
23
39
  return new steps_1.BuildFunction({
24
40
  namespace: 'eas',
25
41
  id: 'start_agent_device_remote_session',
@@ -32,17 +48,31 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
32
48
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
33
49
  }),
34
50
  ],
35
- fn: async ({ logger }, { inputs, env }) => {
51
+ fn: async ({ logger, global }, { inputs, env }) => {
52
+ // Fail fast before any expensive setup if the orchestrator-injected
53
+ // DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
54
+ // report the remote config back to the API server.
55
+ const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
56
+ if (!deviceRunSessionId) {
57
+ throw new eas_build_job_1.SystemError('DEVICE_RUN_SESSION_ID is not set. ' +
58
+ 'This step must run as part of a device run session created by the API server, ' +
59
+ 'which injects DEVICE_RUN_SESSION_ID into the job environment.');
60
+ }
36
61
  const packageVersion = inputs.package_version.value;
37
- logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
62
+ const { runtimePlatform } = global;
63
+ logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
38
64
  logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
39
65
  await node_fs_1.default.promises.mkdir(RUN_DIR, { recursive: true });
40
- logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
41
- await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
66
+ if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
67
+ logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
68
+ await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
69
+ }
42
70
  logger.info('Ensuring cloudflared is installed.');
43
- await ensureCloudflaredInstalledAsync({ env, logger });
44
- logger.info('Ensuring bun is installed.');
45
- await ensureBunInstalledAsync({ env, logger });
71
+ const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
72
+ runtimePlatform,
73
+ env,
74
+ logger,
75
+ });
46
76
  logger.info(packageVersion
47
77
  ? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
48
78
  : `Cloning agent-device (latest) into ${SRC_DIR}.`);
@@ -71,7 +101,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
71
101
  logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
72
102
  logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`);
73
103
  await spawnDetachedAsync({
74
- command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
104
+ command: `${cloudflaredCommand} tunnel --url "http://localhost:${daemonPort}"`,
75
105
  logFile: TUNNEL_LOG,
76
106
  env,
77
107
  logger,
@@ -84,9 +114,16 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
84
114
  description: 'cloudflared tunnel',
85
115
  });
86
116
  logger.info(`Tunnel is ready at ${tunnelUrl}.`);
87
- logger.info('Emitting agent-device credentials for the CLI to pick up:');
88
- logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
89
- logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
117
+ logger.info(`Reporting agent-device remote config to the API server (device run session: ${deviceRunSessionId}).`);
118
+ const result = await ctx.graphqlClient
119
+ .mutation(START_DEVICE_RUN_SESSION_MUTATION, {
120
+ deviceRunSessionId,
121
+ remoteConfig: { url: tunnelUrl, token: daemonToken },
122
+ })
123
+ .toPromise();
124
+ if (result.error) {
125
+ throw new eas_build_job_1.SystemError(`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`);
126
+ }
90
127
  logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
91
128
  // Keep the turtle job alive so the daemon and tunnel stay reachable
92
129
  // until stopDeviceRunSession cancels the run.
@@ -94,15 +131,47 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
94
131
  },
95
132
  });
96
133
  }
97
- async function ensureCloudflaredInstalledAsync({ env, logger, }) {
98
- await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
134
+ async function ensureCloudflaredInstalledAsync({ runtimePlatform, env, logger, }) {
135
+ if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
136
+ await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
137
+ return 'cloudflared';
138
+ }
139
+ if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
140
+ return 'cloudflared';
141
+ }
142
+ const cloudflaredArch = cloudflaredLinuxArchForNodeArch(node_os_1.default.arch());
143
+ const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
144
+ logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
145
+ await (0, turtle_spawn_1.default)('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
146
+ env,
147
+ logger,
148
+ });
149
+ await (0, turtle_spawn_1.default)('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
150
+ // Return the absolute install path so the tunnel command works even when
151
+ // /usr/local/bin is not on the step's PATH.
152
+ return CLOUDFLARED_LINUX_INSTALL_PATH;
99
153
  }
100
- async function ensureBunInstalledAsync({ env, logger, }) {
101
- await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
154
+ function cloudflaredLinuxArchForNodeArch(arch) {
155
+ if (arch === 'x64') {
156
+ return 'amd64';
157
+ }
158
+ if (arch === 'arm64') {
159
+ return 'arm64';
160
+ }
161
+ throw new Error(`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`);
102
162
  }
103
163
  async function ensureBrewPackageInstalledAsync({ name, env, logger, }) {
104
164
  await (0, turtle_spawn_1.default)('bash', ['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`], { env, logger });
105
165
  }
166
+ async function isCommandAvailableAsync({ command, env, }) {
167
+ try {
168
+ await (0, turtle_spawn_1.default)('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
169
+ return true;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
106
175
  async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
107
176
  const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
108
177
  await (0, turtle_spawn_1.default)('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
@@ -15,7 +15,7 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
15
15
  const os_1 = __importDefault(require("os"));
16
16
  const path_1 = __importDefault(require("path"));
17
17
  const zod_1 = require("zod");
18
- const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
18
+ const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
19
19
  const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
20
20
  const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
21
21
  const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
@@ -228,18 +228,19 @@ function formatReport(data) {
228
228
  lines.push('');
229
229
  lines.push('Xcode Build — Compile Metrics by Module');
230
230
  lines.push(`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`);
231
- lines.push('% Task = share of total Compile Task Seconds');
232
- lines.push('Wall = first compile start to last compile end');
231
+ lines.push('Sum = sum of compile-step wall durations within the target; overlapping steps are counted separately');
232
+ lines.push('% Sum = share of total Sum');
233
+ lines.push('Active = merged compile-step wall time, excluding idle gaps between compile steps');
233
234
  lines.push('');
234
235
  lines.push(header);
235
236
  lines.push('│ ' +
236
237
  'Module'.padEnd(nameWidth) +
237
238
  ' │ ' +
238
- 'Task'.padStart(taskWidth) +
239
+ 'Sum'.padStart(taskWidth) +
239
240
  ' │ ' +
240
- '% Task'.padStart(pctWidth) +
241
+ '% Sum'.padStart(pctWidth) +
241
242
  ' │ ' +
242
- 'Wall'.padStart(wallWidth) +
243
+ 'Active'.padStart(wallWidth) +
243
244
  ' │ ' +
244
245
  ' '.repeat(barMaxWidth) +
245
246
  ' │');
@@ -255,7 +256,7 @@ function formatReport(data) {
255
256
  ' │ ' +
256
257
  `${pct.toFixed(1)}%`.padStart(pctWidth) +
257
258
  ' │ ' +
258
- formatSeconds(result.wallSpan).padStart(wallWidth) +
259
+ formatSeconds(result.activeWallTime).padStart(wallWidth) +
259
260
  ' │ ' +
260
261
  bar +
261
262
  ' │');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "18.11.0",
3
+ "version": "18.12.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -38,14 +38,14 @@
38
38
  "@expo/config": "55.0.10",
39
39
  "@expo/config-plugins": "55.0.7",
40
40
  "@expo/downloader": "18.5.0",
41
- "@expo/eas-build-job": "18.8.0",
41
+ "@expo/eas-build-job": "18.12.0",
42
42
  "@expo/env": "^0.4.0",
43
43
  "@expo/logger": "18.5.0",
44
44
  "@expo/package-manager": "1.9.10",
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.9.0",
48
+ "@expo/steps": "18.12.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": "4dec5b3765836aff51febd47938e0c657cf9467e"
101
+ "gitHead": "53d294330de9c63eb792f646c0603224def9a1b0"
102
102
  }