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..a6959f326e --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/exec.test.ts @@ -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 [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] + ); + }); +}); 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..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,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 { @@ -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; + } + } + }); }); 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..309b257961 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -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 { + 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(); + 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 ', + '', + '🌐 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); + }); +}); 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..45cc2f2eb8 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts @@ -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 { + 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; + } + } + }); +}); 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..ffb1fece64 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -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 { + 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...]`.'); + } + + 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 { + // 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); + } +} diff --git a/packages/eas-cli/src/commands/simulator/get.ts b/packages/eas-cli/src/commands/simulator/get.ts index 6a829b4c5e..70f35de535 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,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.' diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index d7a435b1ee..5ae77e8f73 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -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 +): Promise { + 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 { 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 { + 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..d009a9676d 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,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 }); + } } } 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 new file mode 100644 index 0000000000..046144f8bf --- /dev/null +++ b/packages/eas-cli/src/simulator/env.ts @@ -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 { + const simulatorDotenvFilePath = getSimulatorEnvFilePath(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 fde3ac5247..0b83318552 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -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 { + 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 ', + ] + : [ + '🔑 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( '',