[eas-cli] Add eas simulator:feedback command

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Adam ZvadaandClaude Fable 5 committed 2026-08-11 17:12:11 +02:00
1 parent 4c317292d2
commit fea675c1e4
4 files changed
+256

No files matched your search

+1
View File
@@ -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
@@ -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 "<your 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;
}
}
});
});
@@ -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<void> {
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 "<your 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<any> {
// Propagate wrapped command from spawnAsync rejection
if (this.isRunningSubprocess) {
process.exitCode = process.exitCode ?? (err as any).status ?? 1;
return Promise.resolve();
}
return super.catch(err);
}
}
@@ -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`.'));
}
}
}