mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
140a763800 | ||
|
|
52bd5ec5b1 | ||
|
|
2bdc51e4bb | ||
|
|
f7411f44d7 | ||
|
|
2487b30394 |
No files matched your search
@@ -28,6 +28,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.type).toBe(errors.ExpoErrorType.SYSTEM);
|
||||
});
|
||||
|
||||
it('detects log for invalid bundler and reports it to user', async () => {
|
||||
@@ -46,6 +47,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.type).toBe(errors.ExpoErrorType.USER);
|
||||
});
|
||||
|
||||
it('does not detect errors if they show up in different build phase', async () => {
|
||||
|
||||
@@ -94,8 +94,10 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
const errorCode =
|
||||
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
|
||||
userFacingError.errorCode;
|
||||
const type = isUnknownUserError ? errors.ExpoErrorType.SYSTEM : errors.ExpoErrorType.USER;
|
||||
|
||||
return new errors.BuildError(message, {
|
||||
type,
|
||||
errorCode,
|
||||
userFacingErrorCode: userFacingError.errorCode,
|
||||
userFacingMessage: userFacingError.message,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserFacingError } from '../errors';
|
||||
import { BuildMode } from '../common';
|
||||
import { ErrorCode, ExpoErrorType, UserFacingError, toBuildError } from '../errors';
|
||||
|
||||
describe(UserFacingError, () => {
|
||||
it('supports docsUrl in options', () => {
|
||||
@@ -28,3 +29,22 @@ describe(UserFacingError, () => {
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe(toBuildError, () => {
|
||||
it('converts UserFacingError to a user-type BuildError', () => {
|
||||
const err = toBuildError(new UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'user message'));
|
||||
|
||||
expect(err.type).toBe(ExpoErrorType.USER);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
expect(err.userFacingMessage).toBe('user message');
|
||||
});
|
||||
|
||||
it('maps unknown custom errors to UNKNOWN_CUSTOM_BUILD_ERROR', () => {
|
||||
const err = toBuildError(new Error('oops'), { mode: BuildMode.CUSTOM });
|
||||
|
||||
expect(err.type).toBe(ExpoErrorType.SYSTEM);
|
||||
expect(err.errorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BuildMode } from './common';
|
||||
import { BuildPhase, buildPhaseDisplayName } from './logs';
|
||||
|
||||
export enum ErrorCode {
|
||||
@@ -10,6 +11,11 @@ export enum ErrorCode {
|
||||
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
|
||||
}
|
||||
|
||||
export enum ExpoErrorType {
|
||||
USER = 'USER',
|
||||
SYSTEM = 'SYSTEM',
|
||||
}
|
||||
|
||||
export interface ExternalBuildError {
|
||||
errorCode: string;
|
||||
message: string;
|
||||
@@ -17,32 +23,52 @@ export interface ExternalBuildError {
|
||||
buildPhase?: BuildPhase;
|
||||
}
|
||||
|
||||
interface BuildErrorDetails {
|
||||
interface ExpoErrorDetails {
|
||||
type: ExpoErrorType;
|
||||
errorCode: string;
|
||||
userFacingMessage: string;
|
||||
userFacingErrorCode: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class BuildError extends Error {
|
||||
interface BuildErrorDetails {
|
||||
type?: ExpoErrorType;
|
||||
errorCode: string;
|
||||
userFacingMessage: string;
|
||||
userFacingErrorCode: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class ExpoError extends Error {
|
||||
public type: ExpoErrorType;
|
||||
public errorCode: string;
|
||||
public userFacingMessage: string;
|
||||
public userFacingErrorCode: string;
|
||||
public docsUrl?: string;
|
||||
public innerError?: Error;
|
||||
public buildPhase?: BuildPhase;
|
||||
public metadata: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, details: BuildErrorDetails) {
|
||||
super(message);
|
||||
constructor(message: string, details: ExpoErrorDetails) {
|
||||
super(message, { cause: details.cause });
|
||||
this.type = details.type;
|
||||
this.errorCode = details.errorCode;
|
||||
this.userFacingErrorCode = details.userFacingErrorCode;
|
||||
this.userFacingMessage = details.userFacingMessage;
|
||||
this.docsUrl = details.docsUrl;
|
||||
this.innerError = details.innerError;
|
||||
this.buildPhase = details.buildPhase;
|
||||
this.metadata = details.metadata ?? {};
|
||||
}
|
||||
|
||||
public format(): ExternalBuildError {
|
||||
return {
|
||||
errorCode: this.userFacingErrorCode,
|
||||
@@ -53,19 +79,66 @@ export class BuildError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError extends Error {
|
||||
public docsUrl?: string;
|
||||
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string,
|
||||
options?: { docsUrl?: string; cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
this.docsUrl = options?.docsUrl;
|
||||
export class BuildError extends ExpoError {
|
||||
constructor(message: string, details: BuildErrorDetails) {
|
||||
super(message, {
|
||||
...details,
|
||||
type: details.type ?? ExpoErrorType.SYSTEM,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface UserErrorOptions {
|
||||
docsUrl?: string;
|
||||
cause?: unknown;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UserError extends ExpoError {
|
||||
constructor(errorCode: string, message: string, options?: UserErrorOptions) {
|
||||
super(message, {
|
||||
type: ExpoErrorType.USER,
|
||||
errorCode,
|
||||
userFacingErrorCode: errorCode,
|
||||
userFacingMessage: message,
|
||||
docsUrl: options?.docsUrl,
|
||||
innerError: options?.cause instanceof Error ? options.cause : undefined,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface SystemErrorOptions {
|
||||
userFacingErrorCode?: string;
|
||||
userFacingMessage?: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class SystemError extends ExpoError {
|
||||
constructor(errorCode: string, message: string, options?: SystemErrorOptions) {
|
||||
super(message, {
|
||||
type: ExpoErrorType.SYSTEM,
|
||||
errorCode,
|
||||
userFacingErrorCode: options?.userFacingErrorCode ?? errorCode,
|
||||
userFacingMessage: options?.userFacingMessage ?? message,
|
||||
docsUrl: options?.docsUrl,
|
||||
innerError: options?.innerError,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError extends UserError {}
|
||||
|
||||
export class UnknownError extends UserFacingError {
|
||||
constructor(buildPhase?: BuildPhase) {
|
||||
super(
|
||||
@@ -106,3 +179,38 @@ export class CredentialsDistCertMismatchError extends UserFacingError {
|
||||
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
|
||||
}
|
||||
}
|
||||
|
||||
export function toBuildError(
|
||||
error: unknown,
|
||||
options?: {
|
||||
mode?: BuildMode;
|
||||
buildPhase?: BuildPhase;
|
||||
}
|
||||
): BuildError {
|
||||
if (error instanceof BuildError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof ExpoError) {
|
||||
return new BuildError(error.message, {
|
||||
type: error.type,
|
||||
errorCode: error.errorCode,
|
||||
userFacingErrorCode: error.userFacingErrorCode,
|
||||
userFacingMessage: error.userFacingMessage,
|
||||
docsUrl: error.docsUrl,
|
||||
innerError: error.innerError,
|
||||
buildPhase: error.buildPhase ?? options?.buildPhase,
|
||||
metadata: error.metadata,
|
||||
cause: error.cause,
|
||||
});
|
||||
}
|
||||
|
||||
const isCustomMode =
|
||||
options?.mode === BuildMode.CUSTOM || options?.mode === BuildMode.REPACK;
|
||||
const fallback = isCustomMode ? new UnknownCustomBuildError() : new UnknownBuildError();
|
||||
fallback.buildPhase = options?.buildPhase;
|
||||
if (error && typeof error === 'object') {
|
||||
fallback.innerError = error as Error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -320,6 +320,7 @@ describe('State sync mechanism', () => {
|
||||
expect(message.applicationArchiveName).toBe(`application-${buildId}`);
|
||||
expect(message.type).toBe('state-response');
|
||||
expect(message.status).toBe('error');
|
||||
expect(message.internalErrorCode).toBe(errors.ErrorCode.SERVER_ERROR);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -417,5 +418,72 @@ describe('State sync mechanism', () => {
|
||||
ws.close();
|
||||
clearTimeout(messageTimeout);
|
||||
});
|
||||
|
||||
it('should include user-facing error details when builder throws UserFacingError', async () => {
|
||||
jest.mocked(Builders.androidBuilder).mockImplementation(async () => {
|
||||
throw new errors.UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'This is user-visible');
|
||||
});
|
||||
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const dispatchHelper = new WsHelper(dispatchWS);
|
||||
await dispatchHelper.onOpen();
|
||||
dispatchWS.send(
|
||||
JSON.stringify({
|
||||
type: 'dispatch',
|
||||
buildId,
|
||||
job: createTestAndroidJob(),
|
||||
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
|
||||
metadata: {
|
||||
trackingContext: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
dispatchWS.close();
|
||||
await dispatchHelper.onClose();
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const helper = new WsHelper(ws);
|
||||
|
||||
let stateResponsePromiseResolve: () => void;
|
||||
const stateResponsePromise = new Promise<void>(res => {
|
||||
stateResponsePromiseResolve = res;
|
||||
});
|
||||
const onMessage = jest.fn((message: any) => {
|
||||
logger.debug('message received');
|
||||
clearTimeout(messageTimeout);
|
||||
try {
|
||||
expect(message).toBeTruthy();
|
||||
expect(message.type).toBe('state-response');
|
||||
expect(message.status).toBe('error');
|
||||
expect(message.externalBuildError).toEqual({
|
||||
errorCode: 'EAS_BUILD_TEST_USER_ERROR',
|
||||
message: 'This is user-visible',
|
||||
});
|
||||
expect(message.internalErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
stateResponsePromiseResolve();
|
||||
}
|
||||
});
|
||||
const openPromise = helper.onOpen();
|
||||
helper.onMessage(onMessage);
|
||||
|
||||
await openPromise;
|
||||
messageTimeout = setTimeout(() => {
|
||||
unreachableCode('state-response timeout');
|
||||
}, 3000);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'state-query', buildId }));
|
||||
logger.debug('sent state-query');
|
||||
|
||||
await stateResponsePromise;
|
||||
|
||||
expect(helper.onErrorCb).not.toHaveBeenCalled();
|
||||
expect(helper.onOpenCb).toHaveBeenCalled();
|
||||
expect(helper.onMessageCb).toHaveBeenCalled();
|
||||
expect(helper.onCloseCb).not.toHaveBeenCalled();
|
||||
ws.close();
|
||||
clearTimeout(messageTimeout);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,8 @@ export default class BuildService {
|
||||
|
||||
public async finishError(err: errors.BuildError, artifacts: Artifacts | null): Promise<void> {
|
||||
logger.error({ err }, 'Job finished with error');
|
||||
const websocketErrorCode =
|
||||
err.type === errors.ExpoErrorType.SYSTEM ? errors.ErrorCode.SERVER_ERROR : err.errorCode;
|
||||
|
||||
this.state.finish(Worker.Status.ERROR, {
|
||||
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
|
||||
@@ -128,7 +130,7 @@ export default class BuildService {
|
||||
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
|
||||
buildArtifactsName: artifacts?.BUILD_ARTIFACTS ?? null,
|
||||
externalBuildError: err.format(),
|
||||
internalErrorCode: err.errorCode,
|
||||
internalErrorCode: websocketErrorCode,
|
||||
});
|
||||
}
|
||||
this.checkForHangingWorker(isSocketClosed);
|
||||
@@ -308,30 +310,42 @@ export default class BuildService {
|
||||
await this.finishSuccess(artifacts);
|
||||
} catch (error: any) {
|
||||
const maybeArtifacts = (error.artifacts as Artifacts | undefined) ?? null;
|
||||
const err = toBuildError(error, job);
|
||||
const maybeRawError = error instanceof errors.BuildError ? error.innerError : error;
|
||||
const err = errors.toBuildError(error, {
|
||||
mode: 'mode' in job ? job.mode : undefined,
|
||||
});
|
||||
const maybeRawError = err.innerError ?? (error instanceof Error ? error : undefined);
|
||||
const maybeRawErrorWithOutput = maybeRawError as
|
||||
| (Error & { stdout?: string; stderr?: string })
|
||||
| undefined;
|
||||
|
||||
sentry.handleError(err.message, maybeRawError, {
|
||||
tags: {
|
||||
...(err.buildPhase ? { buildPhase: err.buildPhase } : {}),
|
||||
expoErrorType: err.type,
|
||||
errorCode: err.errorCode,
|
||||
userFacingErrorCode: err.userFacingErrorCode,
|
||||
...('type' in job ? { workflow: job.type } : {}),
|
||||
},
|
||||
extras: {
|
||||
buildId: this.buildId,
|
||||
...(maybeRawError.stdout ? { stdout: getLastNLines(100, maybeRawError.stdout) } : {}),
|
||||
...(maybeRawError.stderr ? { stderr: getLastNLines(100, maybeRawError.stderr) } : {}),
|
||||
...(maybeRawErrorWithOutput?.stdout
|
||||
? { stdout: getLastNLines(100, maybeRawErrorWithOutput.stdout) }
|
||||
: {}),
|
||||
...(maybeRawErrorWithOutput?.stderr
|
||||
? { stderr: getLastNLines(100, maybeRawErrorWithOutput.stderr) }
|
||||
: {}),
|
||||
},
|
||||
level: err.type === errors.ExpoErrorType.USER ? 'warning' : 'error',
|
||||
});
|
||||
|
||||
const robotAccessToken = job.secrets?.robotAccessToken;
|
||||
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
|
||||
let rawErrorMessage: string = '';
|
||||
if (maybeRawError.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
|
||||
if (maybeRawErrorWithOutput?.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stderr);
|
||||
}
|
||||
if (maybeRawError.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
|
||||
if (maybeRawErrorWithOutput?.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stdout);
|
||||
}
|
||||
|
||||
await turtleFetch(
|
||||
@@ -367,27 +381,6 @@ export default class BuildService {
|
||||
}
|
||||
}
|
||||
|
||||
function toBuildError(error: unknown, job: Job): errors.BuildError {
|
||||
if (error instanceof errors.BuildError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof errors.UserFacingError) {
|
||||
const innerError = error.cause instanceof Error ? error.cause : error;
|
||||
return new errors.BuildError(error.message, {
|
||||
errorCode: error.errorCode,
|
||||
userFacingErrorCode: error.errorCode,
|
||||
userFacingMessage: error.message,
|
||||
docsUrl: error.docsUrl,
|
||||
innerError,
|
||||
});
|
||||
}
|
||||
|
||||
return 'mode' in job && [BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)
|
||||
? new errors.UnknownCustomBuildError()
|
||||
: new errors.UnknownBuildError();
|
||||
}
|
||||
|
||||
function getLastNLines(numberOfLines: number, stream: string): string {
|
||||
const lines = stream.split('\n');
|
||||
if (lines.length <= numberOfLines) {
|
||||
|
||||
@@ -43,6 +43,11 @@ class State {
|
||||
}
|
||||
|
||||
public stateResponse(): WorkerMessage.StateResponse {
|
||||
const websocketErrorCode =
|
||||
this.userError?.type === errors.ExpoErrorType.SYSTEM
|
||||
? errors.ErrorCode.SERVER_ERROR
|
||||
: this.userError?.errorCode;
|
||||
|
||||
return {
|
||||
type: WorkerMessage.MessageType.STATE_RESPONSE,
|
||||
status: this.status,
|
||||
@@ -50,7 +55,7 @@ class State {
|
||||
applicationArchiveName: this.applicationArchiveName ?? null,
|
||||
...(this.userError && {
|
||||
externalBuildError: this.userError.format(),
|
||||
internalErrorCode: this.userError.errorCode,
|
||||
internalErrorCode: websocketErrorCode,
|
||||
}),
|
||||
abortReason: this.abortReason,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user