Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
132 changes: 132 additions & 0 deletions packages/eas-build-job/src/__tests__/deviceRunSessionJob.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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
);
});
});
135 changes: 135 additions & 0 deletions packages/eas-build-job/src/deviceRunSessionJob.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SessionZ>;

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<typeof IosDeviceZ>;

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<typeof AndroidDeviceZ>;

export const DeviceZ = z.discriminatedUnion('platform', [IosDeviceZ, AndroidDeviceZ]);
export type Device = z.infer<typeof DeviceZ>;

/** 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<typeof ApplicationSourceZ>;

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<typeof ApplicationZ>;

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<typeof JobZ>;
}
5 changes: 3 additions & 2 deletions packages/eas-build-job/src/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions packages/eas-build-job/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading