mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0873b04166 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Print `{"error": {"message", "requestId"?}}` on stdout when a command run with `--json` fails, and keep all other output off stdout from the start of the command.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from '@oclif/core';
|
||||
import { Command, Errors } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
@@ -31,6 +31,7 @@ import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { getActorDisplayName } from '../user/User';
|
||||
import { enableJsonOutput, printJsonErrorOutput } from '../utils/json';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type ContextInput<
|
||||
@@ -257,9 +258,24 @@ export default abstract class EasCommand extends Command {
|
||||
action: `eas ${this.id}`,
|
||||
});
|
||||
|
||||
// Before the context is resolved, so its messages and errors stay off stdout too.
|
||||
if (this.jsonOutputRequested()) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
return await this.runAsync();
|
||||
}
|
||||
|
||||
/** Mirrors oclif's `jsonEnabled`, for commands that declare their own `json` flag. */
|
||||
private jsonOutputRequested(): boolean {
|
||||
if (!this.ctor.flags?.json) {
|
||||
return false;
|
||||
}
|
||||
const jsonIndex = this.argv.indexOf('--json');
|
||||
const passThroughIndex = this.argv.indexOf('--');
|
||||
return jsonIndex !== -1 && (passThroughIndex === -1 || jsonIndex < passThroughIndex);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
override async finally(err: Error): Promise<any> {
|
||||
await this.analytics.flushAsync();
|
||||
@@ -270,7 +286,13 @@ export default abstract class EasCommand extends Command {
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
const commandId = this.id ?? 'unknown';
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
let message = err.message;
|
||||
let requestId: string | undefined;
|
||||
if (err instanceof Errors.ExitError) {
|
||||
// Errors.exit(1) carries no reason; the code that called it printed the reason already.
|
||||
Log.errorToStderr(err.message);
|
||||
message = baseMessage;
|
||||
} else if (err instanceof EasCommandError) {
|
||||
if (!err.messageAlreadyPrinted) {
|
||||
Log.errorToStderr(err.message);
|
||||
}
|
||||
@@ -323,9 +345,14 @@ export default abstract class EasCommand extends Command {
|
||||
: cleanGQLErrorsMessage;
|
||||
Log.errorToStderr(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
message = cleanMessage;
|
||||
requestId = err.graphQLErrors[0]?.extensions?.requestId as string | undefined;
|
||||
} else {
|
||||
Log.errorToStderr(err.message);
|
||||
}
|
||||
if (this.jsonOutputRequested()) {
|
||||
printJsonErrorOutput({ message, requestId });
|
||||
}
|
||||
Log.debug(err);
|
||||
Sentry.withScope(scope => {
|
||||
scope.setTag('command', commandId);
|
||||
|
||||
@@ -24,6 +24,7 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
};
|
||||
});
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
@@ -335,6 +336,112 @@ describe('EasCommand', () => {
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
describe('with --json', () => {
|
||||
const createJsonTestEasCommand = (): any => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const { Flags } = require('@oclif/core');
|
||||
TestEasCommand.flags = { json: Flags.boolean() };
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
it('enables JSON output before the command runs', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { enableJsonOutput } = jest.requireMock('../../utils/json');
|
||||
let enabledBeforeRun = false;
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(async () => {
|
||||
enabledBeforeRun = enableJsonOutput.mock.calls.length > 0;
|
||||
});
|
||||
|
||||
await TestEasCommand.run(['--json'], mockConfig);
|
||||
|
||||
expect(enabledBeforeRun).toBe(true);
|
||||
});
|
||||
|
||||
it('prints the error message', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { EasCommandError } = require('../errors');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new EasCommandError('EAS project not configured.');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith({
|
||||
message: 'EAS project not configured.',
|
||||
requestId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the command failure, not EEXIT, for Errors.exit', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { Errors } = require('@oclif/core');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
Errors.exit(1);
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith({
|
||||
message: `${TestEasCommand.id} command failed.`,
|
||||
requestId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the GraphQL request ID, not the server error code', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const { GraphQLError } = require('graphql/error');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('Entity not authorized', null, null, null, null, null, {
|
||||
errorCode: 'UNAUTHORIZED_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: mockRequestId })
|
||||
);
|
||||
expect(printJsonErrorOutput.mock.calls[0][0]).not.toHaveProperty('code');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['without --json', []],
|
||||
['with --json after --', ['--', '--json']],
|
||||
])('prints no JSON error %s', async (_, argv) => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { enableJsonOutput, printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(argv, mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonErrorOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints no JSON error when the command has no json flag', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-throws the error with default base message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
|
||||
@@ -8,7 +8,7 @@ export function isNonInteractiveByDefault(): boolean {
|
||||
export const EasNonInteractiveAndJsonFlags = {
|
||||
json: Flags.boolean({
|
||||
description:
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}. Implies --non-interactive.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
@@ -87,7 +87,8 @@ export const EASNonInteractiveFlag = {
|
||||
|
||||
export const EasJsonOnlyFlag = {
|
||||
json: Flags.boolean({
|
||||
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
|
||||
description:
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}.',
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
describe('printJsonErrorOutput', () => {
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
it('prints one error object on stdout, without ANSI codes, once per process', () => {
|
||||
const stdout = jest.fn(() => true);
|
||||
const stderr = jest.fn(() => true);
|
||||
process.stdout.write = stdout as any;
|
||||
process.stderr.write = stderr as any;
|
||||
// Jest's console does not write through process.stdout, so record which stream was live.
|
||||
const printed: { text: string; toStdout: boolean }[] = [];
|
||||
jest.spyOn(console, 'log').mockImplementation((text: string) => {
|
||||
printed.push({ text, toStdout: process.stdout.write === stdout });
|
||||
});
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const { printJsonErrorOutput } = require('../json');
|
||||
printJsonErrorOutput({
|
||||
message: '\u001b[31mEAS project not configured.\u001b[39m',
|
||||
});
|
||||
printJsonErrorOutput({ message: 'update:republish command failed.' });
|
||||
});
|
||||
|
||||
expect(printed).toHaveLength(1);
|
||||
expect(printed[0].toStdout).toBe(true);
|
||||
expect(JSON.parse(printed[0].text)).toEqual({
|
||||
error: { message: 'EAS project not configured.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'assert';
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
@@ -22,6 +23,29 @@ export function printJsonOnlyOutput(value: object): void {
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonErrorOutput {
|
||||
message: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
let jsonErrorPrinted = false;
|
||||
|
||||
/**
|
||||
* Print `{ "error": { message, requestId? } }` on stdout for a command that failed with --json.
|
||||
*
|
||||
* Once per process: `update:rollback` runs other commands in-process, and the innermost error is the one
|
||||
* that says what went wrong.
|
||||
*/
|
||||
export function printJsonErrorOutput(error: JsonErrorOutput): void {
|
||||
if (jsonErrorPrinted) {
|
||||
return;
|
||||
}
|
||||
jsonErrorPrinted = true;
|
||||
// The command may have failed before it could enable JSON output.
|
||||
enableJsonOutput();
|
||||
printJsonOnlyOutput({ error: { ...error, message: stripVTControlCharacters(error.message) } });
|
||||
}
|
||||
|
||||
function sanitizeValue(value: any): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(val => sanitizeValue(val));
|
||||
|
||||
Reference in new issue
Block a user