@expo/build-tools 22.0.0 → 22.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/builders/android.js +2 -2
  2. package/dist/builders/ios.js +2 -2
  3. package/dist/steps/easFunctions.js +6 -0
  4. package/dist/steps/functions/downloadBuild.d.ts +9 -2
  5. package/dist/steps/functions/downloadBuild.js +83 -14
  6. package/dist/steps/functions/installBuild.d.ts +12 -0
  7. package/dist/steps/functions/installBuild.js +85 -0
  8. package/dist/steps/functions/installMaestro.d.ts +6 -1
  9. package/dist/steps/functions/installMaestro.js +107 -33
  10. package/dist/steps/functions/launchApplication.d.ts +10 -0
  11. package/dist/steps/functions/launchApplication.js +67 -0
  12. package/dist/steps/functions/maestroResultParser.d.ts +30 -0
  13. package/dist/steps/functions/maestroResultParser.js +78 -5
  14. package/dist/steps/functions/maestroScreenshots.d.ts +6 -0
  15. package/dist/steps/functions/maestroScreenshots.js +123 -0
  16. package/dist/steps/functions/maestroTests.js +187 -69
  17. package/dist/steps/functions/readIpaInfo.d.ts +1 -0
  18. package/dist/steps/functions/readIpaInfo.js +2 -0
  19. package/dist/steps/functions/repack.d.ts +5 -2
  20. package/dist/steps/functions/repack.js +50 -2
  21. package/dist/steps/functions/restoreBuildCache.d.ts +3 -3
  22. package/dist/steps/functions/restoreBuildCache.js +21 -7
  23. package/dist/steps/functions/saveBuildCache.d.ts +3 -3
  24. package/dist/steps/functions/saveBuildCache.js +21 -6
  25. package/dist/steps/functions/saveCache.js +3 -0
  26. package/dist/steps/functions/startAgentDeviceRemoteSession.js +7 -0
  27. package/dist/steps/functions/startAppiumRemoteSession.d.ts +17 -0
  28. package/dist/steps/functions/startAppiumRemoteSession.js +244 -0
  29. package/dist/steps/functions/startArgentRemoteSession.js +7 -0
  30. package/dist/steps/functions/startServeSimRemoteSession.js +17 -1
  31. package/dist/steps/functions/uploadToAsc.js +11 -1
  32. package/dist/steps/utils/appiumEvents.d.ts +10 -0
  33. package/dist/steps/utils/appiumEvents.js +175 -0
  34. package/dist/steps/utils/ios/AscApiClient.d.ts +1 -0
  35. package/dist/steps/utils/ios/AscApiUtils.d.ts +11 -2
  36. package/dist/steps/utils/ios/AscApiUtils.js +36 -2
  37. package/dist/steps/utils/remoteDeviceRunSession.d.ts +6 -3
  38. package/dist/steps/utils/remoteDeviceRunSession.js +81 -52
  39. package/dist/utils/AndroidEmulatorUtils.d.ts +1 -0
  40. package/dist/utils/AndroidEmulatorUtils.js +46 -4
  41. package/dist/utils/cacheKey.d.ts +8 -2
  42. package/dist/utils/cacheKey.js +12 -8
  43. package/package.json +6 -4
@@ -29,6 +29,11 @@ function createSaveBuildCacheFunction(evictUsedBefore) {
29
29
  required: false,
30
30
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
31
31
  }),
32
+ steps_1.BuildStepInput.createProvider({
33
+ id: 'simulator',
34
+ required: false,
35
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
36
+ }),
32
37
  ],
