mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93371c685 | ||
|
|
9f5e3eaca5 | ||
|
|
b6b0cf771d | ||
|
|
f8cf334f2e |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Name the failed Google Play submission phase and include Google's error details in submission errors.
|
||||
|
||||
Keep bounded, redacted Google Play error responses in structured warning logs and error metadata for diagnostics.
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import nock from 'nock';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import {
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('@expo/logger');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
@@ -145,11 +148,18 @@ it('runs the step with a string rollout and validates its required output', asyn
|
||||
await step.executeAsync();
|
||||
expect(step.outputs[0].value).toBe('145');
|
||||
});
|
||||
it.each(['upload', 'track', 'abort'] as const)(
|
||||
it.each([
|
||||
['upload', 'Could not upload the AAB. '],
|
||||
['track', 'Could not update track "internal". '],
|
||||
['commit', 'Could not commit the Google Play edit. '],
|
||||
['abort', undefined],
|
||||
] as const)(
|
||||
'deletes the edit after %s failure and leaves outputs unset',
|
||||
async failure => {
|
||||
async (failure, phase) => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = new Error('step timed out');
|
||||
const task = submission({}, controller.signal);
|
||||
const rejected = { error: { message: 'rejected' } };
|
||||
mockToken();
|
||||
api().post(createPath).reply(200, { id: 'edit' });
|
||||
api()
|
||||
@@ -157,20 +167,30 @@ it.each(['upload', 'track', 'abort'] as const)(
|
||||
.query({ uploadType: 'resumable' })
|
||||
.reply(() => {
|
||||
if (failure === 'abort') {
|
||||
controller.abort(new Error('step timed out'));
|
||||
controller.abort(abortReason);
|
||||
}
|
||||
return failure === 'upload'
|
||||
? [400, { error: { message: 'rejected binary' } }]
|
||||
? [400, rejected]
|
||||
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
|
||||
});
|
||||
if (failure === 'track') {
|
||||
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
api().put(`${editPath}/tracks/internal`).reply(400, rejected);
|
||||
}
|
||||
if (failure === 'commit') {
|
||||
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
|
||||
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query({}).reply(400, rejected);
|
||||
}
|
||||
api().delete(editPath).reply(204);
|
||||
await expect(task.run()).rejects.toThrow();
|
||||
if (phase) {
|
||||
await expect(task.run()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
|
||||
message: expect.stringContaining(phase),
|
||||
});
|
||||
} else {
|
||||
await expect(task.run()).rejects.toBe(abortReason);
|
||||
}
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
}
|
||||
);
|
||||
@@ -184,11 +204,62 @@ it('preserves the original failure when cleanup fails', async () => {
|
||||
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs structured response diagnostics at warning level and includes them in Sentry metadata only', async () => {
|
||||
upload();
|
||||
const task = submission();
|
||||
const records: Record<string, unknown>[] = [];
|
||||
const logger = createLogger({
|
||||
name: 'submission-test',
|
||||
streams: [
|
||||
{
|
||||
level: 'info',
|
||||
stream: new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
records.push(JSON.parse(chunk.toString()));
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
jest.spyOn(task.step.ctx.logger, 'warn').mockImplementation(logger.warn.bind(logger));
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, {
|
||||
error: {
|
||||
message: 'Rejected',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [{ '@type': 'unknown', diagnostic: 'DIAGNOSTIC_ONLY', access_token: 'SECRET' }],
|
||||
},
|
||||
});
|
||||
api().delete(editPath).reply(204);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
if (!(error instanceof UserError)) {
|
||||
throw error;
|
||||
}
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({
|
||||
level: 40,
|
||||
msg: 'Google Play submission failed.',
|
||||
googlePlayHttpStatus: 400,
|
||||
googlePlayErrorResponse: expect.any(Object),
|
||||
}),
|
||||
]);
|
||||
expect(records[0].googlePlayErrorResponse).toEqual(error.metadata?.googlePlayErrorResponse);
|
||||
expect(JSON.stringify(error.metadata)).toContain('DIAGNOSTIC_ONLY');
|
||||
expect(JSON.stringify(error.metadata)).not.toContain('SECRET');
|
||||
expect(JSON.stringify(error.toExternalExpoError())).not.toContain('DIAGNOSTIC_ONLY');
|
||||
});
|
||||
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
|
||||
upload();
|
||||
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query(true).reply(503);
|
||||
api().post(`${editPath}:commit`).query(true).reply(404);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query(true)
|
||||
.reply(404, { error: { message: 'Edit missing', status: 'NOT_FOUND' } });
|
||||
let deleted = false;
|
||||
api()
|
||||
.delete(editPath)
|
||||
@@ -198,7 +269,14 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error).toMatchObject({
|
||||
metadata: {
|
||||
googlePlayHttpStatus: 404,
|
||||
googlePlayErrorResponse: { error: { message: 'Edit missing', status: 'NOT_FOUND' } },
|
||||
},
|
||||
});
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -179,6 +179,8 @@ async function submitToGooglePlayAsync({
|
||||
let editId: string | undefined;
|
||||
let committed = false;
|
||||
let commitOutcomeUnknown = false;
|
||||
// The submission phase in progress, named in Google Play errors.
|
||||
let action = 'create a Google Play edit';
|
||||
try {
|
||||
const packageName = submission.package_name;
|
||||
logger.info(
|
||||
@@ -203,6 +205,7 @@ async function submitToGooglePlayAsync({
|
||||
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
|
||||
}
|
||||
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
|
||||
action = `upload the ${artifactType.toUpperCase()}`;
|
||||
let lastPercent: number | undefined;
|
||||
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
|
||||
packageName,
|
||||
@@ -219,6 +222,7 @@ async function submitToGooglePlayAsync({
|
||||
},
|
||||
});
|
||||
logger.info(`Uploaded version code: ${versionCode}.`);
|
||||
action = `update track ${JSON.stringify(submission.track)}`;
|
||||
await GooglePlayUtils.updateTrackAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
@@ -230,6 +234,7 @@ async function submitToGooglePlayAsync({
|
||||
});
|
||||
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
|
||||
logger.info('Committing the Google Play edit...');
|
||||
action = 'commit the Google Play edit';
|
||||
// Commit also validates the edit.
|
||||
await GooglePlayUtils.commitEditAsync(client, {
|
||||
packageName,
|
||||
@@ -245,7 +250,14 @@ async function submitToGooglePlayAsync({
|
||||
return versionCode;
|
||||
} catch (error) {
|
||||
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
|
||||
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
|
||||
const metadata = GooglePlayUtils.getGooglePlayErrorMetadata(error);
|
||||
if (metadata) {
|
||||
logger.warn(metadata, 'Google Play submission failed.');
|
||||
}
|
||||
throw GooglePlayUtils.mapGooglePlayError(error, {
|
||||
packageName: submission.package_name,
|
||||
action,
|
||||
});
|
||||
} finally {
|
||||
if (editId && !committed && !commitOutcomeUnknown) {
|
||||
try {
|
||||
@@ -254,8 +266,9 @@ async function submitToGooglePlayAsync({
|
||||
editId,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
GooglePlayUtils.getGooglePlayErrorMetadata(error) ?? {},
|
||||
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_DETAILS = 5;
|
||||
const MAX_DETAIL_LENGTH = 500;
|
||||
|
||||
// Reads only the human-readable parts of known google.rpc details. Other types and
|
||||
// fields, such as ErrorInfo metadata, are skipped.
|
||||
const DetailZ = z
|
||||
.union([
|
||||
z
|
||||
.object({
|
||||
'@type': z.literal('type.googleapis.com/google.rpc.ErrorInfo'),
|
||||
reason: z.string(),
|
||||
})
|
||||
.transform(detail => [detail.reason]),
|
||||
z
|
||||
.object({
|
||||
'@type': z.literal('type.googleapis.com/google.rpc.PreconditionFailure'),
|
||||
violations: z.array(z.object({ type: z.string().optional(), description: z.string() })),
|
||||
})
|
||||
.transform(detail =>
|
||||
detail.violations.map(({ type, description }) =>
|
||||
type ? `${type}: ${description}` : description
|
||||
)
|
||||
),
|
||||
z
|
||||
.object({
|
||||
'@type': z.literal('type.googleapis.com/google.rpc.BadRequest'),
|
||||
fieldViolations: z.array(
|
||||
z.object({ field: z.string().optional(), description: z.string() })
|
||||
),
|
||||
})
|
||||
.transform(detail =>
|
||||
detail.fieldViolations.map(({ field, description }) =>
|
||||
field ? `${field}: ${description}` : description
|
||||
)
|
||||
),
|
||||
])
|
||||
.catch([]);
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[]
|
||||
readonly reasons: string[],
|
||||
readonly details: string[] = [],
|
||||
readonly responseBody?: unknown
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
@@ -30,13 +70,60 @@ export class GooglePlayApiError extends Error {
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
// A malformed detail must not hide the primary message.
|
||||
details: z.array(DetailZ).catch([]),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
|
||||
(parsed.data?.error.details ?? [])
|
||||
.flat()
|
||||
.slice(0, MAX_DETAILS)
|
||||
.map(detail => detail.slice(0, MAX_DETAIL_LENGTH)),
|
||||
data === undefined ? undefined : sanitizeResponseBody(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Structured log properties are not covered by the worker's message secret filter.
|
||||
// Never retain headers, request bodies, non-JSON responses or unbounded API data.
|
||||
function sanitizeResponseBody(data: unknown): unknown {
|
||||
let remainingNodes = 200;
|
||||
const sanitize = (value: unknown, depth: number): unknown => {
|
||||
if (--remainingNodes < 0 || depth > 6) {
|
||||
return '[truncated]';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(
|
||||
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,
|
||||
'[redacted]'
|
||||
)
|
||||
.replace(/https?:\/\/\S+/gi, '[URL redacted]')
|
||||
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
|
||||
.slice(0, 2000);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 50).map(item => sanitize(item, depth + 1));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.slice(0, 50)
|
||||
.map(([key, item]) => [
|
||||
key.slice(0, 100),
|
||||
/token|secret|password|authorization|cookie|credential|private.?key|email/i.test(key)
|
||||
? '[redacted]'
|
||||
: sanitize(item, depth + 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const sanitized = sanitize(data, 0);
|
||||
const json = JSON.stringify(sanitized);
|
||||
return json.length > 16000 ? { truncated: true, preview: json.slice(0, 16000) } : sanitized;
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error }
|
||||
{ cause: error, metadata: getGooglePlayErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -210,17 +210,22 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
|
||||
// `action` names the failed submission phase, e.g. "create a Google Play edit".
|
||||
// Other errors already describe themselves and are returned unchanged.
|
||||
export function mapGooglePlayError(
|
||||
error: unknown,
|
||||
{ packageName, action }: { packageName: string; action: string }
|
||||
): unknown {
|
||||
if (error instanceof AggregateError) {
|
||||
return mapGooglePlayError(error.cause, packageName);
|
||||
return mapGooglePlayError(error.cause, { packageName, action });
|
||||
}
|
||||
if (
|
||||
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new SystemError(
|
||||
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
|
||||
{ cause: error }
|
||||
`Could not ${action}. Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
|
||||
{ cause: error, metadata: getGooglePlayErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -231,7 +236,7 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
return new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
`Google rejected the service-account key (${error.errorCode ?? `HTTP ${error.status}`}). Check that the key is valid and the service account is enabled.`,
|
||||
`Could not ${action}. Google rejected the service-account key (${error.errorCode ?? `HTTP ${error.status}`}). Check that the key is valid and the service account is enabled.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
@@ -280,12 +285,28 @@ export namespace GooglePlayUtils {
|
||||
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
|
||||
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
|
||||
}
|
||||
const googleDetails = error.details.length
|
||||
? `\n\nGoogle Play details:\n${error.details.map(item => `- ${item}`).join('\n')}`
|
||||
: '';
|
||||
return new UserError(
|
||||
code,
|
||||
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
|
||||
`Could not ${action}. ${detail}${googleDetails}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
|
||||
{
|
||||
cause: error,
|
||||
metadata: getGooglePlayErrorMetadata(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getGooglePlayErrorMetadata(error: unknown): Record<string, unknown> | undefined {
|
||||
// Retries and uncertain commits wrap the last API error in cause.
|
||||
const seen = new Set<Error>();
|
||||
for (let cause = error; cause instanceof Error && !seen.has(cause); cause = cause.cause) {
|
||||
seen.add(cause);
|
||||
if (cause instanceof GooglePlayApiError && cause.responseBody !== undefined) {
|
||||
return { googlePlayHttpStatus: cause.status, googlePlayErrorResponse: cause.responseBody };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { GooglePlayApiError } from '../GooglePlayApiError';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
function fromBody(error: unknown): Promise<GooglePlayApiError> {
|
||||
return GooglePlayApiError.fromResponseAsync(
|
||||
new Response(JSON.stringify({ error }), { status: 400 })
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps readable Google details and skips metadata, unknown types and malformed entries', async () => {
|
||||
const error = await fromBody({
|
||||
message: 'Precondition check failed.',
|
||||
errors: [{ reason: 'failedPrecondition' }],
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'APP_NOT_READY',
|
||||
metadata: { project: 'PRIVATE' },
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
|
||||
violations: [{ type: 'APP_SETUP', subject: 'PRIVATE', description: 'Complete setup.' }],
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.BadRequest',
|
||||
fieldViolations: [{ field: 'releases[0]', description: 'Invalid rollout.' }],
|
||||
},
|
||||
{ '@type': 'type.googleapis.com/google.rpc.DebugInfo', detail: 'PRIVATE' },
|
||||
{ '@type': 'type.googleapis.com/google.rpc.PreconditionFailure', violations: 'invalid' },
|
||||
null,
|
||||
],
|
||||
});
|
||||
expect(error).toMatchObject({
|
||||
apiMessage: 'Precondition check failed.',
|
||||
reasons: ['failedPrecondition'],
|
||||
details: ['APP_NOT_READY', 'APP_SETUP: Complete setup.', 'releases[0]: Invalid rollout.'],
|
||||
});
|
||||
expect(JSON.stringify(error.details)).not.toContain('PRIVATE');
|
||||
});
|
||||
|
||||
it('retains the JSON response with bounded and redacted diagnostic fields', async () => {
|
||||
const error = await fromBody({
|
||||
message: 'Denied',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [
|
||||
{
|
||||
'@type': 'unknown',
|
||||
explanation: 'Useful extra detail',
|
||||
metadata: {
|
||||
access_token: 'SECRET',
|
||||
password: 'SECRET',
|
||||
url: 'https://example.com?token=SECRET',
|
||||
description: 'Bearer SECRET',
|
||||
private_key: 'SECRET',
|
||||
},
|
||||
},
|
||||
],
|
||||
extra: 'x'.repeat(100000),
|
||||
});
|
||||
const body = JSON.stringify(error.responseBody);
|
||||
expect(body).toContain('Useful extra detail');
|
||||
expect(body).toContain('FAILED_PRECONDITION');
|
||||
expect(body).not.toContain('SECRET');
|
||||
expect(body.length).toBeLessThan(17000);
|
||||
});
|
||||
|
||||
it('does not retain a non-JSON error response', async () => {
|
||||
const error = await GooglePlayApiError.fromResponseAsync(new Response('SECRET', { status: 502 }));
|
||||
expect(error.responseBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the primary error when details is not a list', async () => {
|
||||
const error = await fromBody({ message: 'Denied', details: { '@type': 'unknown' } });
|
||||
expect(error).toMatchObject({ apiMessage: 'Denied', details: [] });
|
||||
});
|
||||
|
||||
it('bounds the number and length of details', async () => {
|
||||
const error = await fromBody({
|
||||
message: 'Invalid request.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.BadRequest',
|
||||
fieldViolations: Array.from({ length: 20 }, () => ({ description: 'x'.repeat(10_000) })),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(error.details).toHaveLength(5);
|
||||
expect(error.details.every(detail => detail.length === 500)).toBe(true);
|
||||
});
|
||||
+15
-6
@@ -14,6 +14,7 @@ jest.mock('promise-retry', () => {
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
const action = 'upload the AAB';
|
||||
beforeEach(() => nock.disableNetConnect());
|
||||
afterEach(() => {
|
||||
const pending = nock.pendingMocks();
|
||||
@@ -105,13 +106,17 @@ it.each([
|
||||
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
|
||||
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
|
||||
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
|
||||
const original = new GooglePlayApiError(status, message, []);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
const original = new GooglePlayApiError(status, message, [], ['APP_SETUP: Complete setup.']);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, { packageName, action });
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: code,
|
||||
cause: original,
|
||||
});
|
||||
expect((mapped as Error).message).toMatch(/^Could not upload the AAB\. /);
|
||||
expect((mapped as Error).message).toContain(
|
||||
'Google Play details:\n- APP_SETUP: Complete setup.\n\nApp:'
|
||||
);
|
||||
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
|
||||
expect((mapped as Error).message).toContain(message);
|
||||
} else {
|
||||
@@ -127,8 +132,9 @@ it.each([
|
||||
});
|
||||
it('maps a rejected OAuth key to a credential error', () => {
|
||||
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, { packageName, action })).toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
message: expect.stringMatching(/^Could not upload the AAB\. /),
|
||||
cause: original,
|
||||
});
|
||||
});
|
||||
@@ -137,13 +143,16 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
|
||||
new GooglePlayApiError(status, 'temporary failure', []),
|
||||
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
|
||||
]) {
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, { packageName, action });
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({ cause: original });
|
||||
expect(mapped).toMatchObject({
|
||||
message: expect.stringMatching(/^Could not upload the AAB\. /),
|
||||
cause: original,
|
||||
});
|
||||
expect((mapped as Error).message).not.toContain('settings');
|
||||
}
|
||||
});
|
||||
it('keeps non-Google errors unchanged', () => {
|
||||
const error = new SystemError('file failure');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, { packageName, action })).toBe(error);
|
||||
});
|
||||
Reference in new issue
Block a user