diff --git a/CHANGELOG.md b/CHANGELOG.md index c7df0d282..320ae807c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas simulator:feedback` to send EAS Simulator feedback to the Expo team through `submit-expo-feedback`, attaching the active session ID. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@zvadaadam](https://github.com/zvadaadam)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/simulator/__tests__/feedback.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/feedback.test.ts new file mode 100644 index 000000000..86de9ba04 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/feedback.test.ts @@ -0,0 +1,164 @@ +import spawnAsync from '@expo/spawn-async'; +import { Config } from '@oclif/core'; + +import { EAS_SIMULATOR_SESSION_ID } from '../../../simulator/env'; +import SimulatorFeedback from '../feedback'; + +jest.mock('@expo/spawn-async'); + +const mockSpawnAsync = jest.mocked(spawnAsync); + +function getMockOclifConfig(): Config { + const config = new Config({ root: __dirname }); + config.runHook = async () => ({ + failures: [], + successes: [], + }); + return config; +} + +describe(SimulatorFeedback, () => { + const mockConfig = getMockOclifConfig(); + const projectDir = '/test/project'; + const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID]; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env[EAS_SIMULATOR_SESSION_ID]; + mockSpawnAsync.mockResolvedValue({} 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: SimulatorFeedback; + getContextAsync: jest.SpyInstance; + } { + const command = new SimulatorFeedback(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({ + projectDir, + }); + return { command, getContextAsync }; + } + + it('submits feedback through submit-expo-feedback with the simulator category', async () => { + const { command } = createCommand([ + 'Sessions boot fast, but exec feels slow', + '--non-interactive', + ]); + await command.runAsync(); + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'submit-expo-feedback@latest', + '--category', + 'simulator', + 'Sessions boot fast, but exec feels slow', + ], + expect.objectContaining({ + cwd: projectDir, + stdio: 'inherit', + }) + ); + }); + + it(`passes an inherited ${EAS_SIMULATOR_SESSION_ID} through to the subprocess`, async () => { + process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env'; + + const { command } = createCommand(['Great product', '--non-interactive']); + await command.runAsync(); + + const spawnOptions = mockSpawnAsync.mock.calls[0][2]!; + expect(spawnOptions.env?.[EAS_SIMULATOR_SESSION_ID]).toBe('session-from-env'); + }); + + it('prefers --id over an inherited session variable', async () => { + process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env'; + + const { command } = createCommand([ + 'Great product', + '--id', + 'session-from-flag', + '--non-interactive', + ]); + await command.runAsync(); + + const spawnOptions = mockSpawnAsync.mock.calls[0][2]!; + expect(spawnOptions.env?.[EAS_SIMULATOR_SESSION_ID]).toBe('session-from-flag'); + }); + + it('passes --subject through to submit-expo-feedback', async () => { + const { command } = createCommand([ + 'The exec command needs a quieter output mode', + '--subject', + 'simulator:exec', + '--non-interactive', + ]); + await command.runAsync(); + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'submit-expo-feedback@latest', + '--category', + 'simulator', + '--subject', + 'simulator:exec', + 'The exec command needs a quieter output mode', + ], + expect.anything() + ); + }); + + it('does not leak an unset session variable into the subprocess env', async () => { + const { command } = createCommand(['Great product', '--non-interactive']); + await command.runAsync(); + + const spawnOptions = mockSpawnAsync.mock.calls[0][2]!; + expect(spawnOptions.env?.[EAS_SIMULATOR_SESSION_ID]).toBeUndefined(); + }); + + it('throws a helpful error when no message is passed in non-interactive mode', async () => { + const { command } = createCommand(['--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + 'Feedback message is required in non-interactive mode. Run `eas simulator:feedback ""` to pass it as an argument.' + ); + expect(mockSpawnAsync).not.toHaveBeenCalled(); + }); + + it('spawns without a message in interactive mode so submit-expo-feedback prompts for it', async () => { + const previousIsTTY = process.stdin.isTTY; + const previousCI = process.env.CI; + process.stdin.isTTY = true; + delete process.env.CI; + + try { + const { command } = createCommand([]); + await command.runAsync(); + + expect(mockSpawnAsync).toHaveBeenCalledWith( + 'npx', + ['--yes', 'submit-expo-feedback@latest', '--category', 'simulator'], + expect.anything() + ); + } finally { + process.stdin.isTTY = previousIsTTY; + if (previousCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = previousCI; + } + } + }); +}); diff --git a/packages/eas-cli/src/commands/simulator/feedback.ts b/packages/eas-cli/src/commands/simulator/feedback.ts new file mode 100644 index 000000000..3974e1563 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/feedback.ts @@ -0,0 +1,87 @@ +import spawnAsync from '@expo/spawn-async'; +import { Args, Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { isNonInteractiveByDefault } from '../../commandUtils/flags'; +import { EAS_SIMULATOR_SESSION_ID, SIMULATOR_DOTENV_FILE_NAME } from '../../simulator/env'; + +export default class SimulatorFeedback extends EasCommand { + static override hidden = true; + static override aliases = ['sim:feedback']; + static override description = '[EXPERIMENTAL] send feedback about EAS Simulator to the Expo team'; + + static override args = { + message: Args.string({ + description: 'Feedback message. Omit it to be prompted in an interactive terminal.', + }), + }; + + static override flags = { + id: Flags.string({ + description: `Simulator session ID to attach to the feedback. Defaults to the session in ${SIMULATOR_DOTENV_FILE_NAME}.`, + }), + subject: Flags.string({ + description: 'Simulator command or feature the feedback is about, e.g. "simulator:exec".', + }), + 'non-interactive': Flags.boolean({ + description: 'Run the command in non-interactive mode.', + default: () => Promise.resolve(isNonInteractiveByDefault()), + noCacheDefault: true, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectDir, + }; + + private isRunningSubprocess = false; + + async runAsync(): Promise { + const { args, flags } = await this.parse(SimulatorFeedback); + const nonInteractive = flags['non-interactive']; + + const message = args.message?.trim(); + if (nonInteractive && !message) { + throw new Error( + 'Feedback message is required in non-interactive mode. Run `eas simulator:feedback ""` to pass it as an argument.' + ); + } + + const { projectDir } = await this.getContextAsync(SimulatorFeedback, { + nonInteractive, + }); + + // submit-expo-feedback resolves the active session itself, from EAS_SIMULATOR_SESSION_ID + // or the simulator dotenv file in its working directory; --id overrides it through the + // environment. It also prompts for the message when one is not passed. + this.isRunningSubprocess = true; + await spawnAsync( + 'npx', + [ + '--yes', + 'submit-expo-feedback@latest', + '--category', + 'simulator', + ...(flags.subject ? ['--subject', flags.subject] : []), + ...(message ? [message] : []), + ], + { + cwd: projectDir, + stdio: 'inherit', + env: { + ...process.env, + ...(flags.id ? { [EAS_SIMULATOR_SESSION_ID]: flags.id } : {}), + }, + } + ); + } + + 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/stop.ts b/packages/eas-cli/src/commands/simulator/stop.ts index 0f22a6f98..7bf635bc6 100644 --- a/packages/eas-cli/src/commands/simulator/stop.ts +++ b/packages/eas-cli/src/commands/simulator/stop.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import EasCommand from '../../commandUtils/EasCommand'; import { @@ -6,6 +7,7 @@ import { resolveNonInteractiveAndJsonFlags, } from '../../commandUtils/flags'; import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation'; +import Log from '../../log'; import { ora } from '../../ora'; import { EAS_SIMULATOR_SESSION_ID, @@ -70,6 +72,8 @@ export default class SimulatorStop extends EasCommand { if (jsonFlag) { printJsonOnlyOutput({ id: session.id, status: session.status }); + } else { + Log.log(chalk.dim('Have feedback about EAS Simulator? Run `eas simulator:feedback`.')); } } }