Compare commits

...
Author SHA1 Message Date
Ash Wu 1ca33ad191 address comments
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 19:16:15 +08:00
Ash Wu 14d5cb1ac5 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output
Signed-off-by: Ash Wu <[email protected]>
2026-06-03 15:49:12 +08:00
Gabe Debes 543261b68a [build-tools] disable apsd in iOS simulator (#3795)
* [build-tools] Disable apsd in iOS Simulator after boot

* [build-tools] Add unit test for disableApsdAsync

* [build-tools] Use { err } logging convention for apsd warning

* [build-tools] Test apsd disable in start_ios_simulator
2026-06-02 09:08:43 -07:00
Krystof Woldrich e1148f2b00 feat(simulator): add exec command and config persistence via dotenv (#3777) 2026-06-02 14:31:27 +00:00
Gabe Debes 2c1837f8ba [build-tools] Auto-upload embedded bundle after build when opt-in flag is set (#3767)
* [build-tools] Auto-upload embedded bundle after build when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set

* [build-tools] Add changelog entry for #3767

* Move isEASUpdateConfigured check inside uploadEmbeddedBundleAsync

* Skip simulator builds; use endsWith for Android archive entry matching

* Skip simulator builds; add tests for uploadEmbeddedBundleAsync

* Simplify archive pattern branch for readability

* Add tests for upload error paths and builder env gate

* Address review: explicit platform branch, channel check first, safe zip.close

* Move #3767 entry back under ## main after v20 release rebase
2026-05-29 10:44:06 -07:00
Szymon Dziedzic 7bdc009c5e [eas-cli] use webrtc for simulator service streaming (#3807)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Use webrtc for better perf

# How

Use webrtc with serve sim for better perf

# Test Plan

Looks good locally
2026-05-29 18:28:56 +02:00
Expo CI b5592e16ad update CHANGELOG.md 2026-05-29 00:04:43 +00:00
29 changed files with 1714 additions and 168 deletions

No files matched your search

+13 -2
View File
@@ -6,6 +6,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
@@ -16,8 +28,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
@@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -269,4 +273,37 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
+7
View File
@@ -25,6 +25,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -209,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+1 -1
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
@@ -0,0 +1,76 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -65,6 +65,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
@@ -96,6 +102,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({
udid: cloneUdid,
env,
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,91 +0,0 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
@@ -146,7 +146,7 @@ export async function startServeSimWithTunnelAsync({
'--stream-quality',
'0.55',
'--codec',
'h264',
'webrtc',
],
env,
});
@@ -157,6 +157,25 @@ export namespace IosSimulatorUtils {
);
}
export async function disableApsdAsync({
udid,
env,
}: {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env }
);
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env }
);
}
export async function collectLogsAsync({
deviceIdentifier,
env,
@@ -41,4 +41,24 @@ describe('IosSimulatorUtils', () => {
);
});
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
it('disables and boots out apsd in the simulator', async () => {
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env: process.env }
);
});
});
});
@@ -0,0 +1,273 @@
import { Platform } from '@expo/eas-build-job';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
jest.mock('../expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
const mockZipEntries = jest.fn();
const mockZipExtract = jest.fn();
const mockZipClose = jest.fn();
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(() => ({
entries: mockZipEntries,
extract: mockZipExtract,
close: mockZipClose,
})),
},
}));
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
const job =
overrides.platform === Platform.IOS
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
mockZipExtract.mockResolvedValue(undefined);
mockZipClose.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('uploads from Android APK archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
'update:embedded:upload',
'--platform',
Platform.ANDROID,
'--channel',
'production',
'--build-id',
'build-123',
]),
})
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'base/assets/index.android.bundle': true,
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalled();
});
it('uploads from iOS IPA archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'Payload/App.app/main.jsbundle': true,
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
})
);
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns and continues when CLI upload throws', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
}
const channel = ctx.job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
}
const [archivePath] = await findArtifacts({
rootDir: projectDir,
patternOrPath: archivePattern,
logger: null,
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
const bundlePath = path.join(tmpDir, bundleName);
const manifestPath = path.join(tmpDir, 'app.manifest');
const zip = new StreamZip.async({ file: archivePath });
try {
const entries = Object.values(await zip.entries());
const bundleEntry = entries.find(e =>
platform === Platform.IOS
? e.name.endsWith('/main.jsbundle')
: e.name.endsWith('assets/index.android.bundle')
);
const manifestEntry = entries.find(e =>
platform === Platform.IOS
? e.name.includes('EXUpdates.bundle/app.manifest')
: e.name.endsWith('assets/app.manifest')
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
}
await zip.extract(bundleEntry.name, bundlePath);
await zip.extract(manifestEntry.name, manifestPath);
const args = [
'update:embedded:upload',
'--platform',
platform,
'--bundle',
bundlePath,
'--manifest',
manifestPath,
'--channel',
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
} finally {
await asyncResult(zip.close());
}
}
+3
View File
@@ -27,6 +27,7 @@ export enum BuildPhase {
*/
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
@@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'Cache stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
@@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'cache-stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
@@ -0,0 +1,134 @@
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import SimulatorExec from '../exec';
jest.mock('@expo/env', () => ({
loadEnvFiles: jest.fn(),
loadProjectEnv: jest.fn(),
}));
jest.mock('@expo/spawn-async');
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorExec, () => {
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawnAsync).mockResolvedValue({} as never);
});
function createCommand(argv: string[]): {
command: SimulatorExec;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorExec(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectDir,
});
return { command, getContextAsync };
}
it('loads local env files and spawns the supplied command with inherited stdio', async () => {
const { command, getContextAsync } = createCommand(['agent-device', 'touch', '@e2']);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorExec, {
nonInteractive: true,
});
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
force: true,
});
expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], {
stdio: 'inherit',
env: process.env,
});
});
it('passes through command flags as args', async () => {
const { command } = createCommand([
'agent-device',
'screenshot',
'/test/path.png',
'--format',
'png',
]);
await command.runAsync();
expect(spawnAsync).toHaveBeenCalledWith(
'agent-device',
['screenshot', '/test/path.png', '--format', 'png'],
{
stdio: 'inherit',
env: process.env,
}
);
});
it('does not emit an oclif unparsed arguments warning', async () => {
const { command } = createCommand(['echo', '"test"']);
const emitWarningSpy = jest.spyOn(process, 'emitWarning').mockImplementation();
jest.spyOn(command, 'run').mockImplementation(async () => {
await command.runAsync();
});
jest.spyOn(command, 'finally').mockResolvedValue(undefined);
try {
// @ts-expect-error _run is internal to oclif, but it is where the unparsed warning is emitted.
await command._run();
const emittedUnparsedCommandWarning = emitWarningSpy.mock.calls.some(([warning, options]) => {
return (
typeof warning === 'string' &&
warning.includes('did not parse its arguments') &&
typeof options === 'object' &&
options !== null &&
'code' in options &&
options.code === 'UnparsedCommand'
);
});
expect(emittedUnparsedCommandWarning).toBe(false);
} finally {
emitWarningSpy.mockRestore();
}
});
it('throws a helpful error when no command is provided', async () => {
const { command, getContextAsync } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'No command provided. Run `eas simulator:exec <command> [args...]`.'
);
expect(getContextAsync).not.toHaveBeenCalled();
expect(loadProjectEnv).not.toHaveBeenCalled();
expect(loadEnvFiles).not.toHaveBeenCalled();
expect(spawnAsync).not.toHaveBeenCalled();
});
it('loads simulator-specific env after regular env files', async () => {
const { command } = createCommand(['agent-device', 'touch', '@e2']);
await command.runAsync();
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
force: true,
});
expect(jest.mocked(loadProjectEnv).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(loadEnvFiles).mock.invocationCallOrder[0]
);
});
});
@@ -8,11 +8,20 @@ import {
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorGet from '../get';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
@@ -30,6 +39,7 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
@@ -71,9 +81,11 @@ function getMockOclifConfig(): Config {
describe(SimulatorGet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
@@ -84,6 +96,7 @@ describe(SimulatorGet, () => {
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return { command, getContextAsync };
}
@@ -96,6 +109,7 @@ describe(SimulatorGet, () => {
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, {
nonInteractive: true,
});
@@ -108,4 +122,46 @@ describe(SimulatorGet, () => {
remoteConfig: session.remoteConfig,
});
});
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
const session = makeDeviceRunSession({ id: 'session-from-env' });
mockByIdAsync.mockResolvedValue(session);
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
try {
const { command } = createCommand([]);
await command.runAsync();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-from-env');
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
it('throws a helpful error when no simulator session ID is available', async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
delete process.env[EAS_SIMULATOR_SESSION_ID];
try {
const { command } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockByIdAsync).not.toHaveBeenCalled();
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
});
@@ -0,0 +1,317 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
CreateDeviceRunSessionMutation,
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_HEADER,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import SimulatorStart from '../start';
jest.mock('fs-extra');
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log', () => ({
__esModule: true,
default: {
debug: jest.fn(),
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
withTick: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
type CreatedDeviceRunSession =
CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession'];
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`;
const jobRunUrl = 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123';
const mockCreateDeviceRunSessionAsync = jest.mocked(
DeviceRunSessionMutation.createDeviceRunSessionAsync
);
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockOra = jest.mocked(ora);
function makeCreatedDeviceRunSession(
overrides: Partial<CreatedDeviceRunSession> = {}
): CreatedDeviceRunSession {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
turtleJobRun: {
id: 'job-123',
},
...overrides,
};
}
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
agentDeviceRemoteSessionToken: 'token-123',
webPreviewUrl: 'https://preview.example.com',
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorStart, () => {
const mockConfig = getMockOclifConfig();
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
beforeEach(() => {
jest.clearAllMocks();
delete process.env[EAS_SIMULATOR_SESSION_ID];
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
afterAll(() => {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
});
function createCommand(argv: string[]): {
command: SimulatorStart;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorStart(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
projectId: 'project-123',
});
return { command, getContextAsync };
}
it('prints environment variables without saving when outputting env', async () => {
const { command, getContextAsync } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--out-config-type',
'env',
]);
await command.runAsync();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStart, {
nonInteractive: true,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
expect(fs.writeFile).not.toHaveBeenCalled();
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123) ${jobRunUrl}`
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'")
);
});
it('writes .env.eas-simulator with the environment variables by default', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
mockByIdAsync.mock.invocationCallOrder[0]
);
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
`Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}`
);
expect(Log.withTick).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(
[
'🔑 Run the following to use agent-device with the simulator:',
'',
'eas simulator:exec agent-device <command>',
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
'https://preview.example.com',
].join('\n')
);
});
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--out-config-type',
'dotenv',
]);
await command.runAsync();
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
});
it(`warns and creates a new session when ${EAS_SIMULATOR_SESSION_ID} is already present by default`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
' Overwriting previous simulator session (id: existing-session). ' +
'The previous remote session will continue running until stopped. ' +
'To stop it, run: eas simulator:stop --id existing-session'
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
});
it(`creates a new session when ${EAS_SIMULATOR_SESSION_ID} is present with --force`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--force']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
' Overwriting previous simulator session (id: existing-session). ' +
'The previous remote session will continue running until stopped. ' +
'To stop it, run: eas simulator:stop --id existing-session'
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'project-123',
packageVersion: undefined,
platform: AppPlatform.Ios,
type: DeviceRunSessionType.AgentDevice,
});
});
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--no-force']);
await expect(command.runAsync()).rejects.toThrow(
'Existing simulator session in environment. Use --force to create a new device session.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('resets .env.eas-simulator when the interactive wait observes the session end', async () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession())
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
});
});
@@ -0,0 +1,152 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
DeviceRunSessionStatus,
EnsureDeviceRunSessionStoppedMutation,
} from '../../../graphql/generated';
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorStop from '../stop';
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type StoppedDeviceRunSession =
EnsureDeviceRunSessionStoppedMutation['deviceRunSession']['ensureDeviceRunSessionStopped'];
const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeStoppedDeviceRunSession(
overrides: Partial<StoppedDeviceRunSession> = {}
): StoppedDeviceRunSession {
return {
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorStop, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectDir = '/test/project';
beforeEach(() => {
jest.clearAllMocks();
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
command: SimulatorStop;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorStop(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStop, {
nonInteractive: true,
});
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-123'
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
});
});
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(
makeStoppedDeviceRunSession({ id: 'session-from-env' })
);
try {
const { command } = createCommand([]);
await command.runAsync();
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
graphqlClient,
'session-from-env'
);
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
it('throws a helpful error when no simulator session ID is available', async () => {
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
delete process.env[EAS_SIMULATOR_SESSION_ID];
try {
const { command } = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
} else {
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
}
}
});
});
@@ -0,0 +1,47 @@
import spawnAsync from '@expo/spawn-async';
import EasCommand from '../../commandUtils/EasCommand';
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
export default class SimulatorExec extends EasCommand {
static override hidden = true;
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
static override strict = false;
static override contextDefinition = {
...this.ContextOptions.ProjectDir,
};
private isRunningSubprocess = false;
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
// Required to avoid `Warning: Command exec did not parse its arguments. Did you forget to call 'this.parse'?`
await this.parse(SimulatorExec, []);
const [command, ...args] = rawArgv;
if (typeof command !== 'string' || command.length === 0) {
throw new Error('No command provided. Run `eas simulator:exec <command> [args...]`.');
}
const { projectDir } = await this.getContextAsync(SimulatorExec, {
nonInteractive: true,
});
await loadSimulatorEnvAsync(projectDir);
this.isRunningSubprocess = true;
await spawnAsync(command, args, {
stdio: 'inherit',
env: process.env,
});
}
protected override catch(err: Error): Promise<any> {
// Propagate wrapped command from spawnAsync rejection
if (this.isRunningSubprocess) {
process.exitCode = process.exitCode ?? (err as any).status ?? 1;
return Promise.resolve();
}
return super.catch(err);
}
}
+20 -6
View File
@@ -10,6 +10,11 @@ import { DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
@@ -23,14 +28,14 @@ export default class SimulatorGet extends EasCommand {
static override flags = {
id: Flags.string({
description: 'Device run session ID',
required: true,
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
@@ -42,18 +47,27 @@ export default class SimulatorGet extends EasCommand {
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorGet, {
nonInteractive,
});
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
await loadSimulatorEnvAsync(projectDir);
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
if (!flagId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const fetchSpinner = ora(`Fetching device run session ${flagId}`).start();
let session;
try {
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flags.id);
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
} catch (err) {
fetchSpinner.fail(`Failed to fetch device run session ${flags.id}`);
fetchSpinner.fail(`Failed to fetch device run session ${flagId}`);
throw err;
}
@@ -81,7 +95,7 @@ export default class SimulatorGet extends EasCommand {
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
if (session.remoteConfig) {
Log.log(formatRemoteSessionInstructions(session.remoteConfig));
Log.log(formatRemoteSessionInstructions(session.remoteConfig, 'env'));
} else {
Log.log(
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
@@ -1,4 +1,5 @@
import { Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
@@ -17,18 +18,29 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
formatRemoteSessionInstructions,
getRemoteSessionEnvironmentVariables,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
const OUT_CONFIG_TYPE_VALUES = {
Env: 'env',
Dotenv: 'dotenv',
} as const;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
@@ -50,11 +62,23 @@ export default class SimulatorStart extends EasCommand {
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
force: Flags.boolean({
description:
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
default: true,
allowNo: true,
}),
'out-config-type': Flags.option({
description: `How to output simulator connection configuration. Use "env" to print shell exports, or "dotenv" to write ${SIMULATOR_DOTENV_FILE_NAME}.`,
options: Object.values(OUT_CONFIG_TYPE_VALUES),
default: OUT_CONFIG_TYPE_VALUES.Dotenv,
})(),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
@@ -68,11 +92,28 @@ export default class SimulatorStart extends EasCommand {
const {
projectId,
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
nonInteractive,
});
await loadSimulatorEnvAsync(projectDir);
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
if (existingDeviceRunSessionId && !flags.force) {
throw new Error(
`Existing simulator session in environment. Use --force to create a new device session.`
);
}
if (existingDeviceRunSessionId) {
Log.warn(
` Overwriting previous simulator session (id: ${existingDeviceRunSessionId}). ` +
`The previous remote session will continue running until stopped. ` +
`To stop it, run: eas simulator:stop --id ${existingDeviceRunSessionId}`
);
Log.newLine();
}
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
const createSpinner = ora('🚀 Creating device run session').start();
@@ -88,8 +129,16 @@ export default class SimulatorStart extends EasCommand {
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
const simulatorEnvWritten =
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
? await writeSimulatorEnvSafelyAsync(projectDir, {
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
})
: false;
createSpinner.succeed(
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
`Device run session created (id: ${deviceRunSessionId}${
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
}) ${link(jobRunUrl)}`
);
} catch (err) {
createSpinner.fail('Failed to create device run session');
@@ -146,6 +195,13 @@ export default class SimulatorStart extends EasCommand {
);
}
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
await writeSimulatorEnvSafelyAsync(projectDir, {
...getRemoteSessionEnvironmentVariables(remoteConfig),
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
});
}
if (jsonFlag) {
printJsonOnlyOutput({
id: deviceRunSessionId,
@@ -157,7 +213,7 @@ export default class SimulatorStart extends EasCommand {
}
Log.newLine();
Log.log(formatRemoteSessionInstructions(remoteConfig));
Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type']));
Log.newLine();
if (nonInteractive) {
@@ -171,18 +227,38 @@ export default class SimulatorStart extends EasCommand {
graphqlClient,
deviceRunSessionId,
jobRunUrl,
projectDir,
});
}
}
async function writeSimulatorEnvSafelyAsync(
projectDir: string,
environmentVariables: Record<string, string>
): Promise<boolean> {
try {
await writeSimulatorEnvAsync(projectDir, environmentVariables);
return true;
} catch (err) {
Log.warn(
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}: ${
err instanceof Error ? err.message : String(err)
}`
);
return false;
}
}
async function waitForSessionEndOrInterruptAsync({
graphqlClient,
deviceRunSessionId,
jobRunUrl,
projectDir,
}: {
graphqlClient: ExpoGraphqlClient;
deviceRunSessionId: string;
jobRunUrl: string;
projectDir: string;
}): Promise<void> {
const spinner = ora(
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
@@ -239,6 +315,7 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Finished
) {
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
await resetSimulatorEnvVerboseAsync(projectDir);
return;
}
@@ -252,6 +329,7 @@ async function waitForSessionEndOrInterruptAsync({
);
if (stopped) {
spinner.succeed('Device run session stopped');
await resetSimulatorEnvVerboseAsync(projectDir);
} else {
spinner.fail(
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -262,6 +340,15 @@ async function waitForSessionEndOrInterruptAsync({
}
}
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
try {
await resetSimulatorEnvAsync(projectDir);
} catch (err) {
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
throw err;
}
}
async function ensureDeviceRunSessionStoppedSafelyAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
+25 -10
View File
@@ -7,6 +7,11 @@ import {
} from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorStop extends EasCommand {
@@ -16,14 +21,14 @@ export default class SimulatorStop extends EasCommand {
static override flags = {
id: Flags.string({
description: 'Device run session ID',
required: true,
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
@@ -35,25 +40,35 @@ export default class SimulatorStop extends EasCommand {
}
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive,
});
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
await loadSimulatorEnvAsync(projectDir);
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
if (!flagId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const stopSpinner = ora(`🛑 Stopping device run session ${flagId}`).start();
let session;
try {
const session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
graphqlClient,
flags.id
flagId
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
if (jsonFlag) {
printJsonOnlyOutput({ id: session.id, status: session.status });
}
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
stopSpinner.fail(`Failed to stop device run session ${flagId}`);
throw err;
}
if (jsonFlag) {
printJsonOnlyOutput({ id: session.id, status: session.status });
}
}
}
@@ -0,0 +1,76 @@
import * as fs from 'fs-extra';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_HEADER,
getSimulatorEnvFilePath,
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../env';
jest.mock('fs-extra');
describe(resetSimulatorEnvAsync, () => {
const projectDir = '/test/project';
const simulatorDotenvPath = getSimulatorEnvFilePath(projectDir);
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(fs.truncate).mockResolvedValue(undefined as never);
});
it('overwrites the simulator dotenv file with the header only', async () => {
await resetSimulatorEnvAsync(projectDir);
expect(fs.writeFile).toHaveBeenCalledWith(simulatorDotenvPath, SIMULATOR_DOTENV_FILE_HEADER, {
flag: 'r+',
});
expect(fs.truncate).toHaveBeenCalledWith(
simulatorDotenvPath,
Buffer.byteLength(SIMULATOR_DOTENV_FILE_HEADER)
);
});
it('ignores a missing simulator dotenv file', async () => {
const err = Object.assign(new Error('missing file'), { code: 'ENOENT' });
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
await expect(resetSimulatorEnvAsync(projectDir)).resolves.toBeUndefined();
expect(fs.truncate).not.toHaveBeenCalled();
});
it('rethrows non-missing-file errors', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
await expect(resetSimulatorEnvAsync(projectDir)).rejects.toThrow('permission denied');
});
});
describe(writeSimulatorEnvAsync, () => {
const projectDir = '/test/project';
const simulatorDotenvPath = getSimulatorEnvFilePath(projectDir);
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
it('writes the simulator dotenv file with the header and environment variables', async () => {
await writeSimulatorEnvAsync(projectDir, {
AGENT_DEVICE_DAEMON_BASE_URL: 'https://agent.example.com',
AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'token-123',
[EAS_SIMULATOR_SESSION_ID]: 'session-123',
});
expect(fs.writeFile).toHaveBeenCalledWith(
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
import * as fs from 'fs-extra';
import path from 'path';
export const SIMULATOR_DOTENV_FILE_NAME = '.env.eas-simulator';
export const EAS_SIMULATOR_SESSION_ID = 'EAS_SIMULATOR_SESSION_ID';
export const SIMULATOR_DOTENV_FILE_HEADER =
'# Do not commit this file.\n# Do not modify these values manually. They are managed by eas-cli.\n# It holds configuration only for the current simulator session.\n\n';
export function getSimulatorEnvFilePath(projectDir: string): string {
return path.join(projectDir, SIMULATOR_DOTENV_FILE_NAME);
}
export async function loadSimulatorEnvAsync(projectDir: string): Promise<void> {
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
loadProjectEnv(projectDir, { silent: true });
loadEnvFiles([simulatorDotenvFilePath], { force: true });
}
export async function writeSimulatorEnvAsync(
projectDir: string,
environmentVariables: Record<string, string>
): Promise<void> {
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
const simulatorDotenvContent =
SIMULATOR_DOTENV_FILE_HEADER +
Object.entries(environmentVariables)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join('\n') +
'\n';
await fs.writeFile(simulatorDotenvFilePath, simulatorDotenvContent);
}
export async function resetSimulatorEnvAsync(projectDir: string): Promise<void> {
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
try {
await fs.writeFile(simulatorDotenvFilePath, SIMULATOR_DOTENV_FILE_HEADER, { flag: 'r+' });
await fs.truncate(simulatorDotenvFilePath, Buffer.byteLength(SIMULATOR_DOTENV_FILE_HEADER));
} catch (err) {
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
return;
}
throw err;
}
}
+34 -7
View File
@@ -21,17 +21,44 @@ export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): str
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
}
export function formatRemoteSessionInstructions(
export function getRemoteSessionEnvironmentVariables(
remoteConfig: DeviceRunSessionRemoteConfig
): Record<string, string> {
switch (remoteConfig.__typename) {
case 'AgentDeviceRunSessionRemoteConfig':
return {
AGENT_DEVICE_DAEMON_BASE_URL: remoteConfig.agentDeviceRemoteSessionUrl,
AGENT_DEVICE_DAEMON_AUTH_TOKEN: remoteConfig.agentDeviceRemoteSessionToken,
};
case 'ArgentRunSessionRemoteConfig':
case 'ServeSimRunSessionRemoteConfig':
return {};
}
}
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig,
configType: RemoteSessionInstructionsConfigType
): string {
switch (remoteConfig.__typename) {
case 'AgentDeviceRunSessionRemoteConfig': {
const lines = [
'🔑 Run the following in your shell to attach to the agent-device daemon:',
'',
`export AGENT_DEVICE_DAEMON_BASE_URL='${remoteConfig.agentDeviceRemoteSessionUrl}'`,
`export AGENT_DEVICE_DAEMON_AUTH_TOKEN='${remoteConfig.agentDeviceRemoteSessionToken}'`,
];
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
const lines =
configType === 'dotenv'
? [
'🔑 Run the following to use agent-device with the simulator:',
'',
'eas simulator:exec agent-device <command>',
]
: [
'🔑 Run the following in your shell to attach to the agent-device daemon:',
'',
...Object.entries(environmentVariables).map(
([key, value]) => `export ${key}='${value}'`
),
];
if (remoteConfig.webPreviewUrl) {
lines.push(
'',
Generated
+14 -43
View File
@@ -789,15 +789,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:7.23.5, @babel/code-frame@npm:^7.23.5":
version: 7.23.5
resolution: "@babel/code-frame@npm:7.23.5"
@@ -808,6 +799,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.12.13":
version: 7.12.13
resolution: "@babel/code-frame@npm:7.12.13"
@@ -3614,16 +3614,15 @@ __metadata:
linkType: soft
"@expo/xcpretty@npm:^4.3.1":
version: 4.3.2
resolution: "@expo/xcpretty@npm:4.3.2"
version: 4.4.4
resolution: "@expo/xcpretty@npm:4.4.4"
dependencies:
"@babel/code-frame": "npm:7.10.4"
"@babel/code-frame": "npm:^7.20.0"
chalk: "npm:^4.1.0"
find-up: "npm:^5.0.0"
js-yaml: "npm:^4.1.0"
bin:
excpretty: build/cli.js
checksum: 10c0/e524817b2e42fb8c8914fca7e8f7c2f723f4f6d338a57b7ae97cd3e76da8108af63a22d4c7dc2e96a192a248a242f6e0f8056f0ca53bc4fb5cd2e5ae428e0891
checksum: 10c0/cd555ad49438dee2cc3f2950ecbef3048f7169bbdadc8db169cfcddaad13668fee6377c010624ed4079dc439c46d6023d2551da403a2070deec000bc864e8dd8
languageName: node
linkType: hard
@@ -12119,16 +12118,6 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
locate-path: "npm:^6.0.0"
path-exists: "npm:^4.0.0"
checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a
languageName: node
linkType: hard
"find-yarn-workspace-root@npm:^2.0.0":
version: 2.0.0
resolution: "find-yarn-workspace-root@npm:2.0.0"
@@ -15375,15 +15364,6 @@ __metadata:
languageName: node
linkType: hard
"locate-path@npm:^6.0.0":
version: 6.0.0
resolution: "locate-path@npm:6.0.0"
dependencies:
p-locate: "npm:^5.0.0"
checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3
languageName: node
linkType: hard
"lodash.clonedeep@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.clonedeep@npm:4.5.0"
@@ -17349,7 +17329,7 @@ __metadata:
languageName: node
linkType: hard
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0":
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.1.0":
version: 3.1.0
resolution: "p-limit@npm:3.1.0"
dependencies:
@@ -17394,15 +17374,6 @@ __metadata:
languageName: node
linkType: hard
"p-locate@npm:^5.0.0":
version: 5.0.0
resolution: "p-locate@npm:5.0.0"
dependencies:
p-limit: "npm:^3.0.2"
checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a
languageName: node
linkType: hard
"p-map-series@npm:2.1.0":
version: 2.1.0
resolution: "p-map-series@npm:2.1.0"