@expo/build-tools 18.11.0 → 18.12.1

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.
@@ -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;
@@ -4,22 +4,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createStartAgentDeviceRemoteSessionBuildFunction = createStartAgentDeviceRemoteSessionBuildFunction;
7
+ const eas_build_job_1 = require("@expo/eas-build-job");
7
8
  const steps_1 = require("@expo/steps");
8
9
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
9
- const node_child_process_1 = __importDefault(require("node:child_process"));
10
10
  const node_fs_1 = __importDefault(require("node:fs"));
11
11
  const node_os_1 = __importDefault(require("node:os"));
12
12
  const node_path_1 = __importDefault(require("node:path"));
13
13
  const retry_1 = require("../../utils/retry");
14
+ const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
14
15
  const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
15
16
  const SRC_DIR = '/tmp/agent-device-src';
16
- const RUN_DIR = '/tmp/agent-device';
17
- const DAEMON_LOG = node_path_1.default.join(RUN_DIR, 'daemon.log');
18
- const TUNNEL_LOG = node_path_1.default.join(RUN_DIR, 'cloudflared.log');
19
17
  const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
20
18
  const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
19
+ const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
21
20
  const STARTUP_TIMEOUT_MS = 60_000;
22
- function createStartAgentDeviceRemoteSessionBuildFunction() {
21
+ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
23
22
  return new steps_1.BuildFunction({
24
23
  namespace: 'eas',
25
24
  id: 'start_agent_device_remote_session',
@@ -32,17 +31,24 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
32
31
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
33
32
  }),
34
33
  ],
35
- fn: async ({ logger }, { inputs, env }) => {
34
+ fn: async ({ logger, global }, { inputs, env }) => {
35
+ // Fail fast before any expensive setup if the orchestrator-injected
36
+ // DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
37
+ // report the remote config back to the API server.
38
+ const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
36
39
  const packageVersion = inputs.package_version.value;
37
- logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
38
- logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
39
- 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 });
40
+ const { runtimePlatform } = global;
41
+ logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
42
+ if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
43
+ logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
44
+ await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
45
+ }
42
46
  logger.info('Ensuring cloudflared is installed.');
43
- await ensureCloudflaredInstalledAsync({ env, logger });
44
- logger.info('Ensuring bun is installed.');
45
- await ensureBunInstalledAsync({ env, logger });
47
+ const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
48
+ runtimePlatform,
49
+ env,
50
+ logger,
51
+ });
46
52
  logger.info(packageVersion
47
53
  ? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
48
54
  : `Cloning agent-device (latest) into ${SRC_DIR}.`);
@@ -53,13 +59,12 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
53
59
  env,
54
60
  logger,
55
61
  });
56
- logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
57
- await spawnDetachedAsync({
58
- command: 'bun run src/daemon.ts',
62
+ logger.info('Launching agent-device daemon.');
63
+ (0, remoteDeviceRunSession_1.spawnDetached)({
64
+ command: 'bun',
65
+ args: ['run', 'src/daemon.ts'],
59
66
  cwd: SRC_DIR,
60
- logFile: DAEMON_LOG,
61
67
  env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
62
- logger,
63
68
  });
64
69
  logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
65
70
  await waitForFileAsync({
@@ -69,24 +74,26 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
69
74
  });
70
75
  const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
71
76
  logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
72
- logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`);
73
- await spawnDetachedAsync({
74
- command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
75
- logFile: TUNNEL_LOG,
77
+ logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
78
+ const cloudflared = (0, remoteDeviceRunSession_1.spawnDetached)({
79
+ command: cloudflaredCommand,
80
+ args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
76
81
  env,
77
- logger,
78
82
  });
79
83
  logger.info('Waiting for a public tunnel URL.');
80
- const tunnelUrl = await waitForMatchInLogAsync({
81
- logFile: TUNNEL_LOG,
84
+ const tunnelUrl = await waitForMatchInOutputAsync({
85
+ process: cloudflared,
82
86
  pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
83
87
  timeoutMs: STARTUP_TIMEOUT_MS,
84
88
  description: 'cloudflared tunnel',
85
89
  });
86
90
  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}"`);
91
+ await (0, remoteDeviceRunSession_1.uploadRemoteSessionConfigAsync)({
92
+ ctx,
93
+ deviceRunSessionId,
94
+ remoteConfig: { url: tunnelUrl, token: daemonToken },
95
+ logger,
96
+ });
90
97
  logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
91
98
  // Keep the turtle job alive so the daemon and tunnel stay reachable
92
99
  // until stopDeviceRunSession cancels the run.
@@ -94,14 +101,43 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
94
101
  },
