@expo/build-tools 20.3.0 → 20.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/context.d.ts CHANGED
@@ -19,6 +19,7 @@ export type ArtifactToUpload = {
19
19
  type: GenericArtifactType;
20
20
  name: string;
21
21
  paths: string[];
22
+ metadata?: Record<string, unknown>;
22
23
  };
23
24
  export interface BuildContextOptions {
24
25
  workingdir: string;
@@ -90,7 +90,7 @@ function getEasFunctions(ctx) {
90
90
  (0, createSubmissionEntity_1.createSubmissionEntityFunction)(),
91
91
  (0, uploadToAsc_1.createUploadToAscBuildFunction)(),
92
92
  (0, reportMaestroTestResults_1.createReportMaestroTestResultsFunction)(ctx),
93
- (0, maestroTests_1.createMaestroTestsBuildFunction)(),
93
+ (0, maestroTests_1.createMaestroTestsBuildFunction)(ctx),
94
94
  ];
95
95
  if (ctx.hasBuildJob()) {
96
96
  functions.push(...[
@@ -0,0 +1,27 @@
1
+ import { bunyan } from '@expo/logger';
2
+ export interface ParsedFailureScreenshot {
3
+ flowName: string;
4
+ capturedAtMs: number;
5
+ }
6
+ export declare function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null;
7
+ export interface HarvestedScreenshot {
8
+ fileAbsPath: string;
9
+ displayName: string;
10
+ metadata: {
11
+ kind: 'maestro-test-screenshot';
12
+ flowName: string;
13
+ attemptIndex: number;
14
+ capturedAtMs: number;
15
+ };
16
+ }
17
+ export declare function harvestFailureScreenshotsAsync(args: {
18
+ testsDirectory: string;
19
+ capturedSinceMs: number;
20
+ attemptIndex: number;
21
+ logger: bunyan;
22
+ }): Promise<HarvestedScreenshot[]>;
23
+ export declare function computePureFailureFlowNames(testCases: readonly {
24
+ name: string;
25
+ status: 'passed' | 'failed';
26
+ }[]): Set<string>;
27
+ export declare function selectFailureScreenshots(harvested: readonly HarvestedScreenshot[], pureFailureFlowNames: ReadonlySet<string>): HarvestedScreenshot[];
@@ -0,0 +1,134 @@
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.parseFailureScreenshotFilename = parseFailureScreenshotFilename;
7
+ exports.harvestFailureScreenshotsAsync = harvestFailureScreenshotsAsync;
8
+ exports.computePureFailureFlowNames = computePureFailureFlowNames;
9
+ exports.selectFailureScreenshots = selectFailureScreenshots;
10
+ const promises_1 = __importDefault(require("fs/promises"));
11
+ const path_1 = __importDefault(require("path"));
12
+ // Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
13
+ // The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
14
+ // `).png` suffix rather than scanning for parens.
15
+ const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
16
+ function parseFailureScreenshotFilename(filename) {
17
+ const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
18
+ if (!match) {
19
+ return null;
20
+ }
21
+ const capturedAtMs = Number(match[1]);
22
+ if (!Number.isFinite(capturedAtMs)) {
23
+ return null;
24
+ }
25
+ return {
26
+ flowName: match[2],
27
+ capturedAtMs,
28
+ };
29
+ }
30
+ // Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
31
+ // the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
32
+ // whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
33
+ // and skipped so a screenshot harvest can't affect the maestro step outcome.
34
+ async function harvestFailureScreenshotsAsync(args) {
35
+ // Gate dirs by mtime floored to the second, so a dir created within the same second as the
36
+ // attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
37
+ // prevents mis-attributing an earlier attempt's screenshot.
38
+ const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
39
+ let entries;
40
+ try {
41
+ entries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
42
+ }
43
+ catch (err) {
44
+ args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
45
+ return [];
46
+ }
47
+ const results = [];
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) {
50
+ continue;
51
+ }
52
+ const dirPath = path_1.default.join(args.testsDirectory, entry.name);
53
+ let filenames;
54
+ try {
55
+ if ((await promises_1.default.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
56
+ continue;
57
+ }
58
+ filenames = await promises_1.default.readdir(dirPath);
59
+ }
60
+ catch (err) {
61
+ args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
62
+ continue;
63
+ }
64
+ for (const filename of filenames) {
65
+ const parsed = parseFailureScreenshotFilename(filename);
66
+ // Re-check capture time per file: a dir's mtime can advance after this attempt began (or
67
+ // fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
68
+ // earlier attempt's screenshot to this one.
69
+ if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
70
+ continue;
71
+ }
72
+ results.push({
73
+ fileAbsPath: path_1.default.join(dirPath, filename),
74
+ displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
75
+ metadata: {
76
+ kind: 'maestro-test-screenshot',
77
+ flowName: parsed.flowName,
78
+ attemptIndex: args.attemptIndex,
79
+ capturedAtMs: parsed.capturedAtMs,
80
+ },
81
+ });
82
+ }
83
+ }
84
+ return results;
85
+ }
86
+ // Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
87
+ // is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
88
+ // name the same way before comparing — matching the website's own normalization.
89
+ function normalizeFlowName(name) {
90
+ return name.replace(/\//g, '_');
91
+ }
92
+ // A flow is a "pure failure" when it failed and never passed across all attempts. The website
93
+ // surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
94
+ // spend the per-job artifact budget on screenshots that are never shown — we keep only the final
95
+ // one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
96
+ // NOT pure: each of its failed attempts is a distinct failure and is kept.
97
+ function computePureFailureFlowNames(testCases) {
98
+ const passed = new Set();
99
+ const failed = new Set();
100
+ for (const testCase of testCases) {
101
+ const flowName = normalizeFlowName(testCase.name);
102
+ if (testCase.status === 'passed') {
103
+ passed.add(flowName);
104
+ }
105
+ else {
106
+ failed.add(flowName);
107
+ }
108
+ }
109
+ const pure = new Set();
110
+ for (const flowName of failed) {
111
+ if (!passed.has(flowName)) {
112
+ pure.add(flowName);
113
+ }
114
+ }
115
+ return pure;
116
+ }
117
+ // Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
118
+ // the final (highest attemptIndex) screenshot.
119
+ function selectFailureScreenshots(harvested, pureFailureFlowNames) {
120
+ const finalAttemptByPureFlow = new Map();
121
+ for (const shot of harvested) {
122
+ if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
123
+ continue;
124
+ }
125
+ const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
126
+ if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
127
+ finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
128
+ }
129
+ }
130
+ return harvested.filter(shot => {
131
+ const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
132
+ return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
133
+ });
134
+ }
@@ -1,2 +1,3 @@
1
1
  import { BuildFunction } from '@expo/steps';
2
- export declare function createMaestroTestsBuildFunction(): BuildFunction;
2
+ import { CustomBuildContext } from '../../customBuildContext';
3
+ export declare function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction;
@@ -8,10 +8,12 @@ const eas_build_job_1 = require("@expo/eas-build-job");
8
8
  const steps_1 = require("@expo/steps");
9
9
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
10
10
  const promises_1 = __importDefault(require("fs/promises"));
11
+ const os_1 = __importDefault(require("os"));
11
12
  const path_1 = __importDefault(require("path"));
12
13
  const zod_1 = require("zod");
13
14
  const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
14
15
  const maestroResultParser_1 = require("./maestroResultParser");
16
+ const maestroScreenshots_1 = require("./maestroScreenshots");
15
17
  const retry_1 = require("../../utils/retry");
16
18
  const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
17
19
  const RetriesSchema = zod_1.z.number().int().min(0).default(0);
@@ -56,7 +58,7 @@ function buildMaestroArgs(args) {
56
58
  out.push(...args.flow_path);
57
59
  return out;
58
60
  }
59
- function createMaestroTestsBuildFunction() {
61
+ function createMaestroTestsBuildFunction(ctx) {
60
62
  return new steps_1.BuildFunction({
61
63
  namespace: 'eas',
62
64
  id: 'maestro_tests',
@@ -171,6 +173,7 @@ function createMaestroTestsBuildFunction() {
171
173
  // trusted; we then fall through to dumb retry (re-run everything).
172
174
  let flowsToRun = flowPaths;
173
175
  let lastAttemptExitCode = null;
176
+ const harvested = [];
174
177
  const totalAttempts = retries + 1;
175
178
  for (let attempt = 0; attempt <= retries; attempt++) {
176
179
  const outputPath = outputFormat === 'junit'
@@ -187,6 +190,7 @@ function createMaestroTestsBuildFunction() {
187
190
  exclude_tags: excludeTags,
188
191
  });
189
192
  logger.info(`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`);
193
+ const attemptStartedAtMs = Date.now();
190
194
  try {
191
195
  await (0, turtle_spawn_1.default)('maestro', maestroArgs, {
192
196
  cwd: stepCtx.workingDirectory,
@@ -207,6 +211,17 @@ function createMaestroTestsBuildFunction() {
207
211
  throw new eas_build_job_1.SystemError('Unexpected spawn failure invoking maestro', { cause: err });
208
212
  }
209
213
  }
214
+ // Harvest this attempt's failure screenshots before any retry subsetting. Gated on
215
+ // junit: test-case-result rows (and therefore the summary icons) only exist for junit
216
+ // runs, so harvesting other formats would just create orphan artifacts the website hides.
217
+ if (outputFormat === 'junit') {
218
+ harvested.push(...(await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
219
+ testsDirectory,
220
+ capturedSinceMs: attemptStartedAtMs,
221
+ attemptIndex: attempt,
222
+ logger,
223
+ })));
224
+ }
210
225
  if (lastAttemptExitCode === 0 || attempt === retries) {
211
226
  break;
212
227
  }
@@ -276,6 +291,11 @@ function createMaestroTestsBuildFunction() {
276
291
  }
277
292
  }
278
293
  }
294
+ // Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
295
+ // screenshots most) still upload. Harvest only ran for junit, so guard the same way.
296
+ if (outputFormat === 'junit') {
297
+ await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
298
+ }
279
299
  // The retry loop exits via success (0), numeric status (retryable),
280
300
  // or throw (infra). A non-null non-zero status means the user's tests
281
301
  // failed every attempt.
@@ -285,3 +305,59 @@ function createMaestroTestsBuildFunction() {
285
305
  },
286
306
  });
287
307
  }
308
+ // Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
309
+ // artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
310
+ // the maestro test result. Caller guards on junit (harvest only runs for junit).
311
+ async function uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger, }) {
312
+ // Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
313
+ // attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
314
+ // Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
315
+ // test verdict (the whole step is verdict-neutral for screenshots).
316
+ let selected;
317
+ try {
318
+ const pureFailureFlowNames = (0, maestroScreenshots_1.computePureFailureFlowNames)(await (0, maestroResultParser_1.parseJUnitTestCases)(junitReportDirectory));
319
+ selected = (0, maestroScreenshots_1.selectFailureScreenshots)(harvested, pureFailureFlowNames);
320
+ }
321
+ catch (err) {
322
+ logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
323
+ return;
324
+ }
325
+ if (selected.length === 0) {
326
+ return;
327
+ }
328
+ // Cap well under www's 50-artifact-per-job limit.
329
+ const MAX_SCREENSHOT_UPLOADS = 30;
330
+ const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
331
+ if (selected.length > toUpload.length) {
332
+ logger.warn(`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`);
333
+ }
334
+ // Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
335
+ // non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
336
+ let safeScreenshotDir;
337
+ try {
338
+ safeScreenshotDir = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'eas-maestro-screenshots-'));
339
+ }
340
+ catch (err) {
341
+ logger.warn({ err }, 'Failed to create the failure-screenshot staging dir; skipping screenshot upload.');
342
+ return;
343
+ }
344
+ await Promise.all(toUpload.map(async (shot, index) => {
345
+ try {
346
+ // `index` disambiguates two flows that fail within the same millisecond of an attempt.
347
+ const safePath = path_1.default.join(safeScreenshotDir, `failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`);
348
+ await promises_1.default.copyFile(shot.fileAbsPath, safePath);
349
+ await ctx.runtimeApi.uploadArtifact({
350
+ artifact: {
351
+ type: eas_build_job_1.GenericArtifactType.OTHER,
352
+ name: shot.displayName,
353
+ paths: [safePath],
354
+ metadata: shot.metadata,
355
+ },
356
+ logger,
357
+ });
358
+ }
359
+ catch (err) {
360
+ logger.warn({ err }, 'Failed to upload failure screenshot.');
361
+ }
362
+ }));
363
+ }
@@ -16,7 +16,6 @@ const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
16
16
  const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
