@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.
- package/dist/common/easBuildInternal.js +32 -27
- package/dist/ios/pod.js +10 -15
- package/dist/steps/easFunctions.js +3 -1
- package/dist/steps/functions/maestroFlowDiscovery.d.ts +9 -0
- package/dist/steps/functions/maestroFlowDiscovery.js +163 -0
- package/dist/steps/functions/maestroResultParser.d.ts +5 -29
- package/dist/steps/functions/maestroResultParser.js +24 -183
- package/dist/steps/functions/maestroTests.js +43 -15
- package/dist/steps/functions/reportMaestroTestResults.js +24 -2
- package/dist/steps/functions/restoreBuildCache.js +19 -1
- package/dist/steps/functions/startAgentDeviceRemoteSession.d.ts +2 -1
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +76 -72
- package/dist/steps/functions/startServeSimRemoteSession.d.ts +3 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +72 -0
- package/dist/steps/utils/ios/xcactivitylog.js +8 -7
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +24 -0
- package/dist/steps/utils/remoteDeviceRunSession.js +65 -0
- package/package.json +4 -4
|
@@ -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
|
-
//
|
|
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',
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
logger.info(`
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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({
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
57
|
-
|
|
58
|
-
command: 'bun
|
|
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}
|
|
73
|
-
|
|
74
|
-
command:
|
|
75
|
-
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,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.
|
|
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('
|
|
232
|
-
lines.push('
|
|
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
|
-
'
|
|
239
|
+
'Sum'.padStart(taskWidth) +
|
|
239
240
|
' │ ' +
|
|
240
|
-
'%
|
|
241
|
+
'% Sum'.padStart(pctWidth) +
|
|
241
242
|
' │ ' +
|
|
242
|
-
'
|
|
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.
|
|
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
|
+
}
|