95
102
  });
96
103
  }
97
- async function ensureCloudflaredInstalledAsync({ env, logger, }) {
98
- await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
104
+ async function ensureCloudflaredInstalledAsync({ runtimePlatform, env, logger, }) {
105
+ if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
106
+ await (0, remoteDeviceRunSession_1.ensureBrewPackageInstalledAsync)({ name: 'cloudflared', env, logger });
107
+ return 'cloudflared';
108
+ }
109
+ if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
110
+ return 'cloudflared';
111
+ }
112
+ const cloudflaredArch = cloudflaredLinuxArchForNodeArch(node_os_1.default.arch());
113
+ const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
114
+ logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
115
+ await (0, turtle_spawn_1.default)('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
116
+ env,
117
+ logger,
118
+ });
119
+ await (0, turtle_spawn_1.default)('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
120
+ // Return the absolute install path so the tunnel command works even when
121
+ // /usr/local/bin is not on the step's PATH.
122
+ return CLOUDFLARED_LINUX_INSTALL_PATH;
99
123
  }
100
- async function ensureBunInstalledAsync({ env, logger, }) {
101
- await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
124
+ function cloudflaredLinuxArchForNodeArch(arch) {
125
+ if (arch === 'x64') {
126
+ return 'amd64';
127
+ }
128
+ if (arch === 'arm64') {
129
+ return 'arm64';
130
+ }
131
+ throw new eas_build_job_1.SystemError(`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`);
102
132
  }
103
- async function ensureBrewPackageInstalledAsync({ name, env, logger, }) {
104
- 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 });
133
+ async function isCommandAvailableAsync({ command, env, }) {
134
+ try {
135
+ await (0, turtle_spawn_1.default)('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
105
141
  }
106
142
  async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
107
143
  const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
@@ -110,40 +146,16 @@ async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
110
146
  logger,
111
147
  });
112
148
  }
113
- async function spawnDetachedAsync({ command, cwd, logFile, env, logger, }) {
114
- // Launch the process fully detached so this function returns immediately and
115
- // the grandchild survives the step. Stdio goes to a log file so the daemon
116
- // output can be polled, and we unref so Node doesn't wait on it.
117
- const fd = node_fs_1.default.openSync(logFile, 'a');
118
- try {
119
- const child = node_child_process_1.default.spawn('bash', ['-c', command], {
120
- cwd,
121
- env,
122
- detached: true,
123
- stdio: ['ignore', fd, fd],
124
- });
125
- if (!child.pid) {
126
- throw new Error(`Failed to spawn detached process: ${command}`);
127
- }
128
- child.unref();
129
- logger.info(`Started detached process (pid ${child.pid}).`);
130
- }
131
- finally {
132
- node_fs_1.default.closeSync(fd);
133
- }
134
- }
135
- async function waitForMatchInLogAsync({ logFile, pattern, timeoutMs, description, }) {
149
+ async function waitForMatchInOutputAsync({ process, pattern, timeoutMs, description, }) {
136
150
  const deadline = Date.now() + timeoutMs;
137
151
  while (Date.now() < deadline) {
138
- const content = await readFileOrEmptyAsync(logFile);
139
- const match = pattern.exec(content);
152
+ const match = pattern.exec(process.getOutput());
140
153
  if (match) {
141
154
  return match[1] ?? match[0];
142
155
  }
143
156
  await (0, retry_1.sleepAsync)(1_000);
144
157
  }
145
- const tail = await readFileOrEmptyAsync(logFile);
146
- throw new Error(`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`);
158
+ throw new eas_build_job_1.SystemError(`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`);
147
159
  }
148
160
  async function waitForFileAsync({ filePath, timeoutMs, description, }) {
149
161
  const deadline = Date.now() + timeoutMs;
@@ -157,15 +169,7 @@ async function waitForFileAsync({ filePath, timeoutMs, description, }) {
157
169
  }
158
170
  await (0, retry_1.sleepAsync)(1_000);
159
171
  }
160
- throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
161
- }
162
- async function readFileOrEmptyAsync(filePath) {
163
- try {
164
- return await node_fs_1.default.promises.readFile(filePath, 'utf8');
165
- }
166
- catch {
167
- return '';
168
- }
172
+ throw new eas_build_job_1.SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
169
173
  }
