From 0034062cab46caeb02e8bea81ad30708fca7f8f7 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 15 Sep 2026 21:15:26 +0200 Subject: [PATCH] [eas-build-job] add DeviceRunSession.Job --- CHANGELOG.md | 1 + .../src/__tests__/deviceRunSessionJob.test.ts | 132 +++++++++++++++++ .../eas-build-job/src/deviceRunSessionJob.ts | 135 ++++++++++++++++++ packages/eas-build-job/src/generic.ts | 5 +- packages/eas-build-job/src/index.ts | 1 + 5 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 packages/eas-build-job/src/__tests__/deviceRunSessionJob.test.ts create mode 100644 packages/eas-build-job/src/deviceRunSessionJob.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 640230a0cd..0c798baa0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk)) - [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp)) - [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp)) +- [eas-build-job] Add the `DeviceRunSession.Job` payload for EAS Simulator sessions. ([#4412](https://github.com/expo/eas-cli/pull/4412) by [@szdziedzic](https://github.com/szdziedzic)) ### 🐛 Bug fixes diff --git a/packages/eas-build-job/src/__tests__/deviceRunSessionJob.test.ts b/packages/eas-build-job/src/__tests__/deviceRunSessionJob.test.ts new file mode 100644 index 0000000000..b74f90573b --- /dev/null +++ b/packages/eas-build-job/src/__tests__/deviceRunSessionJob.test.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'crypto'; +import { ZodError } from 'zod'; + +import { ArchiveSourceType, BuildTrigger, EnvironmentSecretType, Platform } from '../common'; +import { DeviceRunSession } from '../deviceRunSessionJob'; + +function createIosWebPreviewJob( + overrides: Partial = {} +): DeviceRunSession.Job { + return { + type: DeviceRunSession.JobType.DEVICE_RUN_SESSION, + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + projectArchive: { type: ArchiveSourceType.NONE }, + secrets: { + robotAccessToken: 'token', + environmentSecrets: [ + { name: 'NGROK_AUTHTOKEN', value: 'ngrok-token', type: EnvironmentSecretType.STRING }, + ], + }, + expoDevUrl: 'https://expo.dev/', + builderEnvironment: { + image: 'latest', + env: { npm_config_audit: 'false' }, + }, + initiatingUserId: randomUUID(), + appId: randomUUID(), + session: { + id: randomUUID(), + controller: DeviceRunSession.Controller.WEB_PREVIEW_ONLY, + maxDurationSeconds: 2400, + ngrokTunnelDomain: 'sim.example.test', + }, + device: { platform: Platform.IOS, deviceIdentifier: 'iPhone 16 Pro' }, + application: { + source: { buildId: randomUUID() }, + launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'], + openUrl: 'exp://127.0.0.1:8081', + }, + ...overrides, + }; +} + +describe('DeviceRunSession.JobZ', () => { + it('accepts an iOS web preview session that installs an EAS Build', () => { + const job = createIosWebPreviewJob(); + expect(DeviceRunSession.JobZ.parse(job)).toEqual(job); + }); + + it('accepts an Android agent-device session that installs an archive URL', () => { + const job = createIosWebPreviewJob({ + session: { + id: randomUUID(), + controller: DeviceRunSession.Controller.AGENT_DEVICE, + maxDurationSeconds: 1800, + maxIdleTimeMinutes: 10, + packageVersion: '0.4.2', + ngrokTunnelDomain: 'sim.example.test', + }, + device: { + platform: Platform.ANDROID, + deviceIdentifier: 'medium_phone', + systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64', + lcdWidth: 720, + lcdHeight: 1600, + lcdDensity: 300, + }, + application: { + source: { archiveUrl: 'https://example.test/app.apk' }, + }, + }); + expect(DeviceRunSession.JobZ.parse(job)).toEqual(job); + }); + + it('accepts a session without an application and with local egress on iOS', () => { + const job = createIosWebPreviewJob({ + application: undefined, + egress: DeviceRunSession.Egress.LOCAL, + }); + expect(DeviceRunSession.JobZ.parse(job)).toEqual(job); + }); + + it('rejects local egress on Android', () => { + const job = createIosWebPreviewJob({ + device: { + platform: Platform.ANDROID, + deviceIdentifier: 'medium_phone', + systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64', + }, + egress: DeviceRunSession.Egress.LOCAL, + }); + expect(() => DeviceRunSession.JobZ.parse(job)).toThrow(ZodError); + }); + + it('rejects an idle timeout that is not shorter than the session duration', () => { + const job = createIosWebPreviewJob({ + session: { + id: randomUUID(), + controller: DeviceRunSession.Controller.APPIUM, + maxDurationSeconds: 600, + maxIdleTimeMinutes: 10, + ngrokTunnelDomain: 'sim.example.test', + }, + }); + expect(() => DeviceRunSession.JobZ.parse(job)).toThrow(ZodError); + }); + + it('rejects an application with two sources', () => { + const job = createIosWebPreviewJob({ + application: { + source: { buildId: randomUUID(), archiveUrl: 'https://example.test/app.tar.gz' } as never, + }, + }); + expect(() => DeviceRunSession.JobZ.parse(job)).toThrow(ZodError); + }); + + it('rejects a build job type in the discriminator slot', () => { + const job = { ...createIosWebPreviewJob(), type: 'generic' }; + expect(() => DeviceRunSession.JobZ.parse(job)).toThrow(ZodError); + }); + + it('rejects a job that sets platform', () => { + const job = { ...createIosWebPreviewJob(), platform: Platform.IOS }; + expect(() => DeviceRunSession.JobZ.parse(job)).toThrow(ZodError); + }); + + it('rejects a missing session id', () => { + const { session, ...job } = createIosWebPreviewJob(); + expect(() => DeviceRunSession.JobZ.parse({ ...job, session: { ...session, id: '' } })).toThrow( + ZodError + ); + }); +}); diff --git a/packages/eas-build-job/src/deviceRunSessionJob.ts b/packages/eas-build-job/src/deviceRunSessionJob.ts new file mode 100644 index 0000000000..b8e3bb5776 --- /dev/null +++ b/packages/eas-build-job/src/deviceRunSessionJob.ts @@ -0,0 +1,135 @@ +import { LoggerLevel } from '@expo/logger'; +import { z } from 'zod'; + +import { + ArchiveSourceSchemaZ, + BuildTrigger, + EnvironmentSecretZ, + Platform, + SshSettingsZ, +} from './common'; +import { Generic } from './generic'; + +/** + * Job payload for an EAS Simulator device run session. + * + * Unlike `Generic.Job`, this payload describes the session the API server wants + * (device, application, controller) instead of listing `eas/*` steps. The worker + * plans and runs the session lifecycle itself, so work that does not need the + * device can run while it boots, and the lifecycle has one home. + * + * `type` doubles as the job-family discriminator: build jobs carry a `Workflow` + * value there, generic jobs carry `undefined`, and session jobs carry + * `JobType.DEVICE_RUN_SESSION`. + */ +export namespace DeviceRunSession { + export enum JobType { + DEVICE_RUN_SESSION = 'device-run-session', + } + + /** The tool that gives clients access to the device once the session is live. */ + export enum Controller { + /** Interactive web preview only: serve-sim on iOS, expo-device-hub on Android. */ + WEB_PREVIEW_ONLY = 'web-preview-only', + AGENT_DEVICE = 'agent-device', + ARGENT = 'argent', + APPIUM = 'appium', + } + + /** Where the device's proxied network traffic exits. Unset means EAS infrastructure. */ + export enum Egress { + LOCAL = 'local', + } + + export const SessionZ = z.object({ + /** Device run session id in the API server. The worker reports the remote session to it. */ + id: z.string().min(1), + controller: z.nativeEnum(Controller), + /** Usable session time, counted from the moment the remote session is published. */ + maxDurationSeconds: z.number().int().positive(), + /** Stop the session after this many minutes without controller activity. */ + maxIdleTimeMinutes: z.number().int().positive().optional(), + /** Controller package version to run. Unset means latest. */ + packageVersion: z.string().min(1).optional(), + /** Base domain for the session's ngrok tunnels. */ + ngrokTunnelDomain: z.string().min(1), + }); + export type Session = z.infer; + + export const IosDeviceZ = z.object({ + platform: z.literal(Platform.IOS), + /** Simulator name or UDID. Unset picks the most generic available iPhone. */ + deviceIdentifier: z.string().min(1).optional(), + }); + export type IosDevice = z.infer; + + export const AndroidDeviceZ = z.object({ + platform: z.literal(Platform.ANDROID), + /** AVD hardware profile id, for example `medium_phone`. */ + deviceIdentifier: z.string().min(1), + /** SDK system image package, for example `system-images;android-35-ext15;google_apis_playstore;x86_64`. */ + systemImagePackage: z.string().min(1), + lcdWidth: z.number().int().positive().optional(), + lcdHeight: z.number().int().positive().optional(), + lcdDensity: z.number().int().positive().optional(), + }); + export type AndroidDevice = z.infer; + + export const DeviceZ = z.discriminatedUnion('platform', [IosDeviceZ, AndroidDeviceZ]); + export type Device = z.infer; + + /** Exactly one source: an EAS Build id or a direct application archive URL. */ + export const ApplicationSourceZ = z.union([ + z.strictObject({ buildId: z.string().uuid() }), + z.strictObject({ archiveUrl: z.string().url() }), + ]); + export type ApplicationSource = z.infer; + + export const ApplicationZ = z.object({ + source: ApplicationSourceZ, + /** Passed to the application on launch. On Android these are `am start` intent arguments. */ + launchArgs: z.array(z.string()).optional(), + /** Opened in the application after launch. */ + openUrl: z.string().url().optional(), + }); + export type Application = z.infer; + + export const JobZ = z + .object({ + type: z.literal(JobType.DEVICE_RUN_SESSION), + // Build jobs set `platform`; session jobs and generic jobs never do. + platform: z.never().optional(), + triggeredBy: z.literal(BuildTrigger.GIT_BASED_INTEGRATION), + projectArchive: ArchiveSourceSchemaZ, + secrets: z.object({ + robotAccessToken: z.string(), + environmentSecrets: z.array(EnvironmentSecretZ), + }), + expoDevUrl: z.string().url(), + builderEnvironment: Generic.BuilderEnvironmentSchemaZ, + loggerLevel: z.nativeEnum(LoggerLevel).optional(), + initiatingUserId: z.string(), + appId: z.string(), + ssh: SshSettingsZ.optional(), + + session: SessionZ, + device: DeviceZ, + /** Application to install and launch before the remote session is published. */ + application: ApplicationZ.optional(), + egress: z.nativeEnum(Egress).optional(), + }) + .refine(job => job.egress !== Egress.LOCAL || job.device.platform === Platform.IOS, { + message: 'Local egress is only supported for iOS devices.', + path: ['egress'], + }) + .refine( + job => + job.session.maxIdleTimeMinutes === undefined || + job.session.maxIdleTimeMinutes * 60 < job.session.maxDurationSeconds, + { + message: 'maxIdleTimeMinutes must be smaller than maxDurationSeconds.', + path: ['session', 'maxIdleTimeMinutes'], + } + ); + export type Job = z.infer; +} diff --git a/packages/eas-build-job/src/generic.ts b/packages/eas-build-job/src/generic.ts index 9e37c8fe8c..0caf22a211 100644 --- a/packages/eas-build-job/src/generic.ts +++ b/packages/eas-build-job/src/generic.ts @@ -13,7 +13,7 @@ import { import { StepZ } from './step'; export namespace Generic { - const BuilderEnvironmentSchemaZ = z.object({ + export const BuilderEnvironmentSchemaZ = z.object({ image: z.string(), node: z.string().optional(), corepack: z.boolean().optional(), @@ -38,7 +38,8 @@ export namespace Generic { }), expoDevUrl: z.string().url(), builderEnvironment: BuilderEnvironmentSchemaZ, - // We use this to discern between Android.Job, Ios.Job and Generic.Job. + // We use these to discern between Android.Job, Ios.Job, Generic.Job and DeviceRunSession.Job: + // build jobs set both, generic jobs set neither, and session jobs set only `type`. platform: z.never().optional(), type: z.never().optional(), triggeredBy: z.literal(BuildTrigger.GIT_BASED_INTEGRATION), diff --git a/packages/eas-build-job/src/index.ts b/packages/eas-build-job/src/index.ts index d3d419f39f..22585fe9b0 100644 --- a/packages/eas-build-job/src/index.ts +++ b/packages/eas-build-job/src/index.ts @@ -34,6 +34,7 @@ export * from './compositeFunction'; export * from './submission-config'; export * from './projectPackage'; export * from './deviceRunSession'; +export * from './deviceRunSessionJob'; export * from './sandbox'; const version = require('../package.json').version;