From 50a836b33fe4c5324a457a80d8e84a6d0ecff758 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 25 Mar 2026 16:36:14 +0530 Subject: [PATCH] fix: improve handling of Launch deployment file upload size errors --- src/adapters/base-class.test.ts | 143 ++++++++++++++++++++++++++ src/adapters/base-class.ts | 11 +- src/util/deployment-errors.test.ts | 158 +++++++++++++++++++++++++++++ src/util/deployment-errors.ts | 54 ++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/util/deployment-errors.test.ts create mode 100644 src/util/deployment-errors.ts diff --git a/src/adapters/base-class.test.ts b/src/adapters/base-class.test.ts index 009f1e9..c5221f9 100644 --- a/src/adapters/base-class.test.ts +++ b/src/adapters/base-class.test.ts @@ -1,6 +1,7 @@ import BaseClass from './base-class'; import { cliux as ux, ContentstackClient } from '@contentstack/cli-utilities'; import config from '../config'; +import { FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE } from '../util/deployment-errors'; jest.mock('@contentstack/cli-utilities', () => ({ cliux: { @@ -546,4 +547,146 @@ describe('BaseClass', () => { ]); }); }); + + describe('createNewDeployment', () => { + let mutateMock: jest.Mock; + + beforeEach(() => { + mutateMock = jest.fn(); + baseClass = new BaseClass({ + log: logMock, + exit: exitMock, + apolloClient: { mutate: mutateMock } as any, + config: { + currentConfig: { deployments: [] }, + }, + } as any); + }); + + it('should log success and append deployment when mutate succeeds', async () => { + const deployment = { uid: 'dep-1', status: 'PENDING' }; + mutateMock.mockResolvedValueOnce({ data: { deployment } }); + + await baseClass.createNewDeployment(false, 'env-uid-1'); + + expect(mutateMock).toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith('Deployment process started.!', 'info'); + expect(baseClass.config.currentConfig.deployments).toEqual([deployment]); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('should log file size limit message and exit when mutate fails with deployment file size error', async () => { + const apolloError = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.DEPLOYMENT.INVALID_FILE_SIZE'], + }, + }, + }, + ], + }; + mutateMock.mockRejectedValueOnce(apolloError); + + await baseClass.createNewDeployment(true, 'env-uid-2', 'upload-uid-1'); + + expect(logMock).toHaveBeenCalledWith('Deployment process failed.!', 'error'); + expect(logMock).toHaveBeenCalledWith(apolloError, 'debug'); + expect(logMock).toHaveBeenCalledWith(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + expect(logMock).not.toHaveBeenCalledWith(apolloError, 'error'); + }); + + it('should log raw error and exit when mutate fails with a non file size error', async () => { + const otherError = new Error('GraphQL failure'); + mutateMock.mockRejectedValueOnce(otherError); + + await baseClass.createNewDeployment(false, 'env-uid-3'); + + expect(logMock).toHaveBeenCalledWith('Deployment process failed.!', 'error'); + expect(logMock).toHaveBeenCalledWith(otherError, 'error'); + expect(logMock).not.toHaveBeenCalledWith(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + }); + + describe('handleNewProjectCreationError', () => { + beforeEach(() => { + baseClass = new BaseClass({ + log: logMock, + exit: exitMock, + config: { + projectCreationRetryMaxCount: 3, + }, + } as any); + }); + + it('should log file size limit message and exit when error is launch.DEPLOYMENT.INVALID_FILE_SIZE', async () => { + const apolloError = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.DEPLOYMENT.INVALID_FILE_SIZE'], + }, + }, + }, + ], + }; + + await baseClass.handleNewProjectCreationError(apolloError); + + expect(logMock).toHaveBeenCalledWith('New project creation failed!', 'error'); + expect(logMock).toHaveBeenCalledWith(apolloError, 'debug'); + expect(logMock).toHaveBeenCalledWith(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + expect(logMock).not.toHaveBeenCalledWith(apolloError, 'error'); + }); + + it('should log file size limit message and exit when error is launch.DEPLOYMENT.FILE_UPLOAD_FAILED in errorObject', async () => { + const apolloError = { + graphQLErrors: [ + { + extensions: { + exception: { + errorObject: { + uploadUid: [{ code: 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' }], + }, + }, + }, + }, + ], + }; + + await baseClass.handleNewProjectCreationError(apolloError); + + expect(logMock).toHaveBeenCalledWith('New project creation failed!', 'error'); + expect(logMock).toHaveBeenCalledWith(apolloError, 'debug'); + expect(logMock).toHaveBeenCalledWith(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + expect(logMock).not.toHaveBeenCalledWith(apolloError, 'error'); + }); + + it('should log raw error and exit when error is not a known handled case', async () => { + const apolloError = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.PROJECTS.UPLOADED_FILE_NOT_FOUND_ERROR'], + }, + }, + }, + ], + }; + + await baseClass.handleNewProjectCreationError(apolloError); + + expect(logMock).toHaveBeenCalledWith('New project creation failed!', 'error'); + expect(logMock).toHaveBeenCalledWith(apolloError, 'error'); + expect(logMock).not.toHaveBeenCalledWith(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/src/adapters/base-class.ts b/src/adapters/base-class.ts index e73008a..0a37b7b 100755 --- a/src/adapters/base-class.ts +++ b/src/adapters/base-class.ts @@ -19,6 +19,7 @@ import { writeFileSync, existsSync, readFileSync } from 'fs'; import { cliux as ux, ContentstackClient } from '@contentstack/cli-utilities'; import { print, GraphqlApiClient, LogPolling, getOrganizations } from '../util'; +import { FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, isLaunchDeploymentFileSizeRelatedError } from '../util/deployment-errors'; import { branchesQuery, frameworkQuery, @@ -106,7 +107,12 @@ export default class BaseClass { }) .catch((error) => { this.log('Deployment process failed.!', 'error'); - this.log(error, 'error'); + if (isLaunchDeploymentFileSizeRelatedError(error)) { + this.log(error, 'debug'); + this.log(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); + } else { + this.log(error, 'error'); + } this.exit(1); }); } @@ -748,6 +754,9 @@ export default class BaseClass { } } else if (includes(error?.graphQLErrors?.[0]?.extensions?.exception?.messages, 'launch.PROJECTS.LIMIT_REACHED')) { this.log('Launch project limit reached!', 'error'); + } else if (isLaunchDeploymentFileSizeRelatedError(error)) { + this.log(error, 'debug'); + this.log(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, 'error'); } else { this.log(error, 'error'); } diff --git a/src/util/deployment-errors.test.ts b/src/util/deployment-errors.test.ts new file mode 100644 index 00000000..342bfff --- /dev/null +++ b/src/util/deployment-errors.test.ts @@ -0,0 +1,158 @@ +import { + collectLaunchDeploymentErrorCodesFromGraphQLError, + FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE, + isLaunchDeploymentFileSizeRelatedError, +} from './deployment-errors'; + +describe('deployment-errors', () => { + it('should expose user message aligned with Launch file upload rules', () => { + expect(FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE).toBe( + 'Please use a file over the size of 1KB and under the size of 100MB.', + ); + }); + + it('should collect codes from messages, uploadUid errorObject, and cause graphQLErrors in one flow', () => { + const error = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.DEPLOYMENT.INVALID_FILE_SIZE', 'other'], + errorObject: { + uploadUid: [{ code: 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' }], + }, + }, + }, + }, + ], + cause: { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.OTHER.CODE'], + }, + }, + }, + ], + }, + }; + + const codes = collectLaunchDeploymentErrorCodesFromGraphQLError(error); + + expect(codes).toEqual([ + 'launch.DEPLOYMENT.INVALID_FILE_SIZE', + 'other', + 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED', + 'launch.OTHER.CODE', + ]); + expect(isLaunchDeploymentFileSizeRelatedError(error)).toBe(true); + }); + + it('should return false when graphQL errors have no file size related codes', () => { + const error = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: ['launch.PROJECTS.DUPLICATE_NAME'], + }, + }, + }, + ], + }; + + expect(collectLaunchDeploymentErrorCodesFromGraphQLError(error)).toEqual(['launch.PROJECTS.DUPLICATE_NAME']); + expect(isLaunchDeploymentFileSizeRelatedError(error)).toBe(false); + }); + + it('should return false for empty or malformed error input', () => { + expect(collectLaunchDeploymentErrorCodesFromGraphQLError(undefined)).toEqual([]); + expect(isLaunchDeploymentFileSizeRelatedError(undefined)).toBe(false); + expect(collectLaunchDeploymentErrorCodesFromGraphQLError({})).toEqual([]); + expect(isLaunchDeploymentFileSizeRelatedError({})).toBe(false); + expect( + collectLaunchDeploymentErrorCodesFromGraphQLError({ + graphQLErrors: [{ extensions: {} }], + }), + ).toEqual([]); + expect( + isLaunchDeploymentFileSizeRelatedError({ + graphQLErrors: [{ extensions: {} }], + }), + ).toBe(false); + }); + + it('should skip non-string entries in messages and still collect string codes', () => { + const error = { + graphQLErrors: [ + { + extensions: { + exception: { + messages: [ + null, + 42, + { nested: 'launch.DEPLOYMENT.INVALID_FILE_SIZE' }, + 'launch.DEPLOYMENT.INVALID_FILE_SIZE', + '', + ' ', + '\t', + ], + }, + }, + }, + ], + }; + + const codes = collectLaunchDeploymentErrorCodesFromGraphQLError(error); + + expect(codes).toEqual(['launch.DEPLOYMENT.INVALID_FILE_SIZE']); + expect(isLaunchDeploymentFileSizeRelatedError(error)).toBe(true); + }); + + it('should skip uploadUid entries without a string code and still collect valid codes', () => { + const error = { + graphQLErrors: [ + { + extensions: { + exception: { + errorObject: { + uploadUid: [ + null, + 'not-an-object', + {}, + { code: 123 }, + { notCode: 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' }, + { code: 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' }, + ], + }, + }, + }, + }, + ], + }; + + const codes = collectLaunchDeploymentErrorCodesFromGraphQLError(error); + + expect(codes).toEqual(['launch.DEPLOYMENT.FILE_UPLOAD_FAILED']); + expect(isLaunchDeploymentFileSizeRelatedError(error)).toBe(true); + }); + + it('should detect FILE_UPLOAD_FAILED only from uploadUid when messages are absent', () => { + const error = { + graphQLErrors: [ + { + extensions: { + exception: { + errorObject: { + uploadUid: [{ code: 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' }], + }, + }, + }, + }, + ], + }; + + expect(isLaunchDeploymentFileSizeRelatedError(error)).toBe(true); + }); +}); diff --git a/src/util/deployment-errors.ts b/src/util/deployment-errors.ts new file mode 100644 index 00000000..32a12a4 --- /dev/null +++ b/src/util/deployment-errors.ts @@ -0,0 +1,54 @@ +export const FILE_UPLOAD_SIZE_LIMIT_USER_MESSAGE = + 'Please use a file over the size of 1KB and under the size of 100MB.'; + +const DEPLOYMENT_FILE_SIZE_RELATED_CODES: readonly string[] = [ + 'launch.DEPLOYMENT.INVALID_FILE_SIZE', + 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED', +]; + +function appendCodesFromException(codes: string[], exception: Record | undefined | null): void { + if (!exception || typeof exception !== 'object') { + return; + } + const messages = exception.messages; + if (Array.isArray(messages)) { + for (const m of messages) { + if (typeof m === 'string' && m.trim() !== '') { + codes.push(m); + } + } + } + const errorObject = exception.errorObject as Record | undefined; + const uploadUid = errorObject?.uploadUid; + if (Array.isArray(uploadUid)) { + for (const item of uploadUid) { + if (item && typeof item === 'object' && typeof (item as { code?: unknown }).code === 'string') { + codes.push((item as { code: string }).code); + } + } + } +} + +export function collectLaunchDeploymentErrorCodesFromGraphQLError(error: unknown): string[] { + const codes: string[] = []; + const err = error as { + graphQLErrors?: unknown[]; + cause?: { graphQLErrors?: unknown[] }; + }; + const buckets = [err?.graphQLErrors, err?.cause?.graphQLErrors]; + for (const gqlErrors of buckets) { + if (!Array.isArray(gqlErrors)) { + continue; + } + for (const gqlError of gqlErrors) { + const ext = (gqlError as { extensions?: { exception?: Record } })?.extensions?.exception; + appendCodesFromException(codes, ext); + } + } + return codes; +} + +export function isLaunchDeploymentFileSizeRelatedError(error: unknown): boolean { + const codes = collectLaunchDeploymentErrorCodesFromGraphQLError(error); + return DEPLOYMENT_FILE_SIZE_RELATED_CODES.some((code) => codes.includes(code)); +}