170
174
  function readDaemonInfo(filePath) {
171
175
  const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
@@ -174,7 +178,7 @@ function readDaemonInfo(filePath) {
174
178
  typeof parsed !== 'object' ||
175
179
  typeof parsed.httpPort !== 'number' ||
176
180
  typeof parsed.token !== 'string') {
177
- throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
181
+ throw new eas_build_job_1.SystemError(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
178
182
  }
179
183
  const { httpPort, token } = parsed;
180
184
  return { port: httpPort, token };
@@ -0,0 +1,3 @@
1
+ import { BuildFunction } from '@expo/steps';
2
+ import { CustomBuildContext } from '../../customBuildContext';
3
+ export declare function createStartServeSimRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
@@ -0,0 +1,72 @@
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.createStartServeSimRemoteSessionBuildFunction = createStartServeSimRemoteSessionBuildFunction;
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 retry_1 = require("../../utils/retry");
11
+ const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
12
+ const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
13
+ const STARTUP_TIMEOUT_MS = 60_000;
14
+ const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
15
+ function createStartServeSimRemoteSessionBuildFunction(ctx) {
16
+ return new steps_1.BuildFunction({
17
+ namespace: 'eas',
18
+ id: 'start_serve_sim_remote_session',
19
+ name: 'Start serve-sim remote session',
20
+ __metricsId: 'eas/start_serve_sim_remote_session',
21
+ supportedRuntimePlatforms: [steps_1.BuildRuntimePlatform.DARWIN],
22
+ fn: async ({ logger }, { env }) => {
23
+ const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
24
+ logger.info('Starting serve-sim remote session.');
25
+ logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
26
+ await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
27
+ logger.info('Ensuring cloudflared is installed.');
28
+ await (0, remoteDeviceRunSession_1.ensureBrewPackageInstalledAsync)({ name: 'cloudflared', env, logger });
29
+ logger.info('Launching serve-sim with tunnel.');
30
+ const serveSim = (0, remoteDeviceRunSession_1.spawnDetached)({
31
+ command: 'npx',
32
+ args: ['serve-sim-szdziedzic@latest', '--tunnel'],
33
+ env,
34
+ });
35
+ logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
36
+ const { previewUrl, streamUrl } = await waitForServeSimUrlsAsync({
37
+ serveSim,
38
+ timeoutMs: STARTUP_TIMEOUT_MS,
39
+ });
40
+ logger.info(`Preview URL: ${previewUrl}`);
41
+ logger.info(`Stream URL: ${streamUrl}`);
42
+ await (0, remoteDeviceRunSession_1.uploadRemoteSessionConfigAsync)({
43
+ ctx,
44
+ deviceRunSessionId,
45
+ remoteConfig: { previewUrl, streamUrl },
46
+ logger,
47
+ });
48
+ logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
49
+ // Keep the turtle job alive so the serve-sim tunnel stays reachable
50
+ // until stopDeviceRunSession cancels the run.
51
+ await new Promise(() => { });
52
+ },
53
+ });
54
+ }
55
+ async function waitForServeSimUrlsAsync({ serveSim, timeoutMs, }) {
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() < deadline) {
58
+ const output = serveSim.getOutput();
59
+ const previewUrl = matchLabeledUrl(output, 'Tunnel');
60
+ const streamUrl = matchLabeledUrl(output, 'Stream');
61
+ if (previewUrl && streamUrl) {
62
+ return { previewUrl, streamUrl };
63
+ }
64
+ await (0, retry_1.sleepAsync)(1_000);
65
+ }
66
+ throw new eas_build_job_1.SystemError(`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`);
67
+ }
68
+ function matchLabeledUrl(content, label) {
69
+ const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
70
+ const match = labelPattern.exec(content);
71
+ return match ? match[1] : null;
72
+ }
@@ -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
  ' │');
