mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca33ad191 | ||
|
|
14d5cb1ac5 | ||
|
|
543261b68a | ||
|
|
e1148f2b00 | ||
|
|
2c1837f8ba | ||
|
|
7bdc009c5e | ||
|
|
b5592e16ad |
No files matched your search
+13
-2
@@ -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.');
|
||||
|
||||
@@ -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' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
+18
-3
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
'',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user