diff --git a/CHANGELOG.md b/CHANGELOG.md index 9904684681..8a32d220d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This is the log of notable changes to EAS CLI and related packages. - [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)) - [build-tools] Add a device run session runner that plans EAS Simulator sessions as a task graph, so device-independent work overlaps the boot. ([#4413](https://github.com/expo/eas-cli/pull/4413) by [@szdziedzic](https://github.com/szdziedzic)) +- [worker] Run `DeviceRunSession.Job` payloads with the device run session runner. ([#4414](https://github.com/expo/eas-cli/pull/4414) by [@szdziedzic](https://github.com/szdziedzic)) ### 🐛 Bug fixes diff --git a/packages/worker/src/__unit__/build.test.ts b/packages/worker/src/__unit__/build.test.ts new file mode 100644 index 0000000000..462dc2739b --- /dev/null +++ b/packages/worker/src/__unit__/build.test.ts @@ -0,0 +1,123 @@ +import { Builders, runDeviceRunSessionJobAsync, runGenericJobAsync } from '@expo/build-tools'; +import { + ArchiveSourceType, + BuildTrigger, + DeviceRunSession, + Job, + Platform, +} from '@expo/eas-build-job'; + +import { build } from '../build'; + +jest.mock('@expo/build-tools', () => { + const actual = jest.requireActual('@expo/build-tools'); + return { + ...actual, + Builders: { androidBuilder: jest.fn(), iosBuilder: jest.fn() }, + TurtleSshSession: { isSshEnabled: () => false }, + runGenericJobAsync: jest.fn(), + runDeviceRunSessionJobAsync: jest.fn(), + }; +}); +jest.mock('../runtimeEnvironment', () => ({ prepareRuntimeEnvironment: jest.fn() })); +jest.mock('../displayRuntimeInfo', () => ({ displayWorkerRuntimeInfo: jest.fn() })); +jest.mock('../workingdir', () => ({ cleanUpWorkingdir: jest.fn() })); +jest.mock('../config', () => { + const actual = jest.requireActual('../config').default; + return { __esModule: true, default: { ...actual, env: 'production' } }; +}); +jest.mock('../logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockedRunDeviceRunSessionJobAsync = jest.mocked(runDeviceRunSessionJobAsync); +const mockedRunGenericJobAsync = jest.mocked(runGenericJobAsync); +const mockedBuilders = jest.mocked(Builders); + +const sharedJobFields: Omit = { + triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION, + projectArchive: { type: ArchiveSourceType.NONE }, + secrets: { robotAccessToken: 'token', environmentSecrets: [] }, + expoDevUrl: 'https://expo.dev/', + builderEnvironment: { image: 'latest', env: {} }, + initiatingUserId: 'user-id', + appId: 'app-id', +}; + +const deviceRunSessionJob: DeviceRunSession.Job = { + ...sharedJobFields, + type: DeviceRunSession.JobType.DEVICE_RUN_SESSION, + session: { + id: 'session-id', + controller: DeviceRunSession.Controller.WEB_PREVIEW_ONLY, + maxDurationSeconds: 600, + ngrokTunnelDomain: 'sim.example.test', + }, + device: { platform: Platform.IOS }, +}; + +const genericJob: Job = { + ...sharedJobFields, + steps: [{ id: 'step', name: 'Step', run: 'true', shell: 'sh' }], +}; + +function createContext(job: Job): any { + const logger: Record = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + logger.child = jest.fn(() => logger); + return { + job, + logger, + runBuildPhase: jest.fn(async (_phase: unknown, fn: () => Promise) => await fn()), + }; +} + +function createAnalytics(): any { + return { logEvent: jest.fn(), flushEventsAsync: jest.fn(async () => {}) }; +} + +describe(build, () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedRunDeviceRunSessionJobAsync.mockResolvedValue(undefined); + mockedRunGenericJobAsync.mockResolvedValue({ + runResult: { ok: true } as never, + buildWorkflow: {} as never, + }); + }); + + it('runs device run session jobs with the dedicated runner', async () => { + const ctx = createContext(deviceRunSessionJob); + + const artifacts = await build({ ctx, buildId: 'job-run-id', analytics: createAnalytics() }); + + expect(artifacts).toEqual({}); + expect(mockedRunDeviceRunSessionJobAsync).toHaveBeenCalledWith(ctx); + expect(mockedRunGenericJobAsync).not.toHaveBeenCalled(); + expect(mockedBuilders.iosBuilder).not.toHaveBeenCalled(); + }); + + it('still runs generic jobs with the steps runner', async () => { + const ctx = createContext(genericJob); + + await build({ ctx, buildId: 'job-run-id', analytics: createAnalytics() }); + + expect(mockedRunGenericJobAsync).toHaveBeenCalledWith(ctx); + expect(mockedRunDeviceRunSessionJobAsync).not.toHaveBeenCalled(); + }); + + it('propagates the runner error so the job run is reported as errored', async () => { + const error = new Error('simulator did not boot'); + mockedRunDeviceRunSessionJobAsync.mockRejectedValue(error); + const analytics = createAnalytics(); + + await expect( + build({ ctx: createContext(deviceRunSessionJob), buildId: 'job-run-id', analytics }) + ).rejects.toBe(error); + expect(analytics.flushEventsAsync).toHaveBeenCalled(); + }); +}); diff --git a/packages/worker/src/build.ts b/packages/worker/src/build.ts index 99d367bd1a..7e29e2efc6 100644 --- a/packages/worker/src/build.ts +++ b/packages/worker/src/build.ts @@ -3,6 +3,7 @@ import { BuildContext, Builders, TurtleSshSession, + runDeviceRunSessionJobAsync, runGenericJobAsync, } from '@expo/build-tools'; import { @@ -11,6 +12,7 @@ import { BuildMode, BuildPhase, BuildPhaseResult, + DeviceRunSession, Generic, Ios, LogMarker, @@ -80,6 +82,12 @@ export async function build({ } case undefined: { artifacts = {}; + if (job.type === DeviceRunSession.JobType.DEVICE_RUN_SESSION) { + // EAS Simulator sessions: the runner owns the lifecycle, validates the + // payload, and rejects with the error to report on the job run. + await runDeviceRunSessionJobAsync(ctx as BuildContext); + break; + } const buildCtx = ctx as BuildContext; const { runResult } = await runGenericJobAsync(buildCtx); diff --git a/packages/worker/src/external/turtle.ts b/packages/worker/src/external/turtle.ts index 47cf79ac38..154f45a87f 100644 --- a/packages/worker/src/external/turtle.ts +++ b/packages/worker/src/external/turtle.ts @@ -1,5 +1,5 @@ import { type SignedUrl } from '@expo/build-tools'; -import { BuildJob, Generic, Metadata, errors } from '@expo/eas-build-job'; +import { BuildJob, DeviceRunSession, Generic, Metadata, errors } from '@expo/eas-build-job'; export const androidImagesWithJavaVersionLowerThen11 = [ 'ubuntu-20.04-jdk-8-ndk-r19c', @@ -113,7 +113,7 @@ export namespace LauncherMessage { } | { jobType: 'jobRun'; - job: Generic.Job; + job: Generic.Job | DeviceRunSession.Job; jobRunId: string; } ); diff --git a/packages/worker/src/service.ts b/packages/worker/src/service.ts index 7d2b5c80b2..51b41650b1 100644 --- a/packages/worker/src/service.ts +++ b/packages/worker/src/service.ts @@ -325,7 +325,8 @@ export default class BuildService { tags: { ...(err.buildPhase ? { buildPhase: err.buildPhase } : {}), errorCode: err.trackingCode ?? err.errorCode, - ...('type' in job ? { workflow: job.type } : {}), + // `type` is the workflow on build jobs and the job family on session jobs. + ...(job.platform ? { workflow: job.type } : job.type ? { jobType: job.type } : {}), }, extras: { buildId: this.buildId,