@@ -0,0 +1,24 @@
1
+ import { bunyan } from '@expo/logger';
2
+ import { BuildStepEnv } from '@expo/steps';
3
+ import { CustomBuildContext } from '../../customBuildContext';
4
+ export declare function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string;
5
+ export declare function uploadRemoteSessionConfigAsync({ ctx, deviceRunSessionId, remoteConfig, logger, }: {
6
+ ctx: CustomBuildContext;
7
+ deviceRunSessionId: string;
8
+ remoteConfig: Record<string, unknown>;
9
+ logger: bunyan;
10
+ }): Promise<void>;
11
+ export declare function ensureBrewPackageInstalledAsync({ name, env, logger, }: {
12
+ name: string;
13
+ env: BuildStepEnv;
14
+ logger: bunyan;
15
+ }): Promise<void>;
16
+ export type DetachedProcessHandle = {
17
+ getOutput: () => string;
18
+ };
19
+ export declare function spawnDetached({ command, args, cwd, env, }: {
20
+ command: string;
21
+ args: string[];
22
+ cwd?: string;
23
+ env: BuildStepEnv;
24
+ }): DetachedProcessHandle;
@@ -0,0 +1,65 @@
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.getDeviceRunSessionIdOrThrow = getDeviceRunSessionIdOrThrow;
7
+ exports.uploadRemoteSessionConfigAsync = uploadRemoteSessionConfigAsync;
8
+ exports.ensureBrewPackageInstalledAsync = ensureBrewPackageInstalledAsync;
9
+ exports.spawnDetached = spawnDetached;
10
+ const eas_build_job_1 = require("@expo/eas-build-job");
11
+ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
12
+ const gql_tada_1 = require("gql.tada");
13
+ const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
14
+ mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
15
+ deviceRunSession {
16
+ startDeviceRunSession(
17
+ deviceRunSessionId: $deviceRunSessionId
18
+ remoteConfig: $remoteConfig
19
+ ) {
20
+ id
21
+ status
22
+ }
23
+ }
24
+ }
25
+ `);
26
+ function getDeviceRunSessionIdOrThrow(env) {
27
+ const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
28
+ if (!deviceRunSessionId) {
29
+ throw new eas_build_job_1.SystemError('DEVICE_RUN_SESSION_ID is not set. ' +
30
+ 'This step must run as part of a device run session created by the API server, ' +
31
+ 'which injects DEVICE_RUN_SESSION_ID into the job environment.');
32
+ }
33
+ return deviceRunSessionId;
34
+ }
35
+ async function uploadRemoteSessionConfigAsync({ ctx, deviceRunSessionId, remoteConfig, logger, }) {
36
+ logger.info(`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`);
37
+ const result = await ctx.graphqlClient
38
+ .mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
39
+ .toPromise();
40
+ if (result.error) {
41
+ throw new eas_build_job_1.SystemError(`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`);
42
+ }
43
+ }
44
+ async function ensureBrewPackageInstalledAsync({ name, env, logger, }) {
45
+ 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 });
46
+ }
47
+ function spawnDetached({ command, args, cwd, env, }) {
48
+ const promise = (0, turtle_spawn_1.default)(command, args, {
49
+ cwd,
50
+ env,
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ detached: true,
53
+ });
54
+ // We don't await the process — it should outlive this step. Failures show
55
+ // up in the captured output; suppress unhandled rejections here.
56
+ promise.catch(() => { });
57
+ promise.child.unref();
58
+ let output = '';
59
+ const appendChunk = (chunk) => {
60
+ output += chunk.toString();
61
+ };
62
+ promise.child.stdout?.on('data', appendChunk);
63
+ promise.child.stderr?.on('data', appendChunk);
64
+ return { getOutput: () => output };
65
+ }