Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/agent-device-local-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': major
---

`eas/start_agent_device_session` drops `build_id` and `application_archive_url`. Download the app with `eas/download_build` and pass its `artifact_path` as `install_app_path`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
import { launchApplicationAsync } from '../launchApplication';
Expand Down Expand Up @@ -43,7 +42,6 @@ jest.mock('../startIosSimulator', () => ({
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
Expand Down Expand Up @@ -116,8 +114,6 @@ function runStep(
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
Expand Down Expand Up @@ -422,8 +418,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
Expand Down Expand Up @@ -573,38 +567,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});

it('still downloads a build_id app and launches it on iOS', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
);
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/Downloaded.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: undefined,
});
});

it('still downloads an application_archive_url app and installs it on Android', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
await runStep(BuildRuntimePlatform.LINUX, {
application_archive_url: 'https://example.test/app.tar.gz',
});
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
extensions: ['apk'],
})
);
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});

it('runs the warm step on the warm Simulator for a local app only', async () => {
it('runs the warm step on the warm Simulator', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
const runWarm = (inputs: Record<string, unknown>) =>
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
Expand All @@ -613,18 +576,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runWarm({ build_id: 'build-id' });
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
});

it('accepts only one application source', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});

it('waits for Android boot before installing the supplied local APK', async () => {
Expand Down
103 changes: 10 additions & 93 deletions packages/build-tools/src/steps/functions/startAgentDeviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
Expand All @@ -40,7 +38,6 @@ import {
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';

import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
import {
launchApplicationAsync,
Expand All @@ -56,9 +53,9 @@ import { startAndroidEmulatorAsync } from './startAndroidEmulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;

/**
* Starts a device session using an optional local app artifact, or one it downloads.
* Starts a device session using an optional local app artifact.
* On iOS, serve-sim boots the selected Simulator, then installs and launches
* the app. Build-tools verifies any egress guard before install and launch.
* the supplied app. Build-tools verifies any egress guard before install and launch.
*/
export function createStartAgentDeviceSessionBuildFunction(
ctx: CustomBuildContext,
Expand Down Expand Up @@ -97,23 +94,11 @@ export function createStartAgentDeviceSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of install_app_path, build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'install_app_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Deprecated: remove once workflows pass install_app_path.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
...createServeSimLaunchInputProviders(),
// Session.
...createNetworkCaptureInputProviders(),
Expand Down Expand Up @@ -146,31 +131,11 @@ export function createStartAgentDeviceSessionBuildFunction(
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const installAppPath = inputs.install_app_path.value as string | undefined;
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
const download = buildId || applicationArchiveUrl;
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of install_app_path, build_id or application_archive_url.'
);
}
if (download && launchAppIdentifier !== undefined) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_app_identifier cannot be combined with build_id or application_archive_url.'
);
}
if (
!installAppPath &&
!download &&
!launchAppIdentifier &&
(launchArgs.length > 0 || openUrl)
) {
if (!installAppPath && !launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.'
Expand Down Expand Up @@ -198,7 +163,6 @@ export function createStartAgentDeviceSessionBuildFunction(

const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;

try {
Expand All @@ -214,9 +178,8 @@ export function createStartAgentDeviceSessionBuildFunction(
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath,
launchAppIdentifier,
// A downloaded app gets its launch options after the download.
launchArgs: download ? [] : launchArgs,
openUrl: download ? undefined : openUrl,
launchArgs,
openUrl,
env,
logger,
signal: tasks.signal,
Expand Down Expand Up @@ -247,62 +210,17 @@ export function createStartAgentDeviceSessionBuildFunction(
});
});

downloaded = download
? tasks.run('build download', async taskLogger => {
const source = buildId
? { buildId }
: applicationArchiveUrl
? { applicationArchiveUrl }
: undefined;
if (!source) {
throw new Error('No app source was provided for download.');
}
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
const downloadedApp = downloaded;

if (isIos && downloadedApp) {
applicationReady = tasks.run('app preparation', async () => {
const artifactPath = await downloadedApp;
tasks.signal.throwIfAborted();
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
});
} else if (isIos && launch?.launchAppIdentifier) {
if (isIos && launch?.launchAppIdentifier) {
applicationReady = Promise.resolve({
...launch,
launchAppIdentifier: launch.launchAppIdentifier,
installAppPath,
});
} else if (!isIos && (installAppPath || downloadedApp)) {
} else if (!isIos && installAppPath) {
applicationReady = tasks.run('app install and launch', async taskLogger => {
await tasks.untilAborted(booted);
const artifactPath = installAppPath ?? (await downloadedApp);
if (!artifactPath) {
throw new Error('No app artifact was prepared.');
}
tasks.signal.throwIfAborted();
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
artifactPath: installAppPath,
Comment thread
gwdp marked this conversation as resolved.
Resolved
runtimePlatform,
env,
logger: taskLogger,
Expand Down Expand Up @@ -350,11 +268,10 @@ export function createStartAgentDeviceSessionBuildFunction(
});
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
await Promise.allSettled([applicationReady]);
}
};
// The warm path installs only a local app; build_id and application_archive_url start cold.
if (warm && isIos && !download) {
if (warm && isIos) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
Expand Down
Loading