diff --git a/src/adapters/file-upload.test.ts b/src/adapters/file-upload.test.ts index b8f1c81..7b0bd27 100644 --- a/src/adapters/file-upload.test.ts +++ b/src/adapters/file-upload.test.ts @@ -105,6 +105,30 @@ describe('FileUpload Adapter', () => { expect(showSuggestionMock).not.toHaveBeenCalled(); }); + it('should exit with code 1 when deployment status is CANCELLED for new project', async () => { + const fileUploadInstance = new FileUpload({ + config: { + isExistingProject: false, + currentDeploymentStatus: DeploymentStatus.CANCELLED, + }, + log: logMock, + exit: exitMock, + } as any); + + try { + await fileUploadInstance.run(); + } catch (error: any) { + expect(error.message).toBe('1'); + } + + expect(handleNewProjectMock).toHaveBeenCalled(); + expect(prepareLaunchConfigMock).toHaveBeenCalled(); + expect(showLogsMock).toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect(showDeploymentUrlMock).not.toHaveBeenCalled(); + expect(showSuggestionMock).not.toHaveBeenCalled(); + }); + it('should continue normally when deployment status is not FAILED', async () => { const fileUploadInstance = new FileUpload({ config: { diff --git a/src/adapters/file-upload.ts b/src/adapters/file-upload.ts index 7ce956f..7b2fd09 100755 --- a/src/adapters/file-upload.ts +++ b/src/adapters/file-upload.ts @@ -34,7 +34,10 @@ export default class FileUpload extends BaseClass { this.prepareLaunchConfig(); await this.showLogs(); - if(this.config.currentDeploymentStatus === DeploymentStatus.FAILED) { + if ( + this.config.currentDeploymentStatus === DeploymentStatus.FAILED || + this.config.currentDeploymentStatus === DeploymentStatus.CANCELLED + ) { this.exit(1); } this.showDeploymentUrl(); diff --git a/src/adapters/github.test.ts b/src/adapters/github.test.ts index e01e21f..97b525b 100644 --- a/src/adapters/github.test.ts +++ b/src/adapters/github.test.ts @@ -723,6 +723,30 @@ describe('GitHub Adapter', () => { expect(showSuggestionMock).not.toHaveBeenCalled(); }); + it('should exit with code 1 when deployment status is CANCELLED for new project', async () => { + const githubInstance = new GitHub({ + config: { + isExistingProject: false, + currentDeploymentStatus: DeploymentStatus.CANCELLED, + }, + log: logMock, + exit: exitMock, + } as any); + + try { + await githubInstance.run(); + } catch (error: any) { + expect(error.message).toBe('1'); + } + + expect(handleNewProjectMock).toHaveBeenCalled(); + expect(prepareLaunchConfigMock).toHaveBeenCalled(); + expect(showLogsMock).toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect(showDeploymentUrlMock).not.toHaveBeenCalled(); + expect(showSuggestionMock).not.toHaveBeenCalled(); + }); + it('should continue normally when deployment status is not FAILED', async () => { const githubInstance = new GitHub({ config: { diff --git a/src/adapters/github.ts b/src/adapters/github.ts index 57f1111..d8faf08 100755 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -35,7 +35,10 @@ export default class GitHub extends BaseClass { this.prepareLaunchConfig(); await this.showLogs(); - if (this.config.currentDeploymentStatus === DeploymentStatus.FAILED) { + if ( + this.config.currentDeploymentStatus === DeploymentStatus.FAILED || + this.config.currentDeploymentStatus === DeploymentStatus.CANCELLED + ) { this.exit(1); } this.showDeploymentUrl(); diff --git a/src/config/index.ts b/src/config/index.ts index d3b0f45..5ad1555 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -42,7 +42,7 @@ const config = { launchHubUrls: '', launchBaseUrl: '', supportedAdapters: ['GitHub'], - deploymentStatus: ['LIVE', 'FAILED', 'SKIPPED', 'DEPLOYED'], + deploymentStatus: ['LIVE', 'FAILED', 'SKIPPED', 'DEPLOYED', 'CANCELLED'], pollingInterval: 1000, variablePreparationTypeOptions: [ VariablePreparationTypeOptions.IMPORT_FROM_STACK, diff --git a/src/util/logs-polling-utilities.test.ts b/src/util/logs-polling-utilities.test.ts index a1b6ba2..83494e0 100644 --- a/src/util/logs-polling-utilities.test.ts +++ b/src/util/logs-polling-utilities.test.ts @@ -1,8 +1,25 @@ +import { EventEmitter } from 'events'; import { logPolling as cliUtilitiesJestMock } from '../test/mocks/cli-utilities'; +import LogPolling from './logs-polling-utilities'; +import defaultConfig from '../config'; type LogPollingCtor = typeof import('./logs-polling-utilities').default; jest.mock('@contentstack/cli-utilities', () => cliUtilitiesJestMock); +jest.mock('timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) })); + +function makeWatchQuery() { + let subscriber: (result: any) => void = () => {}; + return { + subscribe: jest.fn((cb: (result: any) => void) => { + subscriber = cb; + return { unsubscribe: jest.fn() }; + }), + setVariables: jest.fn(), + stopPolling: jest.fn(), + emit: (result: any) => subscriber(result), + }; +} const CONFIG = { deployment: 'd1', @@ -115,3 +132,66 @@ describe('LogPolling Apollo deprecation regression', () => { expect(watchQuery).toHaveBeenCalledTimes(1); }); }); + +describe('cancelled deployment stops log polling', () => { + function buildInstance(deploymentStatus: string[]) { + const statusWatchQuery = makeWatchQuery(); + const logsWatchQuery = makeWatchQuery(); + const config = { + deployment: 'd1', + environment: 'e1', + pollingInterval: 1000, + deploymentStatus, + }; + const instance = new LogPolling({ + apolloManageClient: { watchQuery: jest.fn().mockReturnValue(statusWatchQuery) } as any, + apolloLogsClient: { watchQuery: jest.fn().mockReturnValue(logsWatchQuery) } as any, + config: config as any, + $event: new EventEmitter(), + }); + return { instance, statusWatchQuery, logsWatchQuery, config }; + } + + it('stops status polling once the deployment status is CANCELLED', async () => { + const { instance, statusWatchQuery } = buildInstance(['LIVE', 'FAILED', 'SKIPPED', 'DEPLOYED', 'CANCELLED']); + + await instance.deploymentLogs(); + statusWatchQuery.emit({ data: { Deployment: { status: 'CANCELLED' } } }); + + expect(instance.deploymentStatus).toBe('CANCELLED'); + expect(statusWatchQuery.stopPolling).toHaveBeenCalledTimes(1); + }); + + it('stops deployment-logs polling and emits DONE once status is CANCELLED', async () => { + const { instance, statusWatchQuery, logsWatchQuery } = buildInstance([ + 'LIVE', + 'FAILED', + 'SKIPPED', + 'DEPLOYED', + 'CANCELLED', + ]); + const events: string[] = []; + (instance as any).$event.on('deployment-logs', (e: any) => events.push(e.message)); + + await instance.deploymentLogs(); + statusWatchQuery.emit({ data: { Deployment: { status: 'CANCELLED' } } }); + await logsWatchQuery.emit({ data: { getLogs: [] } }); + + expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1); + expect(events).toContain('DONE'); + }); + + it('regression guard: keeps polling forever if CANCELLED is missing from deploymentStatus', async () => { + const { instance, statusWatchQuery } = buildInstance(['LIVE', 'FAILED', 'SKIPPED', 'DEPLOYED']); + + await instance.deploymentLogs(); + statusWatchQuery.emit({ data: { Deployment: { status: 'CANCELLED' } } }); + + expect(instance.deploymentStatus).toBe('CANCELLED'); + expect(statusWatchQuery.stopPolling).not.toHaveBeenCalled(); + }); + + it('real app config (src/config) lists CANCELLED as a terminal deployment status', () => { + expect(defaultConfig.deploymentStatus).toContain('CANCELLED'); + }); +});