33
38
  fn: async (stepCtx, { env, inputs }) => {
34
39
  const { logger } = stepCtx;
@@ -38,10 +43,18 @@ function createSaveBuildCacheFunction(evictUsedBefore) {
38
43
  if (!platform || ![eas_build_job_1.Platform.ANDROID, eas_build_job_1.Platform.IOS].includes(platform)) {
39
44
  throw new Error(`Unsupported platform: ${platform}. Platform must be "${eas_build_job_1.Platform.ANDROID}" or "${eas_build_job_1.Platform.IOS}"`);
40
45
  }
46
+ const target = platform === eas_build_job_1.Platform.IOS
47
+ ? {
48
+ platform,
49
+ simulator: inputs.simulator.value ??
50
+ (stepCtx.global.staticContext.job.platform === eas_build_job_1.Platform.IOS &&
51
+ stepCtx.global.staticContext.job.simulator === true),
52
+ }
53
+ : { platform };
41
54
  await saveCcacheAsync({
42
55
  logger,
43
56
  workingDirectory,
44
- platform,
57
+ target,
45
58
  evictUsedBefore,
46
59
  env,
47
60
  secrets: stepCtx.global.staticContext.job.secrets,
@@ -57,7 +70,7 @@ function createSaveBuildCacheFunction(evictUsedBefore) {
57
70
  },
58
71
  });
59
72
  }
60
- async function saveCcacheAsync({ logger, workingDirectory, platform, evictUsedBefore, env, secrets, }) {
73
+ async function saveCcacheAsync({ logger, workingDirectory, target, evictUsedBefore, env, secrets, }) {
61
74
  const enabled = env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
62
75
  if (!enabled) {
63
76
  return;
@@ -72,7 +85,7 @@ async function saveCcacheAsync({ logger, workingDirectory, platform, evictUsedBe
72
85
  return;
73
86
  }
74
87
  try {
75
- const cacheKey = await (0, cacheKey_1.generateDefaultBuildCacheKeyAsync)(workingDirectory, platform);
88
+ const cacheKey = await (0, cacheKey_1.generateDefaultBuildCacheKeyAsync)(workingDirectory, target);
76
89
  logger.info(`Saving cache key: ${cacheKey}`);
77
90
  const jobId = (0, nullthrows_1.default)(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
78
91
  const robotAccessToken = (0, nullthrows_1.default)(secrets?.robotAccessToken, 'Robot access token is required for cache operations');
@@ -104,7 +117,7 @@ async function saveCcacheAsync({ logger, workingDirectory, platform, evictUsedBe
104
117
  key: cacheKey,
105
118
  paths: [cachePath],
106
119
  size,
107
- platform,
120
+ platform: target.platform,
108
121
  });
109
122
  }
110
123
  catch (err) {
@@ -117,6 +130,7 @@ async function saveGradleCacheAsync({ logger, workingDirectory, env, secrets, })
117
130
  }
118
131
  const gradleCachesPath = path_1.default.join(os_1.default.homedir(), '.gradle', 'caches');
119
132
  const buildCachePath = path_1.default.join(gradleCachesPath, 'build-cache-1');
133
+ const journalPath = path_1.default.join(gradleCachesPath, 'journal-1');
120
134
  try {
121
135
  await fs_1.default.promises.access(buildCachePath);
122
136
  }
@@ -130,9 +144,10 @@ async function saveGradleCacheAsync({ logger, workingDirectory, env, secrets, })
130
144
  const jobId = (0, nullthrows_1.default)(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
131
145
  const robotAccessToken = (0, nullthrows_1.default)(secrets?.robotAccessToken, 'Robot access token is required for cache operations');
132
146
  const expoApiServerURL = (0, nullthrows_1.default)(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
147
+ await fs_1.default.promises.mkdir(journalPath, { recursive: true });
133
148
  logger.info('Compressing Gradle build cache...');
134
149
  const { archivePath } = await (0, saveCache_1.compressCacheAsync)({
135
- paths: [buildCachePath],
150
+ paths: [buildCachePath, journalPath],
136
151
  workingDirectory: gradleCachesPath,
137
152
  verbose: env.EXPO_DEBUG === '1',
138
153
  logger,
@@ -146,7 +161,7 @@ async function saveGradleCacheAsync({ logger, workingDirectory, env, secrets, })
146
161
  robotAccessToken,
147
162
  archivePath,
148
163
  key: cacheKey,
149
- paths: [buildCachePath],
164
+ paths: [buildCachePath, journalPath],
150
165
  size,
151
166
  platform: eas_build_job_1.Platform.ANDROID,
152
167
  });
@@ -303,7 +303,10 @@ async function compressCacheAsync({ paths, workingDirectory, verbose, logger, })
303
303
  for (const { absolutePath, archivePath: targetRelativePath } of allFiles) {
304
304
  const targetPath = path_1.default.join(tempDir, targetRelativePath);
305
305
  await fs_1.default.promises.mkdir(path_1.default.dirname(targetPath), { recursive: true });
306
+ // We want to keep source timestamps since Gradle may check them when pruning cache.
307
+ const { atime, mtime } = await fs_1.default.promises.stat(absolutePath);
306
308
  await fs_1.default.promises.copyFile(absolutePath, targetPath);
309
+ await fs_1.default.promises.utimes(targetPath, atime, mtime);
307
310
  if (verbose) {
308
311
  logger.info(`- ${targetRelativePath}`);
309
312
  }
@@ -42,6 +42,11 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
42
42
  required: false,
43
43
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
44
44
  }),
45
+ steps_1.BuildStepInput.createProvider({
46
+ id: 'max_duration_seconds',
47
+ required: false,
48
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
49
+ }),
45
50
  ],
46
51
  fn: async ({ logger, global }, { inputs, env, signal }) => {
47
52
  // Fail fast before any expensive setup if the injected env
@@ -54,6 +59,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
54
59
  const packageVersion = inputs.package_version.value;
55
60
  // A missing or non-positive value disables the idle timeout (opt-in feature).
56
61
  const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value;
62
+ const maxDurationSeconds = inputs.max_duration_seconds?.value;
57
63
  const { runtimePlatform } = global;
58
64
  logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
59
65
  if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
@@ -115,6 +121,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
115
121
  ctx,
116
122
  deviceRunSessionId,
117
123
  logger,
124
+ maxDurationSeconds,
118
125
  signal,
119
126
  idleTimeout: maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
120
127
  ? {
@@ -0,0 +1,17 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ import { BuildFunction, BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
3
+ import { type CustomBuildContext } from '../../customBuildContext';
4
+ export declare function createStartAppiumRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
5
+ export declare function resolveAppium3VersionSpec(packageVersion: string | undefined): string;
6
+ type AppiumDevice = {
7
+ platformName: 'iOS' | 'Android';
8
+ automationName: 'XCUITest' | 'UiAutomator2';
9
+ driverName: 'xcuitest' | 'uiautomator2';
10
+ udid: string;
11
+ };
12
+ export declare function resolveAppiumDeviceAsync({ runtimePlatform, env, logger, }: {
13
+ runtimePlatform: BuildRuntimePlatform;
14
+ env: BuildStepEnv;
15
+ logger: bunyan;
16
+ }): Promise<AppiumDevice>;
17
+ export {};
@@ -0,0 +1,244 @@
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.createStartAppiumRemoteSessionBuildFunction = createStartAppiumRemoteSessionBuildFunction;
7
+ exports.resolveAppium3VersionSpec = resolveAppium3VersionSpec;
8
+ exports.resolveAppiumDeviceAsync = resolveAppiumDeviceAsync;
9
+ const eas_build_job_1 = require("@expo/eas-build-job");
10
+ const steps_1 = require("@expo/steps");
11
+ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_os_1 = __importDefault(require("node:os"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const semver_1 = __importDefault(require("semver"));
16
+ const zod_1 = require("zod");
17
+ const AndroidEmulatorUtils_1 = require("../../utils/AndroidEmulatorUtils");
18
+ const IosSimulatorUtils_1 = require("../../utils/IosSimulatorUtils");
19
+ const retry_1 = require("../../utils/retry");
20
+ const turtleFetch_1 = require("../../utils/turtleFetch");
21
+ const appiumEvents_1 = require("../utils/appiumEvents");
22
+ const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
23
+ const APPIUM_HOST = '127.0.0.1';
24
+ const APPIUM_PORT = 4723;
25
+ const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
26
+ const DEFAULT_APPIUM_VERSION = '^3';
27
+ const AppiumInstalledDriversSchema = zod_1.z.record(zod_1.z.string(), zod_1.z.object({ installed: zod_1.z.boolean().optional() }).passthrough());
28
+ function createStartAppiumRemoteSessionBuildFunction(ctx) {
29
+ return new steps_1.BuildFunction({
30
+ namespace: 'eas',
31
+ id: 'start_appium_remote_session',
32
+ name: 'Start Appium remote session',
33
+ __metricsId: 'eas/start_appium_remote_session',
34
+ inputProviders: [
35
+ steps_1.BuildStepInput.createProvider({
36
+ id: 'package_version',
37
+ required: false,
38
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
39
+ }),
40
+ steps_1.BuildStepInput.createProvider({
41
+ id: 'max_idle_time_minutes',
42
+ required: false,
43
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
44
+ }),
45
+ ],
46
+ fn: async ({ logger, global }, { inputs, env, signal }) => {
47
+ const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
48
+ const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
49
+ const ngrokAuthtoken = (0, remoteDeviceRunSession_1.getNgrokAuthtokenOrThrow)(env);
50
+ const packageVersion = inputs.package_version.value;
51
+ const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value;
52
+ const { runtimePlatform } = global;
53
+ const versionSpec = resolveAppium3VersionSpec(packageVersion);
54
+ logger.info(`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`);
55
+ const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
56
+ const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
57
+ versionSpec,
58
+ driverName: device.driverName,
59
+ env,
60
+ logger,
61
+ });
62
+ const appiumProcess = (0, remoteDeviceRunSession_1.spawnDetached)({
63
+ command: appiumBinPath,
64
+ args: [
65
+ '--address',
66
+ APPIUM_HOST,
67
+ '--port',
68
+ String(APPIUM_PORT),
69
+ '--base-path',
70
+ '/',
71
+ '--log-level',
72
+ 'error',
73
+ // Appium 3 gates session listing (GET /appium/sessions) behind the
74
+ // session_discovery insecure feature. We rely on it to poll for
75
+ // Appium Event Timings, so enable it for all drivers.
76
+ '--allow-insecure',
77
+ '*:session_discovery',
78
+ '--default-capabilities',
79
+ JSON.stringify({ 'appium:eventTimings': true }),
80
+ ],
81
+ env: appiumEnv,
82
+ });
83
+ try {
84
+ await waitForAppiumReadyAsync({ appiumProcess, logger });
85
+ }
86
+ catch (error) {
87
+ await appiumProcess.stopAsync();
88
+ await node_fs_1.default.promises.rm(appiumHome, { recursive: true, force: true });
89
+ throw error;
90
+ }
91
+ const eventCollection = await (0, appiumEvents_1.startAppiumEventCollectionAsync)({
92
+ ctx,
93
+ deviceRunSessionId,
94
+ appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
95
+ logger,
96
+ });
97
+ let appiumTunnel;
98
+ let serveSim;
99
+ try {
100
+ appiumTunnel = await (0, remoteDeviceRunSession_1.startNgrokTunnelAsync)({
101
+ port: APPIUM_PORT,
102
+ subdomainPrefix: 'appium',
103
+ baseDomain: ngrokTunnelDomain,
104
+ authtoken: ngrokAuthtoken,
105
+ logger,
106
+ });
107
+ switch (runtimePlatform) {
108
+ case steps_1.BuildRuntimePlatform.DARWIN:
109
+ serveSim = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
110
+ baseDomain: ngrokTunnelDomain,
111
+ env,
112
+ logger,
113
+ timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
114
+ });
115
+ break;
116
+ case steps_1.BuildRuntimePlatform.LINUX:
117
+ break;
118
+ }
119
+ await (0, remoteDeviceRunSession_1.uploadRemoteSessionConfigAsync)({
120
+ ctx,
121
+ deviceRunSessionId,
122
+ remoteConfig: {
123
+ appiumUrl: appiumTunnel.url,
124
+ capabilities: {
125
+ platformName: device.platformName,
126
+ 'appium:automationName': device.automationName,
127
+ 'appium:udid': device.udid,
128
+ },
129
+ ...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
130
+ },
131
+ logger,
132
+ });
133
+ await (0, remoteDeviceRunSession_1.waitForDeviceRunSessionStoppedAsync)({
134
+ ctx,
135
+ deviceRunSessionId,
136
+ logger,
137
+ signal,
138
+ idleTimeout: maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
139
+ ? {
140
+ maxIdleTimeMinutes,
141
+ getLastEventObservedAt: eventCollection.getLastEventObservedAt,
142
+ }
143
+ : undefined,
144
+ });
145
+ }
146
+ finally {
147
+ if (serveSim) {
148
+ await serveSim.stopAsync();
149
+ }
150
+ if (appiumTunnel) {
151
+ await appiumTunnel.stopAsync();
152
+ }
153
+ await eventCollection.stopAsync();
154
+ await appiumProcess.stopAsync();
155
+ await node_fs_1.default.promises.rm(appiumHome, { recursive: true, force: true });
156
+ }
157
+ },
158
+ });
159
+ }
160
+ function resolveAppium3VersionSpec(packageVersion) {
161
+ const versionSpec = packageVersion ?? DEFAULT_APPIUM_VERSION;
162
+ const range = semver_1.default.validRange(versionSpec);
163
+ if (!range || !semver_1.default.subset(range, '>=3.0.0 <4.0.0-0')) {
164
+ throw new eas_build_job_1.SystemError(`Appium 3 is required for EAS Simulator sessions. Received package version "${versionSpec}".`);
165
+ }
166
+ return versionSpec;
167
+ }
168
+ async function resolveAppiumDeviceAsync({ runtimePlatform, env, logger, }) {
169
+ switch (runtimePlatform) {
170
+ case steps_1.BuildRuntimePlatform.DARWIN: {
171
+ await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
172
+ const [bootedDevice] = await IosSimulatorUtils_1.IosSimulatorUtils.getAvailableDevicesAsync({
173
+ env,
174
+ filter: 'booted',
175
+ });
176
+ if (!bootedDevice) {
177
+ throw new eas_build_job_1.SystemError('Could not find a booted iOS simulator for the Appium session.');
178
+ }
179
+ return {
180
+ platformName: 'iOS',
181
+ automationName: 'XCUITest',
182
+ driverName: 'xcuitest',
183
+ udid: bootedDevice.udid,
184
+ };
185
+ }
186
+ case steps_1.BuildRuntimePlatform.LINUX: {
187
+ const attachedDevices = await AndroidEmulatorUtils_1.AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
188
+ const bootedDevice = attachedDevices.find(device => device.state === 'device');
189
+ if (!bootedDevice) {
190
+ throw new eas_build_job_1.SystemError('Could not find a booted Android emulator for the Appium session.');
191
+ }
192
+ return {
193
+ platformName: 'Android',
194
+ automationName: 'UiAutomator2',
195
+ driverName: 'uiautomator2',
196
+ udid: bootedDevice.serialId,
197
+ };
198
+ }
199
+ }
200
+ }
201
+ async function installAppiumAsync({ versionSpec, driverName, env, logger, }) {
202
+ const appiumHome = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-appium-home-'));
203
+ await node_fs_1.default.promises.writeFile(node_path_1.default.join(appiumHome, 'package.json'), `${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`);
204
+ const appiumEnv = { ...env, APPIUM_HOME: appiumHome };
205
+ const appiumBinPath = node_path_1.default.join(appiumHome, 'node_modules', '.bin', 'appium');
206
+ logger.info(`Installing appium@${versionSpec}.`);
207
+ await (0, turtle_spawn_1.default)('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
208
+ env: appiumEnv,
209
+ logger,
210
+ });
211
+ const { stdout } = await (0, turtle_spawn_1.default)(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
212
+ env: appiumEnv,
213
+ stdio: 'pipe',
214
+ });
215
+ const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
216
+ if (installedDrivers[driverName]?.installed) {
217
+ logger.info(`Updating the installed Appium ${driverName} driver.`);
218
+ await (0, turtle_spawn_1.default)(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
219
+ }
220
+ else {
221
+ logger.info(`Installing the Appium ${driverName} driver.`);
222
+ await (0, turtle_spawn_1.default)(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
223
+ }
224
+ return { appiumHome, appiumBinPath, appiumEnv };
225
+ }
226
+ async function waitForAppiumReadyAsync({ appiumProcess, logger, }) {
227
+ const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
228
+ while (Date.now() < deadline) {
229
+ try {
230
+ const response = await (0, turtleFetch_1.turtleFetch)(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
231
+ timeout: 2_000,
232
+ retries: 0,
233
+ logger,
234
+ });
235
+ if (response.ok) {
236
+ return;
237
+ }
238
+ }
239
+ catch { }
240
+ await (0, retry_1.sleepAsync)(1_000);
241
+ }
242
+ const output = appiumProcess.getOutput();
243
+ throw new eas_build_job_1.SystemError(`Timed out waiting for Appium to become ready.${output ? `\nAppium output:\n${output}` : ''}`);
244
+ }
@@ -57,6 +57,11 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
57
57
  required: false,
58
58
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
59
59
  }),
60
+ steps_1.BuildStepInput.createProvider({
61
+ id: 'max_duration_seconds',
62
+ required: false,
63
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
64
+ }),
60
65
  ],
61
66
  fn: async ({ logger, global }, { inputs, env, signal }) => {
62
67
  // Fail fast before any expensive setup if the injected env
@@ -69,6 +74,7 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
69
74
  const packageVersion = inputs.package_version.value;
70
75
  // A missing or non-positive value disables the idle timeout (opt-in feature).
71
76
  const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value;
77
+ const maxDurationSeconds = inputs.max_duration_seconds?.value;
72
78
  warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
73
79
  const versionSpec = packageVersion ?? 'latest';
74
80
  const { runtimePlatform } = global;
@@ -178,6 +184,7 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
178
184
  ctx,
179
185
  deviceRunSessionId,
180
186
  logger,
187
+ maxDurationSeconds,
181
188
  signal,
182
189
  idleTimeout: maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
183
190
  ? {
@@ -11,9 +11,23 @@ function createStartServeSimRemoteSessionBuildFunction(ctx) {
11
11
  name: 'Start serve-sim remote session',
12
12
  __metricsId: 'eas/start_serve_sim_remote_session',
13
13
  supportedRuntimePlatforms: [steps_1.BuildRuntimePlatform.DARWIN],
14
- fn: async ({ logger }, { env, signal }) => {
14
+ inputProviders: [
15
+ steps_1.BuildStepInput.createProvider({
16
+ id: 'package_version',
17
+ required: false,
18
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
19
+ }),
20
+ steps_1.BuildStepInput.createProvider({
21
+ id: 'max_duration_seconds',
22
+ required: false,
23
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
24
+ }),
25
+ ],
26
+ fn: async ({ logger }, { inputs, env, signal }) => {
15
27
  const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
16
28
  const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
29
+ const maxDurationSeconds = inputs.max_duration_seconds?.value;
30
+ const packageVersion = inputs.package_version?.value;
17
31
  logger.info('Starting serve-sim remote session.');
18
32
  await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
19
33
  const serveSim = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
@@ -21,6 +35,7 @@ function createStartServeSimRemoteSessionBuildFunction(ctx) {
21
35
  env,
22
36
  logger,
23
37
  timeoutMs: STARTUP_TIMEOUT_MS,
38
+ packageVersion,
24
39
  });
25
40
  logger.info(`Preview URL: ${serveSim.previewUrl}`);
26
41
  try {
@@ -34,6 +49,7 @@ function createStartServeSimRemoteSessionBuildFunction(ctx) {
34
49
  ctx,
35
50
  deviceRunSessionId,
36
51
  logger,
52
+ maxDurationSeconds,
37
53
  signal,
38
54
  });
39
55
  }
@@ -141,15 +141,25 @@ function createUploadToAscBuildFunction() {
141
141
  const appResponse = await AscApiUtils_1.AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
142
142
  const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
143
143
  stepsCtx.logger.info(`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`);
144
+ // Derive the App Store Connect platform from the IPA itself so tvOS (and
145
+ // other) binaries are uploaded to the correct version train instead of the
146
+ // iOS one. Falls back to `IOS` if the platform cannot be read.
147
+ const ipaInfoResult = await (0, results_1.asyncResult)((0, readIpaInfo_1.readIpaInfoAsync)(ipaPath));
148
+ const platform = ipaInfoResult.ok
149
+ ? AscApiUtils_1.AscApiUtils.ascPlatformFromDtPlatformName(ipaInfoResult.value.dtPlatformName)
150
+ : 'IOS';
151
+ stepsCtx.logger.info(`Detected App Store Connect platform: ${platform}`);
144
152
  stepsCtx.logger.info('Creating Build Upload...');
145
153
  const buildUploadResponse = await AscApiUtils_1.AscApiUtils.createBuildUploadAsync({
146
154
  client,
147
155
  appleAppIdentifier,
148
156
  bundleShortVersion,
149
157
  bundleVersion,
158
+ platform,
150
159
  });
151
160
  const buildUploadId = buildUploadResponse.data.id;
152
- const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/ios/${buildUploadId}`;
161
+ const platformPathSegment = AscApiUtils_1.AscApiUtils.testFlightPlatformPathSegment(platform);
162
+ const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/${platformPathSegment}/${buildUploadId}`;
153
163
  outputs.build_upload_id.set(buildUploadId);
154
164
  outputs.build_upload_url.set(buildUploadUrl);
155
165
  stepsCtx.logger.info(`Build Upload initialized (ID: ${buildUploadId}). Preparing IPA upload...`);
@@ -0,0 +1,10 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ import { type CustomBuildContext } from '../../customBuildContext';
3
+ import { type DeviceRunSessionEventCollection } from './deviceRunSessionEvents';
4
+ export declare function startAppiumEventCollectionAsync({ ctx, deviceRunSessionId, appiumUrl, logger, pollIntervalMs, }: {
5
+ ctx: CustomBuildContext;
6
+ deviceRunSessionId: string;
7
+ appiumUrl: string;
8
+ logger: bunyan;
9
+ pollIntervalMs?: number;
10
+ }): Promise<DeviceRunSessionEventCollection>;
@@ -0,0 +1,175 @@
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.startAppiumEventCollectionAsync = startAppiumEventCollectionAsync;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const promises_1 = require("node:timers/promises");
11
+ const zod_1 = require("zod");
12
+ const turtleFetch_1 = require("../../utils/turtleFetch");
13
+ const deviceRunSessionEvents_1 = require("./deviceRunSessionEvents");
14
+ const APPIUM_REQUEST_TIMEOUT_MS = 10_000;
15
+ const POLL_INTERVAL_MS = 1_000;
16
+ const GET_LOG_EVENTS_COMMAND = 'getLogEvents';
17
+ const AppiumSessionsResponseSchema = zod_1.z.object({
18
+ value: zod_1.z.array(zod_1.z.object({ id: zod_1.z.string() }).passthrough()),
19
+ });
20
+ const AppiumCommandSchema = zod_1.z.object({
21
+ appiumSessionId: zod_1.z.string(),
22
+ cmd: zod_1.z.string(),
23
+ startTime: zod_1.z.number(),
24
+ endTime: zod_1.z.number(),
25
+ });
26
+ const AppiumEventsResponseSchema = zod_1.z.object({
27
+ value: zod_1.z.object({
28
+ commands: zod_1.z.array(AppiumCommandSchema.omit({ appiumSessionId: true })),
29
+ }),
30
+ });
31
+ async function startAppiumEventCollectionAsync({ ctx, deviceRunSessionId, appiumUrl, logger, pollIntervalMs = POLL_INTERVAL_MS, }) {
32
+ const eventDirectory = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-appium-events-'));
33
+ const eventFile = node_path_1.default.join(eventDirectory, 'commands.ndjson');
34
+ await node_fs_1.default.promises.writeFile(eventFile, '');
35
+ let eventCollection;
36
+ try {
37
+ eventCollection = await (0, deviceRunSessionEvents_1.startDeviceRunSessionEventCollectionAsync)({
38
+ ctx,
39
+ deviceRunSessionId,
40
+ logger,
41
+ pollIntervalMs,
42
+ source: createAppiumEventSource(eventFile),
43
+ });
44
+ }
45
+ catch (error) {
46
+ await node_fs_1.default.promises.rm(eventDirectory, { recursive: true, force: true });
47
+ throw error;
48
+ }
49
+ const observedCommandKeys = new Set();
50
+ const controller = new AbortController();
51
+ let didReportCollectionFailure = false;
52
+ const collectSafelyAsync = async () => {
53
+ try {
54
+ await collectAppiumCommandsAsync({
55
+ appiumUrl,
56
+ eventFile,
57
+ observedCommandKeys,
58
+ logger,
59
+ });
60
+ didReportCollectionFailure = false;
61
+ }
62
+ catch (error) {
63
+ if (!didReportCollectionFailure) {
64
+ didReportCollectionFailure = true;
65
+ logger.warn({ err: error }, 'Could not collect Appium events.');
66
+ }
67
+ }
68
+ };
69
+ const pollingPromise = (async () => {
70
+ while (!controller.signal.aborted) {
71
+ await collectSafelyAsync();
72
+ try {
73
+ await (0, promises_1.setTimeout)(pollIntervalMs, undefined, { signal: controller.signal });
74
+ }
75
+ catch (error) {
76
+ if (!controller.signal.aborted) {
77
+ throw error;
78
+ }
79
+ }
80
+ }
81
+ })().catch(error => {
82
+ logger.warn({ err: error }, 'Appium event collection poller failed.');
83
+ });
84
+ return {
85
+ getLastEventObservedAt: eventCollection.getLastEventObservedAt,
86
+ stopAsync: async () => {
87
+ controller.abort();
88
+ await pollingPromise;
89
+ await collectSafelyAsync();
90
+ try {
91
+ await eventCollection.stopAsync();
92
+ }
93
+ finally {
94
+ await node_fs_1.default.promises.rm(eventDirectory, { recursive: true, force: true });
95
+ }
96
+ },
97
+ };
98
+ }
99
+ function createAppiumEventSource(eventFile) {
100
+ return {
101
+ producer: 'appium',
102
+ findEventFilesAsync: async () => [eventFile],
103
+ sourceKeyForFile: file => node_path_1.default.basename(file, node_path_1.default.extname(file)),
104
+ parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
105
+ if (!line.trim()) {
106
+ return {};
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(line);
111
+ }
112
+ catch {
113
+ return { failure: 'invalid-json' };
114
+ }
115
+ const result = AppiumCommandSchema.safeParse(parsed);
116
+ if (!result.success) {
117
+ return { failure: 'invalid-event' };
118
+ }
119
+ const command = result.data;
120
+ const operationId = `${sourceKey}:${sequenceNumber}`;
121
+ return {
122
+ event: {
123
+ v: 1,
124
+ eventId: `appium:${deviceRunSessionId}:${operationId}`,
125
+ ts: new Date(command.endTime).toISOString(),
126
+ producer: 'appium',
127
+ type: 'operation.completed',
128
+ operationId,
129
+ durationMs: Math.max(0, command.endTime - command.startTime),
130
+ summary: command.cmd,
131
+ data: {
132
+ command: command.cmd,
133
+ appiumSessionId: command.appiumSessionId,
134
+ },
135
+ },
136
+ };
137
+ },
138
+ };
139
+ }
140
+ async function collectAppiumCommandsAsync({ appiumUrl, eventFile, observedCommandKeys, logger, }) {
141
+ const response = await (0, turtleFetch_1.turtleFetch)(new URL('appium/sessions', appiumUrl).toString(), 'GET', {
142
+ timeout: APPIUM_REQUEST_TIMEOUT_MS,
143
+ retries: 0,
144
+ logger,
145
+ });
146
+ const sessions = AppiumSessionsResponseSchema.parse(await response.json()).value;
147
+ const newCommands = [];
148
+ const pendingCommandKeys = new Set();
149
+ for (const { id: appiumSessionId } of sessions) {
150
+ const eventsResponse = await (0, turtleFetch_1.turtleFetch)(new URL(`session/${encodeURIComponent(appiumSessionId)}/appium/events`, appiumUrl).toString(), 'POST', {
151
+ json: {},
152
+ timeout: APPIUM_REQUEST_TIMEOUT_MS,
153
+ retries: 0,
154
+ logger,
155
+ });
156
+ const commands = AppiumEventsResponseSchema.parse(await eventsResponse.json()).value.commands;
157
+ for (const command of commands) {
158
+ const key = [appiumSessionId, command.cmd, command.startTime, command.endTime].join(':');
159
+ if (command.cmd === GET_LOG_EVENTS_COMMAND ||
160
+ observedCommandKeys.has(key) ||
161
+ pendingCommandKeys.has(key)) {
162
+ continue;
163
+ }
164
+ pendingCommandKeys.add(key);
165
+ newCommands.push({ key, command: { appiumSessionId, ...command } });
166
+ }
167
+ }
168
+ if (newCommands.length === 0) {
169
+ return;
170
+ }
171
+ await node_fs_1.default.promises.appendFile(eventFile, `${newCommands.map(({ command }) => JSON.stringify(command)).join('\n')}\n`);
172
+ for (const { key } of newCommands) {
173
+ observedCommandKeys.add(key);
174
+ }
175
+ }