17
17
  const SRC_DIR = '/tmp/agent-device-src';
18
18
  const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
19
- const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
20
19
  const STARTUP_TIMEOUT_MS = 60_000;
21
20
  function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
22
21
  return new steps_1.BuildFunction({
@@ -43,8 +42,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
43
42
  const { runtimePlatform } = global;
44
43
  logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
45
44
  if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
46
- logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
47
- await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
45
+ await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
48
46
  }
49
47
  logger.info('Launching agent-device daemon.');
50
48
  const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
@@ -1,6 +1,7 @@
1
1
  import { type bunyan } from '@expo/logger';
2
2
  import { BuildFunction } from '@expo/steps';
3
3
  import { CustomBuildContext } from '../../customBuildContext';
4
+ export declare const MIN_ARGENT_REMOTE_SESSION_VERSION = "0.12.0";
4
5
  export declare function createStartArgentRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
5
6
  export declare function warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger, }: {
6
7
  packageVersion: string | undefined;
@@ -3,6 +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.MIN_ARGENT_REMOTE_SESSION_VERSION = void 0;
6
7
  exports.createStartArgentRemoteSessionBuildFunction = createStartArgentRemoteSessionBuildFunction;
7
8
  exports.warnIfArgentPackageVersionCannotBeVerified = warnIfArgentPackageVersionCannotBeVerified;
8
9
  const eas_build_job_1 = require("@expo/eas-build-job");
@@ -13,11 +14,12 @@ const node_os_1 = __importDefault(require("node:os"));
13
14
  const node_path_1 = __importDefault(require("node:path"));
14
15
  const semver_1 = __importDefault(require("semver"));
15
16
  const zod_1 = require("zod");
17
+ const argentArtifacts_1 = require("../utils/argentArtifacts");
16
18
  const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
17
19
  const ARGENT_PACKAGE_NAME = '@swmansion/argent';
18
- const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.11.0';
20
+ exports.MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
21
+ const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
19
22
  const ARGENT_STATE_FILE = node_path_1.default.join(node_os_1.default.homedir(), '.argent', 'tool-server.json');
20
- const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
21
23
  const STARTUP_TIMEOUT_MS = 60_000;
22
24
  const ArgentToolServerStateSchema = zod_1.z.object({
23
25
  port: zod_1.z.number(),
@@ -50,11 +52,12 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
50
52
  const { runtimePlatform } = global;
51
53
  logger.info(`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`);
52
54
  if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
53
- logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
54
- await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
55
+ await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
55
56
  }
56
57
  // Stale state from a previous run would mask the new server's port.
57
58
  await node_fs_1.default.promises.rm(ARGENT_STATE_FILE, { force: true });
59
+ logger.info('Enabling the Argent artifacts list endpoint flag.');
60
+ await (0, turtle_spawn_1.default)('bunx', [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG], { env, logger });
58
61
  logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
59
62
  const argentServer = (0, remoteDeviceRunSession_1.spawnDetached)({
60
63
  command: 'bunx',
@@ -88,6 +91,12 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
88
91
  throw new eas_build_job_1.SystemError(`${err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`}${output ? `\nArgent tool-server output:\n${output}` : ''}`);
89
92
  }
90
93
  logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
94
+ void (0, argentArtifacts_1.pollArgentArtifactsForUploadAsync)(ctx, {
95
+ deviceRunSessionId,
96
+ toolsUrl: `http://127.0.0.1:${toolServerPort}`,
97
+ toolsAuthToken: toolServerToken,
98
+ logger,
99
+ });
91
100
  const publicToolsUrl = await (0, remoteDeviceRunSession_1.startNgrokTunnelAsync)({
92
101
  port: toolServerPort,
93
102
  subdomainPrefix: 'argent',
@@ -132,15 +141,15 @@ function warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger, })
132
141
  }
133
142
  const validVersion = semver_1.default.valid(packageVersion);
134
143
  if (!validVersion) {
135
- logger.warn(`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
144
+ logger.warn(`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${exports.MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
136
145
  `but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
137
146
  `Continuing and letting bunx resolve it.`);
138
147
  return;
139
148
  }
140
- if (semver_1.default.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
141
- throw new eas_build_job_1.SystemError(`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
149
+ if (semver_1.default.lt(validVersion, exports.MIN_ARGENT_REMOTE_SESSION_VERSION)) {
150
+ throw new eas_build_job_1.SystemError(`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${exports.MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
142
151
  `The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
143
- `Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`);
152
+ `Use "latest" or pass an exact version >= ${exports.MIN_ARGENT_REMOTE_SESSION_VERSION}.`);
144
153
  }
145
154
  }
146
155
  function parseArgentToolServerState(raw) {
@@ -1,13 +1,8 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.createStartServeSimRemoteSessionBuildFunction = createStartServeSimRemoteSessionBuildFunction;
7
4
  const steps_1 = require("@expo/steps");
8
- const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
9
5
  const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
10
- const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
11
6
  const STARTUP_TIMEOUT_MS = 60_000;
12
7
  function createStartServeSimRemoteSessionBuildFunction(ctx) {
13
8
  return new steps_1.BuildFunction({
@@ -20,8 +15,7 @@ function createStartServeSimRemoteSessionBuildFunction(ctx) {
20
15
  const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
21
16
  const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
22
17
  logger.info('Starting serve-sim remote session.');
23
- logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
24
- await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
18
+ await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
25
19
  const { previewUrl, streamUrl } = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
26
20
  baseDomain: ngrokTunnelDomain,
27
21
  env,
@@ -62,6 +62,11 @@ function createUploadArtifactBuildFunction(ctx) {
62
62
  required: false,
63
63
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
64
64
  }),
65
+ steps_1.BuildStepInput.createProvider({
66
+ id: 'metadata',
67
+ required: false,
68
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.JSON,
69
+ }),
65
70
  ],
66
71
  outputProviders: [
67
72
  steps_1.BuildStepOutput.createProvider({
@@ -93,7 +98,7 @@ function createUploadArtifactBuildFunction(ctx) {
93
98
  }
94
99
  throw result.reason;
95
100
  });
96
- const artifact = {
101
+ const artifactSpec = {
97
102
  type: parseArtifactTypeInput({
98
103
  platform: ctx.job.platform,
99
104
  inputValue: `${inputs.type.value ?? ''}`,
@@ -101,11 +106,14 @@ function createUploadArtifactBuildFunction(ctx) {
101
106
  paths: artifactPaths,
102
107
  name: (inputs.name.value || inputs.key.value),
103
108
  };
109
+ const artifact = (0, eas_build_job_1.isGenericArtifact)(artifactSpec)
110
+ ? {
111
+ ...artifactSpec,
112
+ metadata: inputs.metadata.value,
113
+ }
114
+ : artifactSpec;
104
115
  try {
105
- const { artifactId } = await ctx.runtimeApi.uploadArtifact({
106
- artifact,
107
- logger,
108
- });
116
+ const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
109
117
  if (artifactId) {
110
118
  outputs.artifact_id.set(artifactId);
111
119
  }
@@ -0,0 +1,28 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ import { z } from 'zod';
3
+ import { CustomBuildContext } from '../../customBuildContext';
4
+ declare const ArgentArtifactSchema: z.ZodObject<{
5
+ id: z.ZodString;
6
+ filename: z.ZodString;
7
+ mimeType: z.ZodString;
8
+ isDirectory: z.ZodOptional<z.ZodBoolean>;
9
+ }, z.core.$strip>;
10
+ type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
11
+ export declare function pollArgentArtifactsForUploadAsync(ctx: CustomBuildContext, { deviceRunSessionId, toolsUrl, toolsAuthToken, logger, }: {
12
+ deviceRunSessionId: string;
13
+ toolsUrl: string;
14
+ toolsAuthToken?: string;
15
+ logger: bunyan;
16
+ }): Promise<never>;
17
+ export declare function listArgentArtifactsAsync({ toolsUrl, toolsAuthToken, }: {
18
+ toolsUrl: string;
19
+ toolsAuthToken?: string;
20
+ }): Promise<ArgentArtifact[]>;
21
+ export declare function uploadArgentArtifactAsync(ctx: CustomBuildContext, { deviceRunSessionId, toolsUrl, toolsAuthToken, artifact, logger, }: {
22
+ deviceRunSessionId: string;
23
+ toolsUrl: string;
24
+ toolsAuthToken?: string;
25
+ artifact: ArgentArtifact;
26
+ logger: bunyan;
27
+ }): Promise<void>;
28
+ export {};
@@ -0,0 +1,119 @@
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.pollArgentArtifactsForUploadAsync = pollArgentArtifactsForUploadAsync;
7
+ exports.listArgentArtifactsAsync = listArgentArtifactsAsync;
8
+ exports.uploadArgentArtifactAsync = uploadArgentArtifactAsync;
9
+ const eas_build_job_1 = require("@expo/eas-build-job");
10
+ const node_fs_1 = require("node:fs");
11
+ const promises_1 = require("node:fs/promises");
12
+ const node_fetch_1 = __importDefault(require("node-fetch"));
13
+ const node_os_1 = __importDefault(require("node:os"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const promises_2 = require("node:stream/promises");
16
+ const zod_1 = require("zod");
17
+ const sentry_1 = require("../../sentry");
18
+ const artifacts_1 = require("../../utils/artifacts");
19
+ const retry_1 = require("../../utils/retry");
20
+ const deviceRunSessionArtifacts_1 = require("./deviceRunSessionArtifacts");
21
+ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
22
+ const ArgentArtifactSchema = zod_1.z.object({
23
+ id: zod_1.z.string(),
24
+ filename: zod_1.z.string(),
25
+ mimeType: zod_1.z.string(),
26
+ isDirectory: zod_1.z.boolean().optional(),
27
+ });
28
+ const ArgentArtifactsListResponseSchema = zod_1.z.object({
29
+ artifacts: zod_1.z.array(ArgentArtifactSchema),
30
+ });
31
+ async function pollArgentArtifactsForUploadAsync(ctx, { deviceRunSessionId, toolsUrl, toolsAuthToken, logger, }) {
32
+ logger.info('Started polling Argent tool-server for artifacts.');
33
+ const seenArtifactIds = new Set();
34
+ let listArtifactsErrorCount = 0;
35
+ for (;;) {
36
+ try {
37
+ const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
38
+ listArtifactsErrorCount = 0;
39
+ for (const artifact of artifacts) {
40
+ if (seenArtifactIds.has(artifact.id)) {
41
+ continue;
42
+ }
43
+ seenArtifactIds.add(artifact.id);
44
+ void uploadArgentArtifactAsync(ctx, {
45
+ deviceRunSessionId,
46
+ toolsUrl,
47
+ toolsAuthToken,
48
+ artifact,
49
+ logger,
50
+ }).catch(err => {
51
+ const error = err instanceof Error ? err : new Error(String(err));
52
+ sentry_1.Sentry.capture('Could not upload Argent remote session artifact', error);
53
+ logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
54
+ });
55
+ }
56
+ }
57
+ catch (err) {
58
+ const error = err instanceof Error ? err : new Error(String(err));
59
+ listArtifactsErrorCount += 1;
60
+ if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
61
+ sentry_1.Sentry.capture('Could not list Argent remote session artifacts', error);
62
+ logger.warn({ err: error, failedArtifactListCount: listArtifactsErrorCount }, 'Could not list Argent remote session artifacts.');
63
+ }
64
+ }
65
+ await (0, retry_1.sleepAsync)(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
66
+ }
67
+ }
68
+ async function listArgentArtifactsAsync({ toolsUrl, toolsAuthToken, }) {
69
+ const response = await (0, node_fetch_1.default)(new URL('/artifacts', toolsUrl).toString(), {
70
+ headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
71
+ });
72
+ if (!response.ok) {
73
+ throw new eas_build_job_1.SystemError(`Failed to list Argent artifacts: ${response.status} ${response.statusText}`);
74
+ }
75
+ const result = ArgentArtifactsListResponseSchema.safeParse(await response.json());
76
+ if (!result.success) {
77
+ throw new eas_build_job_1.SystemError(`Invalid Argent artifacts response: ${result.error.message}`);
78
+ }
79
+ return result.data.artifacts;
80
+ }
81
+ async function uploadArgentArtifactAsync(ctx, { deviceRunSessionId, toolsUrl, toolsAuthToken, artifact, logger, }) {
82
+ const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
83
+ logger.info(`Downloading artifact ${filename}.`);
84
+ const temporaryDirectory = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), 'argent-artifact-'));
85
+ try {
86
+ const temporaryArtifactPath = node_path_1.default.join(temporaryDirectory, node_path_1.default.basename(filename));
87
+ await downloadArgentArtifactToFileAsync({
88
+ artifact,
89
+ toolsUrl,
90
+ toolsAuthToken,
91
+ destinationPath: temporaryArtifactPath,
92
+ });
93
+ const { size } = await (0, promises_1.stat)(temporaryArtifactPath);
94
+ logger.info(`Uploading artifact ${filename} (${(0, artifacts_1.formatBytes)(size)}).`);
95
+ await (0, deviceRunSessionArtifacts_1.uploadDeviceRunSessionArtifactAsync)(ctx, {
96
+ deviceRunSessionId,
97
+ artifactId: artifact.id,
98
+ name: `${filename} (${artifact.id})`,
99
+ filename,
100
+ size,
101
+ stream: (0, node_fs_1.createReadStream)(temporaryArtifactPath),
102
+ });
103
+ }
104
+ finally {
105
+ await (0, promises_1.rm)(temporaryDirectory, { recursive: true, force: true });
106
+ }
107
+ }
108
+ async function downloadArgentArtifactToFileAsync({ artifact, toolsUrl, toolsAuthToken, destinationPath, }) {
109
+ const response = await (0, node_fetch_1.default)(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
110
+ headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
111
+ });
112
+ if (!response.ok) {
113
+ throw new eas_build_job_1.SystemError(`Failed to download Argent artifact ${artifact.id}: ${response.status} ${response.statusText}`);
114
+ }
115
+ if (!response.body) {
116
+ throw new eas_build_job_1.SystemError(`Argent artifact ${artifact.id} response did not include a readable body.`);
117
+ }
118
+ await (0, promises_2.pipeline)(response.body, (0, node_fs_1.createWriteStream)(destinationPath));
119
+ }
@@ -0,0 +1,9 @@
1
+ import { CustomBuildContext } from '../../customBuildContext';
2
+ export declare function uploadDeviceRunSessionArtifactAsync(ctx: CustomBuildContext, { deviceRunSessionId, artifactId, name, filename, size, stream, }: {
3
+ deviceRunSessionId: string;
4
+ artifactId: string;
5
+ name: string;
6
+ filename: string;
7
+ size: number;
8
+ stream: NodeJS.ReadableStream;
9
+ }): Promise<void>;
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.uploadDeviceRunSessionArtifactAsync = uploadDeviceRunSessionArtifactAsync;
37
+ const eas_build_job_1 = require("@expo/eas-build-job");
38
+ const gql_tada_1 = require("gql.tada");
39
+ const node_fetch_1 = __importStar(require("node-fetch"));
40
+ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
41
+ mutation CreateDeviceRunSessionArtifactUploadSession(
42
+ $deviceRunSessionId: ID!
43
+ $input: CreateDeviceRunSessionArtifactUploadSessionInput!
44
+ ) {
45
+ deviceRunSession {
46
+ createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
47
+ uploadSession {
48
+ url
49
+ headers
50
+ }
51
+ }
52
+ }
53
+ }
54
+ `);
55
+ async function uploadDeviceRunSessionArtifactAsync(ctx, { deviceRunSessionId, artifactId, name, filename, size, stream, }) {
56
+ const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
57
+ deviceRunSessionId,
58
+ artifactId,
59
+ name,
60
+ filename,
61
+ size,
62
+ });
63
+ const response = await (0, node_fetch_1.default)(uploadSession.url, {
64
+ method: 'PUT',
65
+ headers: new node_fetch_1.Headers(uploadSession.headers),
66
+ body: stream,
67
+ });
68
+ if (!response.ok) {
69
+ throw new eas_build_job_1.SystemError(`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`, { cause: response });
70
+ }
71
+ }
72
+ async function createDeviceRunSessionArtifactUploadSessionAsync(ctx, { deviceRunSessionId, artifactId, name, filename, size, }) {
73
+ const result = await ctx.graphqlClient
74
+ .mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
75
+ deviceRunSessionId,
76
+ input: {
77
+ name,
78
+ filename,
79
+ size,
80
+ },
81
+ })
82
+ .toPromise();
83
+ if (result.error) {
84
+ throw new eas_build_job_1.SystemError(`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`, { cause: result.error });
85
+ }
86
+ return result.data.deviceRunSession.createArtifactUploadSession.uploadSession;
87
+ }
@@ -11,6 +11,10 @@ declare const TurnIceServersSchema: z.ZodArray<z.ZodObject<{
11
11
  credential: z.ZodOptional<z.ZodString>;
12
12
  }, z.core.$strip>>;
13
13
  export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
14
+ export declare function selectXcodeDeveloperDirectoryAsync({ env, logger, }: {
15
+ env: BuildStepEnv;
16
+ logger: bunyan;
17
+ }): Promise<void>;
14
18
  /**
15
19
  * Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
16
20
  * credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.getDeviceRunSessionIdOrThrow = getDeviceRunSessionIdOrThrow;
40
40
  exports.getNgrokTunnelDomainOrThrow = getNgrokTunnelDomainOrThrow;
41
41
  exports.getNgrokAuthtokenOrThrow = getNgrokAuthtokenOrThrow;
42
+ exports.selectXcodeDeveloperDirectoryAsync = selectXcodeDeveloperDirectoryAsync;
42
43
  exports.turnIceServersToServeSimArgs = turnIceServersToServeSimArgs;
43
44
  exports.fetchServeSimTurnArgsAsync = fetchServeSimTurnArgsAsync;
44
45
  exports.uploadRemoteSessionConfigAsync = uploadRemoteSessionConfigAsync;
@@ -47,6 +48,7 @@ exports.startServeSimWithTunnelAsync = startServeSimWithTunnelAsync;
47
48
  exports.startNgrokTunnelAsync = startNgrokTunnelAsync;
48
49
  exports.waitForFileAsync = waitForFileAsync;
49
50
  const eas_build_job_1 = require("@expo/eas-build-job");
51
+ const steps_1 = require("@expo/steps");
50
52
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
51
53
  const ngrok = __importStar(require("@ngrok/ngrok"));
52
54
  const gql_tada_1 = require("gql.tada");
@@ -57,6 +59,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
57
59
  const sentry_1 = require("../../sentry");
58
60
  const retry_1 = require("../../utils/retry");
59
61
  const turtleFetch_1 = require("../../utils/turtleFetch");
62
+ const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
60
63
  const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
61
64
  mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
62
65
  deviceRunSession {
@@ -102,6 +105,18 @@ const TurnIceServersSchema = zod_1.z.array(zod_1.z.object({
102
105
  username: zod_1.z.string().optional(),
103
106
  credential: zod_1.z.string().optional(),
104
107
  }));
108
+ async function selectXcodeDeveloperDirectoryAsync({ env, logger, }) {
109
+ if (process.env.ENVIRONMENT === 'development') {
110
+ logger.info('Job running outside of EAS, not selecting Xcode developer directory.');
111
+ return;
112
+ }
113
+ logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
114
+ await (0, steps_1.spawnAsync)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], {
115
+ env,
116
+ logger,
117
+ stdio: ['ignore', 'pipe', 'pipe'],
118
+ });
119
+ }
105
120
  const TurnIceServersResponseSchema = zod_1.z.object({
106
121
  data: zod_1.z.object({
107
122
  iceServers: TurnIceServersSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "20.3.0",
3
+ "version": "20.4.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -100,5 +100,5 @@
100
100
  "typescript": "^5.5.4",
101
101
  "uuid": "^9.0.1"
102
102
  },
103
- "gitHead": "490457976c996d06447e6442fbd1aec1ace09f1b"
103
+ "gitHead": "f867b7b9f2641fe1db6924711cb705e663d40397"
104
104
  }