From 2f97321c971971686a1fb66ef568c36dbe656e5a Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 21 May 2026 23:09:37 +0200 Subject: [PATCH 01/11] feat(simulator): add exec config output --- .../commands/simulator/__tests__/exec.test.ts | 61 +++++ .../simulator/__tests__/start.test.ts | 210 ++++++++++++++++++ .../eas-cli/src/commands/simulator/exec.ts | 28 +++ .../eas-cli/src/commands/simulator/start.ts | 93 +++++++- packages/eas-cli/src/simulator/utils.ts | 19 +- 5 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts create mode 100644 packages/eas-cli/src/commands/simulator/__tests__/start.test.ts create mode 100644 packages/eas-cli/src/commands/simulator/exec.ts diff --git a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts new file mode 100644 index 0000000000..3a9fef7b6d --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -0,0 +1,61 @@ +import { load } from '@expo/env'; +import spawnAsync from '@expo/spawn-async'; +import { Config } from '@oclif/core'; + +import SimulatorExec from '../exec'; + +jest.mock('@expo/env', () => ({ + load: 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(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(spawnAsync).mockResolvedValue({} as never); + }); + + it('loads local env files and spawns the supplied command with inherited stdio', async () => { + const command = new SimulatorExec(['agent-device', 'touch', '@e2'], mockConfig); + + await command.runAsync(); + + expect(load).toHaveBeenCalledWith(process.cwd(), { + force: true, + silent: true, + }); + expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], { + stdio: 'inherit', + env: process.env, + }); + }); + + it('passes through command flags as args', async () => { + const command = new SimulatorExec( + ['agent-device', 'screenshot', '/test/path.png', '--format', 'png'], + mockConfig + ); + + await command.runAsync(); + + expect(spawnAsync).toHaveBeenCalledWith( + 'agent-device', + ['screenshot', '/test/path.png', '--format', 'png'], + { + stdio: 'inherit', + env: process.env, + } + ); + }); +}); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts new file mode 100644 index 0000000000..fe6aa22c11 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -0,0 +1,210 @@ +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 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('../../../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 envLocalPath = `${projectDir}/.env.local`; + +const mockCreateDeviceRunSessionAsync = jest.mocked( + DeviceRunSessionMutation.createDeviceRunSessionAsync +); +const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); + +function makeCreatedDeviceRunSession( + overrides: Partial = {} +): 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 { + 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(); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession()); + mockByIdAsync.mockResolvedValue(makeDeviceRunSession()); + jest.mocked(fs.appendFile).mockResolvedValue(undefined as never); + jest.mocked(fs.readFile).mockResolvedValue('' as never); + }); + + 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.appendFile).not.toHaveBeenCalled(); + expect(Log.log).toHaveBeenCalledWith( + expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'") + ); + }); + + it('creates .env.local with the environment variables by default', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(false as never); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(fs.appendFile).toHaveBeenCalledWith( + envLocalPath, + '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(Log.withTick).toHaveBeenCalledWith( + 'Wrote simulator environment variables to .env.local' + ); + expect(Log.log).toHaveBeenCalledWith( + '🔑 Run the following to use agent-device with the simulator:' + ); + expect(Log.log).toHaveBeenCalledWith('eas simulator:exec agent-device '); + expect(Log.log).toHaveBeenCalledWith( + '🌐 Open the following URL in your browser to preview the simulator:' + ); + expect(Log.log).toHaveBeenCalledWith('https://preview.example.com'); + }); + + it('appends the environment variables when outputting dotenv and .env.local exists', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest.mocked(fs.readFile).mockResolvedValue('EXISTING_ENV=1' as never); + + const { command } = createCommand([ + '--platform', + 'ios', + '--non-interactive', + '--out-config-type', + 'dotenv', + ]); + await command.runAsync(); + + expect(fs.readFile).toHaveBeenCalledWith(envLocalPath, 'utf8'); + expect(fs.appendFile).toHaveBeenCalledWith( + envLocalPath, + '\nAGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' + + 'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' + + 'EAS_SIMULATOR_SESSION_ID="session-123"\n' + ); + }); +}); diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts new file mode 100644 index 0000000000..6838393839 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -0,0 +1,28 @@ +import { load } from '@expo/env'; +import spawnAsync from '@expo/spawn-async'; +import pkgDir from 'pkg-dir'; + +import EasCommand from '../../commandUtils/EasCommand'; + +export default class SimulatorExec extends EasCommand { + static override hidden = true; + static override description = + '[EXPERIMENTAL] execute a simulator command with local .env files loaded'; + static override strict = false; + + async runAsync(): Promise { + const projectDir = (await pkgDir(process.cwd())) ?? process.cwd(); + load(projectDir, { force: true, silent: true }); + + const [command, ...args] = this.argv as [string, ...string[]]; + await spawnAsync(command, args, { + stdio: 'inherit', + env: process.env, + }); + } + + // eslint-disable-next-line async-protect/async-suffix + protected override async catch(err: Error): Promise { + process.exitCode = process.exitCode ?? (err as any).status ?? 1; + } +} diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index d7a435b1ee..fec19d8ac2 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -1,4 +1,7 @@ import { Flags } from '@oclif/core'; +import * as fs from 'fs-extra'; +import path from 'path'; +import nullthrows from 'nullthrows'; import { getBareJobRunUrl } from '../../build/utils/url'; import EasCommand from '../../commandUtils/EasCommand'; @@ -22,13 +25,17 @@ import { 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 +57,18 @@ 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.', }), + 'out-config-type': Flags.option({ + description: + 'How to output simulator connection configuration. Use "env" to print shell exports, or "dotenv" to write .env.local.', + 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,6 +82,7 @@ export default class SimulatorStart extends EasCommand { const { projectId, + projectDir, loggedIn: { graphqlClient }, } = await this.getContextAsync(SimulatorStart, { nonInteractive, @@ -156,9 +171,17 @@ export default class SimulatorStart extends EasCommand { return; } - Log.newLine(); - Log.log(formatRemoteSessionInstructions(remoteConfig)); - Log.newLine(); + if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) { + await writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( + projectDir, + remoteConfig, + deviceRunSessionId + ); + } else { + Log.newLine(); + Log.log(formatRemoteSessionInstructions(remoteConfig)); + Log.newLine(); + } if (nonInteractive) { Log.log( @@ -175,6 +198,68 @@ export default class SimulatorStart extends EasCommand { } } +async function writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( + projectDir: string, + remoteConfig: DeviceRunSessionRemoteConfig, + deviceRunSessionId: string +): Promise { + const environmentVariables = { + ...getRemoteSessionEnvironmentVariables(remoteConfig), + EAS_SIMULATOR_SESSION_ID: deviceRunSessionId, + }; + if (Object.keys(environmentVariables).length === 0) { + return; + } + + try { + await appendEnvironmentVariablesToEnvLocalAsync(projectDir, environmentVariables); + Log.newLine(); + Log.withTick('Wrote simulator environment variables to .env.local'); + Log.newLine(); + Log.log('🔑 Run the following to use agent-device with the simulator:'); + Log.newLine(); + Log.log('eas simulator:exec agent-device '); + if ( + remoteConfig.__typename === 'AgentDeviceRunSessionRemoteConfig' && + remoteConfig.webPreviewUrl + ) { + Log.newLine(); + Log.log('🌐 Open the following URL in your browser to preview the simulator:'); + Log.newLine(); + Log.log(remoteConfig.webPreviewUrl); + } + Log.newLine(); + } catch (err) { + Log.warn( + `Failed to write simulator environment variables to .env.local: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } +} + +async function appendEnvironmentVariablesToEnvLocalAsync( + projectDir: string, + environmentVariables: Record +): Promise { + const envLocalPath = path.join(projectDir, '.env.local'); + let prefix = ''; + + if (await fs.pathExists(envLocalPath)) { + const existingContent = await fs.readFile(envLocalPath, 'utf8'); + if (existingContent.length > 0 && !existingContent.endsWith('\n')) { + prefix = '\n'; + } + } + + const envLocalContent = + Object.entries(environmentVariables) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join('\n') + '\n'; + + await fs.appendFile(envLocalPath, prefix + envLocalContent); +} + async function waitForSessionEndOrInterruptAsync({ graphqlClient, deviceRunSessionId, diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index fde3ac5247..d0add9e27e 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -21,16 +21,31 @@ export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): str return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type]; } +export function getRemoteSessionEnvironmentVariables( + remoteConfig: DeviceRunSessionRemoteConfig +): Record { + 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 {}; + } +} + export function formatRemoteSessionInstructions( remoteConfig: DeviceRunSessionRemoteConfig ): string { switch (remoteConfig.__typename) { case 'AgentDeviceRunSessionRemoteConfig': { + const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig); 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}'`, + ...Object.entries(environmentVariables).map(([key, value]) => `export ${key}='${value}'`), ]; if (remoteConfig.webPreviewUrl) { lines.push( From aae7c1f881412eea1fe86ea1d864ff3baf860492 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 25 May 2026 14:37:05 +0200 Subject: [PATCH 02/11] use dedicated .env.eas-simulator instead of .env.local --- .../commands/simulator/__tests__/exec.test.ts | 36 +++++++++++++++++-- .../simulator/__tests__/start.test.ts | 14 ++++---- .../eas-cli/src/commands/simulator/exec.ts | 4 +-- .../eas-cli/src/commands/simulator/start.ts | 27 +++++++------- packages/eas-cli/src/simulator/env.ts | 15 ++++++++ 5 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 packages/eas-cli/src/simulator/env.ts diff --git a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts index 3a9fef7b6d..dda4cfe0c7 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -1,13 +1,15 @@ -import { load } from '@expo/env'; +import { loadProjectEnv } from '@expo/env'; import spawnAsync from '@expo/spawn-async'; import { Config } from '@oclif/core'; +import * as fs from 'fs-extra'; import SimulatorExec from '../exec'; jest.mock('@expo/env', () => ({ - load: jest.fn(), + loadProjectEnv: jest.fn(), })); jest.mock('@expo/spawn-async'); +jest.mock('fs-extra'); function getMockOclifConfig(): Config { const config = new Config({ root: __dirname }); @@ -24,6 +26,8 @@ describe(SimulatorExec, () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(spawnAsync).mockResolvedValue({} as never); + jest.mocked(fs.pathExists).mockResolvedValue(false as never); + jest.mocked(fs.readFile).mockResolvedValue('' as never); }); it('loads local env files and spawns the supplied command with inherited stdio', async () => { @@ -31,10 +35,11 @@ describe(SimulatorExec, () => { await command.runAsync(); - expect(load).toHaveBeenCalledWith(process.cwd(), { + expect(loadProjectEnv).toHaveBeenCalledWith(process.cwd(), { force: true, silent: true, }); + expect(fs.pathExists).toHaveBeenCalledWith(`${process.cwd()}/.env.eas-simulator`); expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], { stdio: 'inherit', env: process.env, @@ -58,4 +63,29 @@ describe(SimulatorExec, () => { } ); }); + + it('loads simulator-specific env after regular env files', async () => { + const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL; + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest + .mocked(fs.readFile) + .mockResolvedValue('AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' as never); + + try { + const command = new SimulatorExec(['agent-device', 'touch', '@e2'], mockConfig); + await command.runAsync(); + + expect(loadProjectEnv).toHaveBeenCalledWith(process.cwd(), { + force: true, + silent: true, + }); + expect(process.env.AGENT_DEVICE_DAEMON_BASE_URL).toBe('https://agent.example.com'); + } finally { + if (previousBaseUrl === undefined) { + delete process.env.AGENT_DEVICE_DAEMON_BASE_URL; + } else { + process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl; + } + } + }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index fe6aa22c11..49c2aedc06 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -47,7 +47,7 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId const graphqlClient = {} as ExpoGraphqlClient; const projectDir = '/test/project'; -const envLocalPath = `${projectDir}/.env.local`; +const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`; const mockCreateDeviceRunSessionAsync = jest.mocked( DeviceRunSessionMutation.createDeviceRunSessionAsync @@ -161,20 +161,20 @@ describe(SimulatorStart, () => { ); }); - it('creates .env.local with the environment variables by default', async () => { + it('creates .env.eas-simulator with the environment variables by default', async () => { jest.mocked(fs.pathExists).mockResolvedValue(false as never); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); await command.runAsync(); expect(fs.appendFile).toHaveBeenCalledWith( - envLocalPath, + simulatorDotenvPath, '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(Log.withTick).toHaveBeenCalledWith( - 'Wrote simulator environment variables to .env.local' + 'Wrote simulator environment variables to .env.eas-simulator' ); expect(Log.log).toHaveBeenCalledWith( '🔑 Run the following to use agent-device with the simulator:' @@ -186,7 +186,7 @@ describe(SimulatorStart, () => { expect(Log.log).toHaveBeenCalledWith('https://preview.example.com'); }); - it('appends the environment variables when outputting dotenv and .env.local exists', async () => { + it('appends the environment variables when outputting dotenv and .env.eas-simulator exists', async () => { jest.mocked(fs.pathExists).mockResolvedValue(true as never); jest.mocked(fs.readFile).mockResolvedValue('EXISTING_ENV=1' as never); @@ -199,9 +199,9 @@ describe(SimulatorStart, () => { ]); await command.runAsync(); - expect(fs.readFile).toHaveBeenCalledWith(envLocalPath, 'utf8'); + expect(fs.readFile).toHaveBeenCalledWith(simulatorDotenvPath, 'utf8'); expect(fs.appendFile).toHaveBeenCalledWith( - envLocalPath, + simulatorDotenvPath, '\nAGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' + 'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' + 'EAS_SIMULATOR_SESSION_ID="session-123"\n' diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index 6838393839..00641bdacd 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -1,8 +1,8 @@ -import { load } from '@expo/env'; import spawnAsync from '@expo/spawn-async'; import pkgDir from 'pkg-dir'; import EasCommand from '../../commandUtils/EasCommand'; +import { loadSimulatorEnvironmentVariablesAsync } from '../../simulator/env'; export default class SimulatorExec extends EasCommand { static override hidden = true; @@ -12,7 +12,7 @@ export default class SimulatorExec extends EasCommand { async runAsync(): Promise { const projectDir = (await pkgDir(process.cwd())) ?? process.cwd(); - load(projectDir, { force: true, silent: true }); + await loadSimulatorEnvironmentVariablesAsync(projectDir); const [command, ...args] = this.argv as [string, ...string[]]; await spawnAsync(command, args, { diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index fec19d8ac2..eb069e4b63 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -1,6 +1,5 @@ import { Flags } from '@oclif/core'; import * as fs from 'fs-extra'; -import path from 'path'; import nullthrows from 'nullthrows'; import { getBareJobRunUrl } from '../../build/utils/url'; @@ -20,6 +19,7 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; +import { SIMULATOR_DOTENV_FILE_NAME, getSimulatorDotenvFilePath } from '../../simulator/env'; import { DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE, DEVICE_RUN_SESSION_TYPE_FLAG_VALUES, @@ -58,8 +58,7 @@ export default class SimulatorStart extends EasCommand { 'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.', }), 'out-config-type': Flags.option({ - description: - 'How to output simulator connection configuration. Use "env" to print shell exports, or "dotenv" to write .env.local.', + 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, })(), @@ -172,7 +171,7 @@ export default class SimulatorStart extends EasCommand { } if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) { - await writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( + await writeRemoteSessionEnvironmentVariablesToSimulatorDotenvSafelyAsync( projectDir, remoteConfig, deviceRunSessionId @@ -198,7 +197,7 @@ export default class SimulatorStart extends EasCommand { } } -async function writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( +async function writeRemoteSessionEnvironmentVariablesToSimulatorDotenvSafelyAsync( projectDir: string, remoteConfig: DeviceRunSessionRemoteConfig, deviceRunSessionId: string @@ -212,9 +211,9 @@ async function writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( } try { - await appendEnvironmentVariablesToEnvLocalAsync(projectDir, environmentVariables); + await appendEnvironmentVariablesToSimulatorDotenvAsync(projectDir, environmentVariables); Log.newLine(); - Log.withTick('Wrote simulator environment variables to .env.local'); + Log.withTick(`Wrote simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}`); Log.newLine(); Log.log('🔑 Run the following to use agent-device with the simulator:'); Log.newLine(); @@ -231,33 +230,33 @@ async function writeRemoteSessionEnvironmentVariablesToEnvLocalSafelyAsync( Log.newLine(); } catch (err) { Log.warn( - `Failed to write simulator environment variables to .env.local: ${ + `Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}: ${ err instanceof Error ? err.message : String(err) }` ); } } -async function appendEnvironmentVariablesToEnvLocalAsync( +async function appendEnvironmentVariablesToSimulatorDotenvAsync( projectDir: string, environmentVariables: Record ): Promise { - const envLocalPath = path.join(projectDir, '.env.local'); + const simulatorDotenvFilePath = getSimulatorDotenvFilePath(projectDir); let prefix = ''; - if (await fs.pathExists(envLocalPath)) { - const existingContent = await fs.readFile(envLocalPath, 'utf8'); + if (await fs.pathExists(simulatorDotenvFilePath)) { + const existingContent = await fs.readFile(simulatorDotenvFilePath, 'utf8'); if (existingContent.length > 0 && !existingContent.endsWith('\n')) { prefix = '\n'; } } - const envLocalContent = + const simulatorDotenvContent = Object.entries(environmentVariables) .map(([key, value]) => `${key}=${JSON.stringify(value)}`) .join('\n') + '\n'; - await fs.appendFile(envLocalPath, prefix + envLocalContent); + await fs.appendFile(simulatorDotenvFilePath, prefix + simulatorDotenvContent); } async function waitForSessionEndOrInterruptAsync({ diff --git a/packages/eas-cli/src/simulator/env.ts b/packages/eas-cli/src/simulator/env.ts new file mode 100644 index 0000000000..1b81e3f429 --- /dev/null +++ b/packages/eas-cli/src/simulator/env.ts @@ -0,0 +1,15 @@ +import { loadProjectEnv, loadEnvFiles } from '@expo/env'; +import path from 'path'; + +export const SIMULATOR_DOTENV_FILE_NAME = '.env.eas-simulator'; + +export function getSimulatorDotenvFilePath(projectDir: string): string { + return path.join(projectDir, SIMULATOR_DOTENV_FILE_NAME); +} + +export async function loadSimulatorEnvironmentVariablesAsync(projectDir: string): Promise { + const simulatorDotenvFilePath = getSimulatorDotenvFilePath(projectDir); + + loadProjectEnv(projectDir); + loadEnvFiles([simulatorDotenvFilePath], { force: true }); +} From eb5268c1699a669cbd443c3ad786f05d8758d380 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 25 May 2026 16:56:42 +0200 Subject: [PATCH 03/11] feat(simulator): persist session env config --- .../commands/simulator/__tests__/exec.test.ts | 71 ++++---- .../commands/simulator/__tests__/get.test.ts | 31 ++++ .../simulator/__tests__/start.test.ts | 151 +++++++++++++++--- .../commands/simulator/__tests__/stop.test.ts | 127 +++++++++++++++ .../eas-cli/src/commands/simulator/exec.ts | 16 +- .../eas-cli/src/commands/simulator/get.ts | 22 ++- .../eas-cli/src/commands/simulator/start.ts | 129 ++++++++------- .../eas-cli/src/commands/simulator/stop.ts | 33 ++-- .../src/simulator/__tests__/env.test.ts | 76 +++++++++ packages/eas-cli/src/simulator/env.ts | 44 ++++- packages/eas-cli/src/simulator/utils.ts | 24 ++- 11 files changed, 569 insertions(+), 155 deletions(-) create mode 100644 packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts create mode 100644 packages/eas-cli/src/simulator/__tests__/env.test.ts diff --git a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts index dda4cfe0c7..f0b0ac1657 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -1,15 +1,14 @@ -import { loadProjectEnv } from '@expo/env'; +import { loadEnvFiles, loadProjectEnv } from '@expo/env'; import spawnAsync from '@expo/spawn-async'; import { Config } from '@oclif/core'; -import * as fs from 'fs-extra'; import SimulatorExec from '../exec'; jest.mock('@expo/env', () => ({ + loadEnvFiles: jest.fn(), loadProjectEnv: jest.fn(), })); jest.mock('@expo/spawn-async'); -jest.mock('fs-extra'); function getMockOclifConfig(): Config { const config = new Config({ root: __dirname }); @@ -22,24 +21,37 @@ function getMockOclifConfig(): Config { describe(SimulatorExec, () => { const mockConfig = getMockOclifConfig(); + const projectDir = '/test/project'; beforeEach(() => { jest.clearAllMocks(); jest.mocked(spawnAsync).mockResolvedValue({} as never); - jest.mocked(fs.pathExists).mockResolvedValue(false as never); - jest.mocked(fs.readFile).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 = new SimulatorExec(['agent-device', 'touch', '@e2'], mockConfig); + const { command, getContextAsync } = createCommand(['agent-device', 'touch', '@e2']); await command.runAsync(); - expect(loadProjectEnv).toHaveBeenCalledWith(process.cwd(), { + expect(getContextAsync).toHaveBeenCalledWith(SimulatorExec, { + nonInteractive: true, + }); + expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true }); + expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], { force: true, - silent: true, }); - expect(fs.pathExists).toHaveBeenCalledWith(`${process.cwd()}/.env.eas-simulator`); expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], { stdio: 'inherit', env: process.env, @@ -47,10 +59,13 @@ describe(SimulatorExec, () => { }); it('passes through command flags as args', async () => { - const command = new SimulatorExec( - ['agent-device', 'screenshot', '/test/path.png', '--format', 'png'], - mockConfig - ); + const { command } = createCommand([ + 'agent-device', + 'screenshot', + '/test/path.png', + '--format', + 'png', + ]); await command.runAsync(); @@ -65,27 +80,15 @@ describe(SimulatorExec, () => { }); it('loads simulator-specific env after regular env files', async () => { - const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL; - jest.mocked(fs.pathExists).mockResolvedValue(true as never); - jest - .mocked(fs.readFile) - .mockResolvedValue('AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' as never); - - try { - const command = new SimulatorExec(['agent-device', 'touch', '@e2'], mockConfig); - await command.runAsync(); + const { command } = createCommand(['agent-device', 'touch', '@e2']); + await command.runAsync(); - expect(loadProjectEnv).toHaveBeenCalledWith(process.cwd(), { - force: true, - silent: true, - }); - expect(process.env.AGENT_DEVICE_DAEMON_BASE_URL).toBe('https://agent.example.com'); - } finally { - if (previousBaseUrl === undefined) { - delete process.env.AGENT_DEVICE_DAEMON_BASE_URL; - } else { - process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl; - } - } + 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] + ); }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts index b1c6510084..a9cd350e99 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -8,11 +8,16 @@ import { JobRunStatus, } from '../../../graphql/generated'; import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery'; +import { EAS_SIMULATOR_SESSION_ID, 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 +35,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 { @@ -71,9 +77,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 +92,7 @@ describe(SimulatorGet, () => { // @ts-expect-error getContextAsync is protected const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient }, + projectDir, }); return { command, getContextAsync }; } @@ -96,6 +105,7 @@ describe(SimulatorGet, () => { await command.runAsync(); expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir); expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, { nonInteractive: true, }); @@ -108,4 +118,25 @@ 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; + } + } + }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index 49c2aedc06..4ff7dfbc63 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -13,6 +13,14 @@ import { 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'); @@ -29,6 +37,11 @@ jest.mock('../../../log', () => ({ }, 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 = { @@ -48,11 +61,15 @@ 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 = {} @@ -113,13 +130,24 @@ function getMockOclifConfig(): 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()); - jest.mocked(fs.appendFile).mockResolvedValue(undefined as never); - jest.mocked(fs.readFile).mockResolvedValue('' as never); + 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[]): { @@ -155,41 +183,54 @@ describe(SimulatorStart, () => { platform: AppPlatform.Ios, type: DeviceRunSessionType.AgentDevice, }); - expect(fs.appendFile).not.toHaveBeenCalled(); + 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('creates .env.eas-simulator with the environment variables by default', async () => { - jest.mocked(fs.pathExists).mockResolvedValue(false as never); - + it('writes .env.eas-simulator with the environment variables by default', async () => { const { command } = createCommand(['--platform', 'ios', '--non-interactive']); await command.runAsync(); - expect(fs.appendFile).toHaveBeenCalledWith( + expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir); + expect(fs.writeFile).toHaveBeenNthCalledWith( + 1, simulatorDotenvPath, - 'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' + + 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' + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n` ); - expect(Log.withTick).toHaveBeenCalledWith( - 'Wrote simulator environment variables to .env.eas-simulator' + expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan( + mockByIdAsync.mock.invocationCallOrder[0] ); - expect(Log.log).toHaveBeenCalledWith( - '🔑 Run the following to use agent-device with the simulator:' + expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith( + `Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}` ); - expect(Log.log).toHaveBeenCalledWith('eas simulator:exec agent-device '); + expect(Log.withTick).not.toHaveBeenCalled(); expect(Log.log).toHaveBeenCalledWith( - '🌐 Open the following URL in your browser to preview the simulator:' + [ + '🔑 Run the following to use agent-device with the simulator:', + '', + 'eas simulator:exec agent-device ', + '', + '🌐 Open the following URL in your browser to preview the simulator:', + '', + 'https://preview.example.com', + ].join('\n') ); - expect(Log.log).toHaveBeenCalledWith('https://preview.example.com'); }); - it('appends the environment variables when outputting dotenv and .env.eas-simulator exists', async () => { - jest.mocked(fs.pathExists).mockResolvedValue(true as never); - jest.mocked(fs.readFile).mockResolvedValue('EXISTING_ENV=1' as never); - + it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => { const { command } = createCommand([ '--platform', 'ios', @@ -199,12 +240,74 @@ describe(SimulatorStart, () => { ]); await command.runAsync(); - expect(fs.readFile).toHaveBeenCalledWith(simulatorDotenvPath, 'utf8'); - expect(fs.appendFile).toHaveBeenCalledWith( + expect(fs.writeFile).toHaveBeenNthCalledWith( + 1, + simulatorDotenvPath, + SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n` + ); + expect(fs.writeFile).toHaveBeenNthCalledWith( + 2, simulatorDotenvPath, - '\nAGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' + + 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' + `${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).' + ); + 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).' + ); + 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); + }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts new file mode 100644 index 0000000000..e363efb5d2 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts @@ -0,0 +1,127 @@ +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, 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 { + 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; + } + } + }); +}); diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index 00641bdacd..4da3253272 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -1,18 +1,22 @@ import spawnAsync from '@expo/spawn-async'; -import pkgDir from 'pkg-dir'; import EasCommand from '../../commandUtils/EasCommand'; -import { loadSimulatorEnvironmentVariablesAsync } from '../../simulator/env'; +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 local .env files loaded'; + 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, + }; + async runAsync(): Promise { - const projectDir = (await pkgDir(process.cwd())) ?? process.cwd(); - await loadSimulatorEnvironmentVariablesAsync(projectDir); + const { projectDir } = await this.getContextAsync(SimulatorExec, { + nonInteractive: true, + }); + await loadSimulatorEnvAsync(projectDir); const [command, ...args] = this.argv as [string, ...string[]]; await spawnAsync(command, args, { diff --git a/packages/eas-cli/src/commands/simulator/get.ts b/packages/eas-cli/src/commands/simulator/get.ts index 6a829b4c5e..26490495fd 100644 --- a/packages/eas-cli/src/commands/simulator/get.ts +++ b/packages/eas-cli/src/commands/simulator/get.ts @@ -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 { @@ -42,18 +47,25 @@ 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('Missing required flag id'); + } + + 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; } diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index eb069e4b63..1c5c2171b9 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -1,5 +1,4 @@ import { Flags } from '@oclif/core'; -import * as fs from 'fs-extra'; import nullthrows from 'nullthrows'; import { getBareJobRunUrl } from '../../build/utils/url'; @@ -19,7 +18,13 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; -import { SIMULATOR_DOTENV_FILE_NAME, getSimulatorDotenvFilePath } from '../../simulator/env'; +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, @@ -57,6 +62,12 @@ 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), @@ -87,6 +98,18 @@ export default class SimulatorStart extends EasCommand { 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}).`); + Log.newLine(); + } + const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios; const createSpinner = ora('🚀 Creating device run session').start(); @@ -102,8 +125,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'); @@ -160,6 +191,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, @@ -170,17 +208,9 @@ export default class SimulatorStart extends EasCommand { return; } - if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) { - await writeRemoteSessionEnvironmentVariablesToSimulatorDotenvSafelyAsync( - projectDir, - remoteConfig, - deviceRunSessionId - ); - } else { - Log.newLine(); - Log.log(formatRemoteSessionInstructions(remoteConfig)); - Log.newLine(); - } + Log.newLine(); + Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'])); + Log.newLine(); if (nonInteractive) { Log.log( @@ -193,80 +223,38 @@ export default class SimulatorStart extends EasCommand { graphqlClient, deviceRunSessionId, jobRunUrl, + projectDir, }); } } -async function writeRemoteSessionEnvironmentVariablesToSimulatorDotenvSafelyAsync( +async function writeSimulatorEnvSafelyAsync( projectDir: string, - remoteConfig: DeviceRunSessionRemoteConfig, - deviceRunSessionId: string -): Promise { - const environmentVariables = { - ...getRemoteSessionEnvironmentVariables(remoteConfig), - EAS_SIMULATOR_SESSION_ID: deviceRunSessionId, - }; - if (Object.keys(environmentVariables).length === 0) { - return; - } - + environmentVariables: Record +): Promise { try { - await appendEnvironmentVariablesToSimulatorDotenvAsync(projectDir, environmentVariables); - Log.newLine(); - Log.withTick(`Wrote simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}`); - Log.newLine(); - Log.log('🔑 Run the following to use agent-device with the simulator:'); - Log.newLine(); - Log.log('eas simulator:exec agent-device '); - if ( - remoteConfig.__typename === 'AgentDeviceRunSessionRemoteConfig' && - remoteConfig.webPreviewUrl - ) { - Log.newLine(); - Log.log('🌐 Open the following URL in your browser to preview the simulator:'); - Log.newLine(); - Log.log(remoteConfig.webPreviewUrl); - } - Log.newLine(); + 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 appendEnvironmentVariablesToSimulatorDotenvAsync( - projectDir: string, - environmentVariables: Record -): Promise { - const simulatorDotenvFilePath = getSimulatorDotenvFilePath(projectDir); - let prefix = ''; - - if (await fs.pathExists(simulatorDotenvFilePath)) { - const existingContent = await fs.readFile(simulatorDotenvFilePath, 'utf8'); - if (existingContent.length > 0 && !existingContent.endsWith('\n')) { - prefix = '\n'; - } - } - - const simulatorDotenvContent = - Object.entries(environmentVariables) - .map(([key, value]) => `${key}=${JSON.stringify(value)}`) - .join('\n') + '\n'; - - await fs.appendFile(simulatorDotenvFilePath, prefix + simulatorDotenvContent); -} - async function waitForSessionEndOrInterruptAsync({ graphqlClient, deviceRunSessionId, jobRunUrl, + projectDir, }: { graphqlClient: ExpoGraphqlClient; deviceRunSessionId: string; jobRunUrl: string; + projectDir: string; }): Promise { const spinner = ora( `Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell` @@ -323,6 +311,7 @@ async function waitForSessionEndOrInterruptAsync({ jobRunStatus === JobRunStatus.Finished ) { spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`); + await resetSimulatorEnvSafelyAsync(projectDir); return; } @@ -336,6 +325,7 @@ async function waitForSessionEndOrInterruptAsync({ ); if (stopped) { spinner.succeed('Device run session stopped'); + await resetSimulatorEnvSafelyAsync(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.` @@ -346,6 +336,15 @@ async function waitForSessionEndOrInterruptAsync({ } } +async function resetSimulatorEnvSafelyAsync(projectDir: string): Promise { + 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 diff --git a/packages/eas-cli/src/commands/simulator/stop.ts b/packages/eas-cli/src/commands/simulator/stop.ts index ff9224ee91..052a9a55cc 100644 --- a/packages/eas-cli/src/commands/simulator/stop.ts +++ b/packages/eas-cli/src/commands/simulator/stop.ts @@ -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 { @@ -35,25 +40,33 @@ 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('Missing required flag id'); + } + + 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 }); + } } } diff --git a/packages/eas-cli/src/simulator/__tests__/env.test.ts b/packages/eas-cli/src/simulator/__tests__/env.test.ts new file mode 100644 index 0000000000..a547662dbb --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/env.test.ts @@ -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` + ); + }); +}); diff --git a/packages/eas-cli/src/simulator/env.ts b/packages/eas-cli/src/simulator/env.ts index 1b81e3f429..bba1ef9c66 100644 --- a/packages/eas-cli/src/simulator/env.ts +++ b/packages/eas-cli/src/simulator/env.ts @@ -1,15 +1,49 @@ -import { loadProjectEnv, loadEnvFiles } from '@expo/env'; +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# It holds configuration only for the current simulator session.\n\n'; -export function getSimulatorDotenvFilePath(projectDir: string): string { +export function getSimulatorEnvFilePath(projectDir: string): string { return path.join(projectDir, SIMULATOR_DOTENV_FILE_NAME); } -export async function loadSimulatorEnvironmentVariablesAsync(projectDir: string): Promise { - const simulatorDotenvFilePath = getSimulatorDotenvFilePath(projectDir); +export async function loadSimulatorEnvAsync(projectDir: string): Promise { + const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir); - loadProjectEnv(projectDir); + loadProjectEnv(projectDir, { silent: true }); loadEnvFiles([simulatorDotenvFilePath], { force: true }); } + +export async function writeSimulatorEnvAsync( + projectDir: string, + environmentVariables: Record +): Promise { + 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 { + 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; + } +} diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index d0add9e27e..6e9d5359f4 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -36,17 +36,29 @@ export function getRemoteSessionEnvironmentVariables( } } +type RemoteSessionInstructionsConfigType = 'env' | 'dotenv'; + export function formatRemoteSessionInstructions( - remoteConfig: DeviceRunSessionRemoteConfig + remoteConfig: DeviceRunSessionRemoteConfig, + configType: RemoteSessionInstructionsConfigType = 'env' ): string { switch (remoteConfig.__typename) { case 'AgentDeviceRunSessionRemoteConfig': { const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig); - const lines = [ - '🔑 Run the following in your shell to attach to the agent-device daemon:', - '', - ...Object.entries(environmentVariables).map(([key, value]) => `export ${key}='${value}'`), - ]; + const lines = + configType === 'dotenv' + ? [ + '🔑 Run the following to use agent-device with the simulator:', + '', + 'eas simulator:exec agent-device ', + ] + : [ + '🔑 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( '', From 0bd8939713ebc1d9dafddf978aac5c4fae80e265 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 27 May 2026 16:57:46 +0200 Subject: [PATCH 04/11] refactor(simulator): simplify exec catch --- packages/eas-cli/src/commands/simulator/exec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index 4da3253272..d1b60ba11c 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -25,8 +25,9 @@ export default class SimulatorExec extends EasCommand { }); } - // eslint-disable-next-line async-protect/async-suffix - protected override async catch(err: Error): Promise { + protected override catch(err: Error): Promise { + // Propagate wrapped command from spawnAsync rejection process.exitCode = process.exitCode ?? (err as any).status ?? 1; + return Promise.resolve(); } } From 9f0866e11ce75bf13ab5737d2bb9d1cab7bbed43 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 27 May 2026 17:04:00 +0200 Subject: [PATCH 05/11] add do not edit to the env header --- packages/eas-cli/src/simulator/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/simulator/env.ts b/packages/eas-cli/src/simulator/env.ts index bba1ef9c66..046144f8bf 100644 --- a/packages/eas-cli/src/simulator/env.ts +++ b/packages/eas-cli/src/simulator/env.ts @@ -5,7 +5,7 @@ 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# It holds configuration only for the current simulator session.\n\n'; + '# 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); From 8a0a07ae01817844d9a6447654b3176c58371218 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 27 May 2026 17:11:40 +0200 Subject: [PATCH 06/11] remove default config type --- packages/eas-cli/src/commands/simulator/get.ts | 2 +- packages/eas-cli/src/simulator/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/get.ts b/packages/eas-cli/src/commands/simulator/get.ts index 26490495fd..6a9da228f2 100644 --- a/packages/eas-cli/src/commands/simulator/get.ts +++ b/packages/eas-cli/src/commands/simulator/get.ts @@ -93,7 +93,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.' diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index 6e9d5359f4..0b83318552 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -40,7 +40,7 @@ type RemoteSessionInstructionsConfigType = 'env' | 'dotenv'; export function formatRemoteSessionInstructions( remoteConfig: DeviceRunSessionRemoteConfig, - configType: RemoteSessionInstructionsConfigType = 'env' + configType: RemoteSessionInstructionsConfigType ): string { switch (remoteConfig.__typename) { case 'AgentDeviceRunSessionRemoteConfig': { From 6673ee5f01b94eff172b92bb5c15f9e943c62a4c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 28 May 2026 15:32:57 +0200 Subject: [PATCH 07/11] improve missing id flag message Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/eas-cli/src/commands/simulator/get.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/simulator/get.ts b/packages/eas-cli/src/commands/simulator/get.ts index 6a9da228f2..70f35de535 100644 --- a/packages/eas-cli/src/commands/simulator/get.ts +++ b/packages/eas-cli/src/commands/simulator/get.ts @@ -56,7 +56,9 @@ export default class SimulatorGet extends EasCommand { await loadSimulatorEnvAsync(projectDir); const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID]; if (!flagId) { - throw new Error('Missing required flag id'); + 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(); From 46944a0881eaabbb65117f5e58f8d2c86c1e215f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:09:04 +0200 Subject: [PATCH 08/11] review fixes --- .../commands/simulator/__tests__/get.test.ts | 27 ++++++++++++++++++- .../simulator/__tests__/start.test.ts | 8 ++++-- .../commands/simulator/__tests__/stop.test.ts | 27 ++++++++++++++++++- .../eas-cli/src/commands/simulator/start.ts | 12 ++++++--- .../eas-cli/src/commands/simulator/stop.ts | 4 ++- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts index a9cd350e99..b92e51cd2d 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -8,7 +8,11 @@ import { JobRunStatus, } from '../../../graphql/generated'; import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery'; -import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env'; +import { + EAS_SIMULATOR_SESSION_ID, + SIMULATOR_DOTENV_FILE_NAME, + loadSimulatorEnvAsync, +} from '../../../simulator/env'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import SimulatorGet from '../get'; @@ -139,4 +143,25 @@ describe(SimulatorGet, () => { } } }); + + 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; + } + } + }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index 4ff7dfbc63..309b257961 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -262,7 +262,9 @@ describe(SimulatorStart, () => { await command.runAsync(); expect(Log.warn).toHaveBeenCalledWith( - ' Overwriting previous simulator session (id: existing-session).' + ' 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', @@ -279,7 +281,9 @@ describe(SimulatorStart, () => { await command.runAsync(); expect(Log.warn).toHaveBeenCalledWith( - ' Overwriting previous simulator session (id: existing-session).' + ' 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', diff --git a/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts index e363efb5d2..45cc2f2eb8 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts @@ -6,7 +6,11 @@ import { EnsureDeviceRunSessionStoppedMutation, } from '../../../graphql/generated'; import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation'; -import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env'; +import { + EAS_SIMULATOR_SESSION_ID, + SIMULATOR_DOTENV_FILE_NAME, + loadSimulatorEnvAsync, +} from '../../../simulator/env'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import SimulatorStop from '../stop'; @@ -124,4 +128,25 @@ describe(SimulatorStop, () => { } } }); + + 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; + } + } + }); }); diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index 1c5c2171b9..aa538efd93 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -106,7 +106,11 @@ export default class SimulatorStart extends EasCommand { ); } if (existingDeviceRunSessionId) { - Log.warn(` Overwriting previous simulator session (id: ${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(); } @@ -311,7 +315,7 @@ async function waitForSessionEndOrInterruptAsync({ jobRunStatus === JobRunStatus.Finished ) { spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`); - await resetSimulatorEnvSafelyAsync(projectDir); + await resetSimulatorEnvAsyncWithLog(projectDir); return; } @@ -325,7 +329,7 @@ async function waitForSessionEndOrInterruptAsync({ ); if (stopped) { spinner.succeed('Device run session stopped'); - await resetSimulatorEnvSafelyAsync(projectDir); + await resetSimulatorEnvAsyncWithLog(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.` @@ -336,7 +340,7 @@ async function waitForSessionEndOrInterruptAsync({ } } -async function resetSimulatorEnvSafelyAsync(projectDir: string): Promise { +async function resetSimulatorEnvAsyncWithLog(projectDir: string): Promise { try { await resetSimulatorEnvAsync(projectDir); } catch (err) { diff --git a/packages/eas-cli/src/commands/simulator/stop.ts b/packages/eas-cli/src/commands/simulator/stop.ts index 052a9a55cc..d009a9676d 100644 --- a/packages/eas-cli/src/commands/simulator/stop.ts +++ b/packages/eas-cli/src/commands/simulator/stop.ts @@ -49,7 +49,9 @@ export default class SimulatorStop extends EasCommand { await loadSimulatorEnvAsync(projectDir); const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID]; if (!flagId) { - throw new Error('Missing required flag id'); + 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(); From eb5877827fc382f4813b3a7b0748d04a06ac10fd Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:13:04 +0200 Subject: [PATCH 09/11] fix empty command exec --- .../commands/simulator/__tests__/exec.test.ts | 12 ++++++++++++ packages/eas-cli/src/commands/simulator/exec.ts | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts index f0b0ac1657..77cef3461f 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -79,6 +79,18 @@ describe(SimulatorExec, () => { ); }); + 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 [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(); diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index d1b60ba11c..a11b174189 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -12,13 +12,20 @@ export default class SimulatorExec extends EasCommand { ...this.ContextOptions.ProjectDir, }; + private isRunningSubprocess = false; + async runAsync(): Promise { + const [command, ...args] = this.argv; + if (!command) { + throw new Error('No command provided. Run `eas simulator:exec [args...]`.'); + } + const { projectDir } = await this.getContextAsync(SimulatorExec, { nonInteractive: true, }); await loadSimulatorEnvAsync(projectDir); - const [command, ...args] = this.argv as [string, ...string[]]; + this.isRunningSubprocess = true; await spawnAsync(command, args, { stdio: 'inherit', env: process.env, @@ -27,7 +34,10 @@ export default class SimulatorExec extends EasCommand { protected override catch(err: Error): Promise { // Propagate wrapped command from spawnAsync rejection - process.exitCode = process.exitCode ?? (err as any).status ?? 1; - return Promise.resolve(); + if (this.isRunningSubprocess) { + process.exitCode = process.exitCode ?? (err as any).status ?? 1; + return Promise.resolve(); + } + return super.catch(err); } } From 49985f16a3ee19aa35168882740e3fe0f0b2bd8a Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:17:45 +0200 Subject: [PATCH 10/11] fix lint naming async --- packages/eas-cli/src/commands/simulator/exec.ts | 5 +++-- packages/eas-cli/src/commands/simulator/start.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index a11b174189..4fcf3ad97f 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -15,8 +15,9 @@ export default class SimulatorExec extends EasCommand { private isRunningSubprocess = false; async runAsync(): Promise { - const [command, ...args] = this.argv; - if (!command) { + const { argv } = await this.parse(SimulatorExec); + const [command, ...args] = argv as string[]; + if (typeof command !== 'string' || command.length === 0) { throw new Error('No command provided. Run `eas simulator:exec [args...]`.'); } diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index aa538efd93..5ae77e8f73 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -315,7 +315,7 @@ async function waitForSessionEndOrInterruptAsync({ jobRunStatus === JobRunStatus.Finished ) { spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`); - await resetSimulatorEnvAsyncWithLog(projectDir); + await resetSimulatorEnvVerboseAsync(projectDir); return; } @@ -329,7 +329,7 @@ async function waitForSessionEndOrInterruptAsync({ ); if (stopped) { spinner.succeed('Device run session stopped'); - await resetSimulatorEnvAsyncWithLog(projectDir); + 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.` @@ -340,7 +340,7 @@ async function waitForSessionEndOrInterruptAsync({ } } -async function resetSimulatorEnvAsyncWithLog(projectDir: string): Promise { +async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise { try { await resetSimulatorEnvAsync(projectDir); } catch (err) { From c5d3c1a7fbdd9618b208e565830f3420b9000797 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:28:15 +0200 Subject: [PATCH 11/11] fix parse warn in exec --- .../commands/simulator/__tests__/exec.test.ts | 28 +++++++++++++++++++ .../eas-cli/src/commands/simulator/exec.ts | 7 +++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts index 77cef3461f..a6959f326e 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -79,6 +79,34 @@ describe(SimulatorExec, () => { ); }); + 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([]); diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index 4fcf3ad97f..ffb1fece64 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -15,8 +15,11 @@ export default class SimulatorExec extends EasCommand { private isRunningSubprocess = false; async runAsync(): Promise { - const { argv } = await this.parse(SimulatorExec); - const [command, ...args] = argv as string[]; + 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 [args...]`.'); }