diff --git a/client/src/model/backend/gitlab/constants.ts b/client/src/model/backend/gitlab/constants.ts index f4988c287..815617302 100644 --- a/client/src/model/backend/gitlab/constants.ts +++ b/client/src/model/backend/gitlab/constants.ts @@ -8,3 +8,5 @@ export const RUNNER_TAG = 'linux'; // route/digitaltwins/execute/pipelineChecks.ts export const MAX_EXECUTION_TIME = 10 * 60 * 1000; + +export const PIPELINE_POLL_INTERVAL = 5000; // 5 seconds - for pipeline status checks diff --git a/client/src/model/backend/gitlab/execution/logFetching.ts b/client/src/model/backend/gitlab/execution/logFetching.ts new file mode 100644 index 000000000..4ebba4176 --- /dev/null +++ b/client/src/model/backend/gitlab/execution/logFetching.ts @@ -0,0 +1,175 @@ +import { JobLog } from 'model/backend/gitlab/types/executionHistory'; +import cleanLog from 'model/backend/gitlab/cleanLog'; + +interface GitLabJob { + id?: number; + name?: string; + [key: string]: unknown; +} + +/** + * Fetches job logs from GitLab for a specific pipeline + * Pure business logic - no UI dependencies + * @param gitlabInstance GitLab instance with API methods + * @param pipelineId Pipeline ID to fetch logs for + * @returns Promise resolving to array of job logs + */ +export const fetchJobLogs = async ( + gitlabInstance: { + projectId?: number | null; + getPipelineJobs: ( + projectId: number, + pipelineId: number, + ) => Promise; + getJobTrace: (projectId: number, jobId: number) => Promise; + }, + pipelineId: number, +): Promise => { + const { projectId } = gitlabInstance; + if (!projectId) { + return []; + } + + const rawJobs = await gitlabInstance.getPipelineJobs(projectId, pipelineId); + const jobs: GitLabJob[] = rawJobs.map((job) => job as GitLabJob); + + const logPromises = jobs.map((job) => fetchSingleJobLog(gitlabInstance, job)); + return (await Promise.all(logPromises)).reverse(); +}; + +/** + * Fetches the log for a single GitLab job. + * @param gitlabInstance - An object containing the GitLab project ID and a method to fetch the job trace. + * @param job - The GitLab job for which the log should be fetched. + * @returns A promise that resolves to a `JobLog` object containing the job name and its log content. + */ +export const fetchSingleJobLog = async ( + gitlabInstance: { + projectId?: number | null; + getJobTrace: (projectId: number, jobId: number) => Promise; + }, + job: GitLabJob, +): Promise => { + const { projectId } = gitlabInstance; + let result: JobLog; + + if (!projectId || job?.id === undefined) { + result = { + jobName: typeof job?.name === 'string' ? job.name : 'Unknown', + log: job?.id === undefined ? 'Job ID not available' : '', + }; + } else { + try { + let log = await gitlabInstance.getJobTrace(projectId, job.id); + + if (typeof log === 'string') { + log = cleanLog(log); + } else { + log = ''; + } + + result = { + jobName: typeof job.name === 'string' ? job.name : 'Unknown', + log, + }; + } catch (_e) { + result = { + jobName: typeof job.name === 'string' ? job.name : 'Unknown', + log: 'Error fetching log content', + }; + } + } + + return result; +}; + +/** + * Validates if job logs contain meaningful content + * @param logs Array of job logs to validate + * @returns True if logs contain meaningful content + */ +export const validateLogs = (logs: JobLog[]): boolean => { + if (!logs || logs.length === 0) return false; + + return !logs.every((log) => !log.log || log.log.trim() === ''); +}; + +/** + * Filters out empty or invalid job logs + * @param logs Array of job logs to filter + * @returns Filtered array of valid job logs + */ +export const filterValidLogs = (logs: JobLog[]): JobLog[] => { + if (!logs) return []; + + return logs.filter((log) => log.log && log.log.trim() !== ''); +}; + +/** + * Combines multiple job logs into a single log entry + * @param logs Array of job logs to combine + * @param separator Separator between logs (default: '\n---\n') + * @returns Combined log string + */ +export const combineLogs = ( + logs: JobLog[], + separator: string = '\n---\n', +): string => { + if (!logs || logs.length === 0) return ''; + + return logs + .filter((log) => log.log && log.log.trim() !== '') + .map((log) => `[${log.jobName}]\n${log.log}`) + .join(separator); +}; + +/** + * Extracts job names from job logs + * @param logs Array of job logs + * @returns Array of job names + */ +export const extractJobNames = (logs: JobLog[]): string[] => { + if (!logs) return []; + + return logs.map((log) => log.jobName).filter(Boolean); +}; + +/** + * Finds a specific job log by job name + * @param logs Array of job logs to search + * @param jobName Name of the job to find + * @returns The job log if found, undefined otherwise + */ +export const findJobLog = ( + logs: JobLog[], + jobName: string, +): JobLog | undefined => { + if (!logs || !jobName) return undefined; + + return logs.find((log) => log.jobName === jobName); +}; + +/** + * Counts the number of successful jobs based on log content + * @param logs Array of job logs to analyze + * @returns Number of jobs that appear to have succeeded + */ +export const countSuccessfulJobs = (logs: JobLog[]): number => + Array.isArray(logs) + ? logs.filter( + (log) => + typeof log.log === 'string' && /success|completed/i.test(log.log), + ).length + : 0; + +/** + * Counts the number of failed jobs based on log content + * @param logs Array of job logs to analyze + * @returns Number of jobs that appear to have failed + */ +export const countFailedJobs = (logs: JobLog[]): number => + Array.isArray(logs) + ? logs.filter( + (log) => typeof log.log === 'string' && /(error|failed)/i.test(log.log), + ).length + : 0; diff --git a/client/src/model/backend/gitlab/execution/pipelineCore.ts b/client/src/model/backend/gitlab/execution/pipelineCore.ts new file mode 100644 index 000000000..3523940e1 --- /dev/null +++ b/client/src/model/backend/gitlab/execution/pipelineCore.ts @@ -0,0 +1,85 @@ +import { + MAX_EXECUTION_TIME, + PIPELINE_POLL_INTERVAL, +} from 'model/backend/gitlab/constants'; + +/** + * Creates a delay promise for polling operations + * @param ms Milliseconds to delay + * @returns Promise that resolves after the specified time + */ +export const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * Checks if a pipeline execution has timed out + * @param startTime Timestamp when execution started + * @param maxTime Maximum allowed execution time (optional, defaults to MAX_EXECUTION_TIME) + * @returns True if execution has timed out + */ +export const hasTimedOut = ( + startTime: number, + maxTime: number = MAX_EXECUTION_TIME, +): boolean => Date.now() - startTime > maxTime; + +/** + * Determines the appropriate pipeline ID for execution + * @param executionPipelineId Pipeline ID from execution history (if available) + * @param fallbackPipelineId Fallback pipeline ID from digital twin + * @returns The pipeline ID to use + */ +export const determinePipelineId = ( + executionPipelineId?: number, + fallbackPipelineId?: number, +): number => { + if (executionPipelineId) return executionPipelineId; + if (fallbackPipelineId) return fallbackPipelineId; + throw new Error('No pipeline ID available'); +}; + +/** + * Determines the child pipeline ID (parent + 1) + * @param parentPipelineId The parent pipeline ID + * @returns The child pipeline ID + */ +export const getChildPipelineId = (parentPipelineId: number): number => + parentPipelineId + 1; + +/** + * Checks if a pipeline status indicates completion + * @param status Pipeline status string + * @returns True if pipeline is completed (success or failed) + */ +export const isPipelineCompleted = (status: string): boolean => + status === 'success' || status === 'failed'; + +/** + * Checks if a pipeline status indicates it's still running + * @param status Pipeline status string + * @returns True if pipeline is still running + */ +export const isPipelineRunning = (status: string): boolean => + status === 'running' || status === 'pending'; + +/** + * Determines if polling should continue based on status and timeout + * @param status Current pipeline status + * @param startTime When polling started + * @returns True if polling should continue + */ +export const shouldContinuePolling = ( + status: string, + startTime: number, +): boolean => { + if (isPipelineCompleted(status)) return false; + if (hasTimedOut(startTime)) return false; + return isPipelineRunning(status); +}; + +/** + * Gets the default polling interval for pipeline status checks + * @returns Polling interval in milliseconds + */ +export const getPollingInterval = (): number => PIPELINE_POLL_INTERVAL; diff --git a/client/src/model/backend/gitlab/execution/statusChecking.ts b/client/src/model/backend/gitlab/execution/statusChecking.ts new file mode 100644 index 000000000..8aea2bbac --- /dev/null +++ b/client/src/model/backend/gitlab/execution/statusChecking.ts @@ -0,0 +1,148 @@ +import { ExecutionStatus } from 'model/backend/gitlab/types/executionHistory'; + +/** + * Maps GitLab pipeline status to internal execution status + * @param gitlabStatus Status string from GitLab API + * @returns Internal execution status + */ +export const mapGitlabStatusToExecutionStatus = ( + gitlabStatus: string, +): ExecutionStatus => { + let executionStatus: ExecutionStatus; + switch (gitlabStatus.toLowerCase()) { + case 'success': + executionStatus = ExecutionStatus.COMPLETED; + break; + case 'failed': + executionStatus = ExecutionStatus.FAILED; + break; + case 'running': + case 'pending': + executionStatus = ExecutionStatus.RUNNING; + break; + case 'canceled': + case 'cancelled': + executionStatus = ExecutionStatus.CANCELED; + break; + case 'skipped': + executionStatus = ExecutionStatus.FAILED; // Treat skipped as failed + break; + default: + executionStatus = ExecutionStatus.RUNNING; // Default to running for unknown statuses + } + return executionStatus; +}; + +/** + * Determines if a GitLab status indicates success + * @param status GitLab pipeline status (can be null/undefined) + * @returns True if status indicates success + */ +export const isSuccessStatus = (status: string | null | undefined): boolean => + status?.toLowerCase() === 'success'; + +/** + * Determines if a GitLab status indicates failure + * @param status GitLab pipeline status (can be null/undefined) + * @returns True if status indicates failure + */ +export const isFailureStatus = (status: string | null | undefined): boolean => { + if (!status) return false; + const lowerStatus = status.toLowerCase(); + return lowerStatus === 'failed' || lowerStatus === 'skipped'; +}; + +/** + * Determines if a GitLab status indicates the pipeline is still running + * @param status GitLab pipeline status (can be null/undefined) + * @returns True if status indicates pipeline is running + */ +export const isRunningStatus = (status: string | null | undefined): boolean => { + if (!status) return false; + const lowerStatus = status.toLowerCase(); + return lowerStatus === 'running' || lowerStatus === 'pending'; +}; + +/** + * Determines if a GitLab status indicates the pipeline was canceled + * @param status GitLab pipeline status (can be null/undefined) + * @returns True if status indicates cancellation + */ +export const isCanceledStatus = ( + status: string | null | undefined, +): boolean => { + if (!status) return false; + const lowerStatus = status.toLowerCase(); + return lowerStatus === 'canceled' || lowerStatus === 'cancelled'; +}; + +/** + * Determines if a status indicates the pipeline has finished (success or failure) + * @param status GitLab pipeline status (can be null/undefined) + * @returns True if pipeline has finished + */ +export const isFinishedStatus = (status: string | null | undefined): boolean => + isSuccessStatus(status) || + isFailureStatus(status) || + isCanceledStatus(status); + +/** + * Gets a human-readable description of the pipeline status + * @param status GitLab pipeline status (can be null/undefined) + * @returns Human-readable status description + */ +export const getStatusDescription = ( + status: string | null | undefined, +): string => { + let description: string; + if (!status) { + description = 'Pipeline status: unknown'; + } else { + switch (status.toLowerCase()) { + case 'success': + description = 'Pipeline completed successfully'; + break; + case 'failed': + description = 'Pipeline failed'; + break; + case 'running': + description = 'Pipeline is running'; + break; + case 'pending': + description = 'Pipeline is pending'; + break; + case 'canceled': + case 'cancelled': + description = 'Pipeline was canceled'; + break; + case 'skipped': + description = 'Pipeline was skipped'; + break; + default: + description = `Pipeline status: ${status}`; + break; + } + } + return description; +}; + +/** + * Determines the severity level of a status for UI display + * @param status GitLab pipeline status (can be null/undefined) + * @returns Severity level ('success', 'error', 'warning', 'info') + */ +export const getStatusSeverity = ( + status: string | null | undefined, +): 'success' | 'error' | 'warning' | 'info' => { + let severity: 'success' | 'error' | 'warning' | 'info'; + if (isSuccessStatus(status)) { + severity = 'success'; + } else if (isFailureStatus(status)) { + severity = 'error'; + } else if (isCanceledStatus(status)) { + severity = 'warning'; + } else { + severity = 'info'; // Default to info for running/pending/unknown statuses + } + return severity; +}; diff --git a/client/src/model/backend/gitlab/types/executionHistory.ts b/client/src/model/backend/gitlab/types/executionHistory.ts new file mode 100644 index 000000000..46f6e15ef --- /dev/null +++ b/client/src/model/backend/gitlab/types/executionHistory.ts @@ -0,0 +1,15 @@ +export type JobName = string; +export type LogContent = string; + +export enum ExecutionStatus { + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELED = 'canceled', + TIMEOUT = 'timeout', +} + +export interface JobLog { + jobName: JobName; + log: LogContent; +} diff --git a/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts b/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts index d6a8cd248..05aff4dc7 100644 --- a/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts +++ b/client/src/preview/route/digitaltwins/execute/pipelineChecks.ts @@ -6,7 +6,15 @@ import { updatePipelineStateOnCompletion, } from 'preview/route/digitaltwins/execute/pipelineUtils'; import { showSnackbar } from 'preview/store/snackbar.slice'; -import { MAX_EXECUTION_TIME } from 'model/backend/gitlab/constants'; +import { + delay, + hasTimedOut, + getPollingInterval, +} from 'model/backend/gitlab/execution/pipelineCore'; +import { + isSuccessStatus, + isFailureStatus, +} from 'model/backend/gitlab/execution/statusChecking'; interface PipelineStatusParams { setButtonText: Dispatch>; @@ -15,14 +23,6 @@ interface PipelineStatusParams { dispatch: ReturnType; } -export const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -export const hasTimedOut = (startTime: number) => - Date.now() - startTime > MAX_EXECUTION_TIME; - export const handleTimeout = ( DTName: string, setButtonText: Dispatch>, @@ -58,7 +58,7 @@ export const checkParentPipelineStatus = async ({ digitalTwin.pipelineId!, ); - if (pipelineStatus === 'success') { + if (isSuccessStatus(pipelineStatus)) { await checkChildPipelineStatus({ setButtonText, digitalTwin, @@ -66,7 +66,7 @@ export const checkParentPipelineStatus = async ({ dispatch, startTime, }); - } else if (pipelineStatus === 'failed') { + } else if (isFailureStatus(pipelineStatus)) { const jobLogs = await fetchJobLogs( digitalTwin.gitlabInstance, digitalTwin.pipelineId!, @@ -86,8 +86,8 @@ export const checkParentPipelineStatus = async ({ dispatch, ); } else { - await delay(5000); - checkParentPipelineStatus({ + await delay(getPollingInterval()); + await checkParentPipelineStatus({ setButtonText, digitalTwin, setLogButtonDisabled, @@ -138,14 +138,17 @@ export const checkChildPipelineStatus = async ({ pipelineId, ); - if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + if (isSuccessStatus(pipelineStatus) || isFailureStatus(pipelineStatus)) { + const statusForCompletion = isSuccessStatus(pipelineStatus) + ? 'success' + : 'failed'; await handlePipelineCompletion( pipelineId, digitalTwin, setButtonText, setLogButtonDisabled, dispatch, - pipelineStatus, + statusForCompletion, ); } else if (hasTimedOut(startTime)) { handleTimeout( @@ -155,7 +158,7 @@ export const checkChildPipelineStatus = async ({ dispatch, ); } else { - await delay(5000); + await delay(getPollingInterval()); await checkChildPipelineStatus({ setButtonText, digitalTwin, diff --git a/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts b/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts index 75efbeffc..a1ea85f13 100644 --- a/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts +++ b/client/src/preview/route/digitaltwins/execute/pipelineUtils.ts @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction } from 'react'; import DigitalTwin, { formatName } from 'preview/util/digitalTwin'; import GitlabInstance from 'preview/util/gitlab'; -import cleanLog from 'model/backend/gitlab/cleanLog'; +import { fetchJobLogs as fetchJobLogsCore } from 'model/backend/gitlab/execution/logFetching'; import { setJobLogs, setPipelineCompleted, @@ -95,38 +95,5 @@ export const updatePipelineStateOnStop = ( export const fetchJobLogs = async ( gitlabInstance: GitlabInstance, pipelineId: number, -): Promise> => { - const { projectId } = gitlabInstance; - if (!projectId) { - return []; - } - - const jobs = await gitlabInstance.getPipelineJobs(projectId, pipelineId); - - const logPromises = jobs.map(async (job) => { - if (!job || typeof job.id === 'undefined') { - return { jobName: 'Unknown', log: 'Job ID not available' }; - } - - try { - let log = await gitlabInstance.getJobTrace(projectId, job.id); - - if (typeof log === 'string') { - log = cleanLog(log); - } else { - log = ''; - } - - return { - jobName: typeof job.name === 'string' ? job.name : 'Unknown', - log, - }; - } catch (_e) { - return { - jobName: typeof job.name === 'string' ? job.name : 'Unknown', - log: 'Error fetching log content', - }; - } - }); - return (await Promise.all(logPromises)).reverse(); -}; +): Promise> => + fetchJobLogsCore(gitlabInstance, pipelineId); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx index b8d671529..081979e5a 100644 --- a/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineChecks.test.tsx @@ -1,5 +1,6 @@ import * as PipelineChecks from 'preview/route/digitaltwins/execute/pipelineChecks'; import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import * as PipelineCore from 'model/backend/gitlab/execution/pipelineCore'; import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; import { previewStore as store } from 'test/preview/integration/integration.testUtil'; @@ -11,6 +12,12 @@ jest.mock('preview/route/digitaltwins/execute/pipelineUtils', () => ({ updatePipelineStateOnCompletion: jest.fn(), })); +jest.mock('model/backend/gitlab/execution/pipelineCore', () => ({ + delay: jest.fn(), + hasTimedOut: jest.fn(), + getPollingInterval: jest.fn(() => 5000), +})); + describe('PipelineChecks', () => { const digitalTwin = mockDigitalTwin; @@ -111,7 +118,7 @@ describe('PipelineChecks', () => { jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); - jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + jest.spyOn(PipelineCore, 'hasTimedOut').mockReturnValue(true); await PipelineChecks.checkParentPipelineStatus({ setButtonText, digitalTwin, @@ -126,14 +133,14 @@ describe('PipelineChecks', () => { }); it('checks parent pipeline status and returns running', async () => { - const delay = jest.spyOn(PipelineChecks, 'delay'); + const delay = jest.spyOn(PipelineCore, 'delay'); delay.mockImplementation(() => Promise.resolve()); jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); jest - .spyOn(PipelineChecks, 'hasTimedOut') + .spyOn(PipelineCore, 'hasTimedOut') .mockReturnValueOnce(false) .mockReturnValueOnce(true); @@ -182,7 +189,7 @@ describe('PipelineChecks', () => { jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); - jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + jest.spyOn(PipelineCore, 'hasTimedOut').mockReturnValue(true); await PipelineChecks.checkChildPipelineStatus(completeParams); @@ -190,7 +197,7 @@ describe('PipelineChecks', () => { }); it('checks child pipeline status and returns running', async () => { - const delay = jest.spyOn(PipelineChecks, 'delay'); + const delay = jest.spyOn(PipelineCore, 'delay'); delay.mockImplementation(() => Promise.resolve()); const getPipelineStatusMock = jest.spyOn( diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx index 74a93d043..0a830a3a3 100644 --- a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx @@ -1,4 +1,5 @@ import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import { fetchJobLogs } from 'model/backend/gitlab/execution/logFetching'; import cleanLog from 'model/backend/gitlab/cleanLog'; import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; @@ -64,7 +65,7 @@ describe('PipelineUtils', () => { const mockGetJobTrace = jest.spyOn(mockGitlabInstance, 'getJobTrace'); mockGetJobTrace.mockResolvedValue('log1'); - const result = await PipelineUtils.fetchJobLogs(mockGitlabInstance, 1); + const result = await fetchJobLogs(mockGitlabInstance, 1); expect(mockGetPipelineJobs).toHaveBeenCalledWith( mockGitlabInstance.projectId, @@ -93,7 +94,7 @@ describe('PipelineUtils', () => { const mockGetJobTrace = jest.spyOn(mockGitlabInstance, 'getJobTrace'); mockGetJobTrace.mockResolvedValue(rawLog); - const logs = await PipelineUtils.fetchJobLogs(mockGitlabInstance, 456); + const logs = await fetchJobLogs(mockGitlabInstance, 456); expect(logs).toHaveLength(1); expect(logs[0].jobName).toBe('test-job'); diff --git a/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts b/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts index 4ab162455..abdefe286 100644 --- a/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts +++ b/client/test/preview/unit/routes/digitaltwins/execute/PipelineChecks.test.ts @@ -1,5 +1,6 @@ import * as PipelineChecks from 'preview/route/digitaltwins/execute/pipelineChecks'; import * as PipelineUtils from 'preview/route/digitaltwins/execute/pipelineUtils'; +import * as PipelineCore from 'model/backend/gitlab/execution/pipelineCore'; import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; jest.mock('preview/util/digitalTwin', () => ({ @@ -12,6 +13,12 @@ jest.mock('preview/route/digitaltwins/execute/pipelineUtils', () => ({ updatePipelineStateOnCompletion: jest.fn(), })); +jest.mock('model/backend/gitlab/execution/pipelineCore', () => ({ + delay: jest.fn(), + hasTimedOut: jest.fn(), + getPollingInterval: jest.fn(() => 5000), +})); + jest.useFakeTimers(); describe('PipelineChecks', () => { @@ -104,7 +111,7 @@ describe('PipelineChecks', () => { jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); - jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + jest.spyOn(PipelineCore, 'hasTimedOut').mockReturnValue(true); await PipelineChecks.checkParentPipelineStatus({ setButtonText, digitalTwin, @@ -119,14 +126,14 @@ describe('PipelineChecks', () => { }); it('checks parent pipeline status and returns running', async () => { - const delay = jest.spyOn(PipelineChecks, 'delay'); + const delay = jest.spyOn(PipelineCore, 'delay'); delay.mockImplementation(() => Promise.resolve()); jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); jest - .spyOn(PipelineChecks, 'hasTimedOut') + .spyOn(PipelineCore, 'hasTimedOut') .mockReturnValueOnce(false) .mockReturnValueOnce(true); @@ -174,7 +181,7 @@ describe('PipelineChecks', () => { jest .spyOn(digitalTwin.gitlabInstance, 'getPipelineStatus') .mockResolvedValue('running'); - jest.spyOn(PipelineChecks, 'hasTimedOut').mockReturnValue(true); + jest.spyOn(PipelineCore, 'hasTimedOut').mockReturnValue(true); await PipelineChecks.checkChildPipelineStatus(completeParams); @@ -182,7 +189,7 @@ describe('PipelineChecks', () => { }); it('checks child pipeline status and returns running', async () => { - const delay = jest.spyOn(PipelineChecks, 'delay'); + const delay = jest.spyOn(PipelineCore, 'delay'); delay.mockImplementation(() => Promise.resolve()); const getPipelineStatusMock = jest.spyOn( diff --git a/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts b/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts index ffac238a3..a18590173 100644 --- a/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts +++ b/client/test/preview/unit/routes/digitaltwins/execute/PipelineUtils.test.ts @@ -1,8 +1,8 @@ import { - fetchJobLogs, startPipeline, updatePipelineStateOnCompletion, } from 'preview/route/digitaltwins/execute/pipelineUtils'; +import { fetchJobLogs } from 'model/backend/gitlab/execution/logFetching'; import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; import { JobSchema } from '@gitbeaker/rest'; import GitlabInstance from 'preview/util/gitlab'; diff --git a/client/test/unit/model/backend/gitlab/execution/logFetching.test.ts b/client/test/unit/model/backend/gitlab/execution/logFetching.test.ts new file mode 100644 index 000000000..923b1e4e1 --- /dev/null +++ b/client/test/unit/model/backend/gitlab/execution/logFetching.test.ts @@ -0,0 +1,139 @@ +import { + fetchJobLogs, + validateLogs, + filterValidLogs, + combineLogs, + extractJobNames, + findJobLog, + countSuccessfulJobs, + countFailedJobs, +} from 'model/backend/gitlab/execution/logFetching'; +import { JobLog } from 'model/backend/gitlab/types/executionHistory'; + +describe('logFetching', () => { + const mockGitlabInstance = { + projectId: 123, + getPipelineJobs: jest.fn(), + getJobTrace: jest.fn(), + }; + + const mockJobs = [ + { id: 1, name: 'job1' }, + { id: 2, name: 'job2' }, + ]; + + const mockJobLogs: JobLog[] = [ + { jobName: 'job1', log: 'Success: Job completed' }, + { jobName: 'job2', log: 'Error: Job failed' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchJobLogs', () => { + it('should fetch job logs successfully', async () => { + mockGitlabInstance.getPipelineJobs.mockResolvedValue(mockJobs); + mockGitlabInstance.getJobTrace + .mockResolvedValueOnce('Success: Job completed') + .mockResolvedValueOnce('Error: Job failed'); + + const result = await fetchJobLogs(mockGitlabInstance, 456); + + expect(result).toHaveLength(2); + expect(result[0].jobName).toBe('job2'); + expect(result[1].jobName).toBe('job1'); + expect(mockGitlabInstance.getPipelineJobs).toHaveBeenCalledWith(123, 456); + }); + + it('should return empty array when projectId is null', async () => { + const instanceWithoutProject = { ...mockGitlabInstance, projectId: null }; + const result = await fetchJobLogs(instanceWithoutProject, 456); + expect(result).toEqual([]); + }); + + it('should handle jobs without id', async () => { + mockGitlabInstance.getPipelineJobs.mockResolvedValue([{ name: 'job1' }]); + const result = await fetchJobLogs(mockGitlabInstance, 456); + expect(result[0].log).toBe('Job ID not available'); + }); + + it('should handle trace fetch errors', async () => { + mockGitlabInstance.getPipelineJobs.mockResolvedValue(mockJobs); + mockGitlabInstance.getJobTrace.mockRejectedValue(new Error('API Error')); + + const result = await fetchJobLogs(mockGitlabInstance, 456); + expect(result[0].log).toBe('Error fetching log content'); + }); + }); + + describe('validateLogs', () => { + it('should return true for valid logs', () => { + expect(validateLogs(mockJobLogs)).toBe(true); + }); + + it('should return false for empty logs', () => { + expect(validateLogs([])).toBe(false); + expect(validateLogs([{ jobName: 'test', log: '' }])).toBe(false); + }); + }); + + describe('filterValidLogs', () => { + it('should filter out empty logs', () => { + const logsWithEmpty = [ + ...mockJobLogs, + { jobName: 'empty', log: '' }, + { jobName: 'whitespace', log: ' ' }, + ]; + const result = filterValidLogs(logsWithEmpty); + expect(result).toHaveLength(2); + }); + }); + + describe('combineLogs', () => { + it('should combine logs with default separator', () => { + const result = combineLogs(mockJobLogs); + expect(result).toContain('[job1]'); + expect(result).toContain('[job2]'); + expect(result).toContain('\n---\n'); + }); + + it('should use custom separator', () => { + const result = combineLogs(mockJobLogs, ' | '); + expect(result).toContain(' | '); + }); + }); + + describe('extractJobNames', () => { + it('should extract job names', () => { + const result = extractJobNames(mockJobLogs); + expect(result).toEqual(['job1', 'job2']); + }); + }); + + describe('findJobLog', () => { + it('should find job by name', () => { + const result = findJobLog(mockJobLogs, 'job1'); + expect(result?.jobName).toBe('job1'); + }); + + it('should return undefined for non-existent job', () => { + const result = findJobLog(mockJobLogs, 'nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('countSuccessfulJobs', () => { + it('should count successful jobs', () => { + const result = countSuccessfulJobs(mockJobLogs); + expect(result).toBe(1); + }); + }); + + describe('countFailedJobs', () => { + it('should count failed jobs', () => { + const result = countFailedJobs(mockJobLogs); + expect(result).toBe(1); + }); + }); +}); diff --git a/client/test/unit/model/backend/gitlab/execution/pipelineCore.test.ts b/client/test/unit/model/backend/gitlab/execution/pipelineCore.test.ts new file mode 100644 index 000000000..528d620b9 --- /dev/null +++ b/client/test/unit/model/backend/gitlab/execution/pipelineCore.test.ts @@ -0,0 +1,121 @@ +import { + delay, + hasTimedOut, + determinePipelineId, + getChildPipelineId, + isPipelineCompleted, + isPipelineRunning, + shouldContinuePolling, + getPollingInterval, +} from 'model/backend/gitlab/execution/pipelineCore'; + +describe('pipelineCore', () => { + describe('delay', () => { + it('should delay for specified milliseconds', async () => { + const start = Date.now(); + await delay(100); + const end = Date.now(); + expect(end - start).toBeGreaterThanOrEqual(90); // Allow some tolerance + }); + }); + + describe('hasTimedOut', () => { + it('should return false for recent start time', () => { + const recentTime = Date.now() - 1000; // 1 second ago + expect(hasTimedOut(recentTime)).toBe(false); + }); + + it('should return true for old start time', () => { + const oldTime = Date.now() - 15 * 60 * 1000; // 15 minutes ago + expect(hasTimedOut(oldTime)).toBe(true); + }); + + it('should use custom max time', () => { + const startTime = Date.now() - 2000; // 2 seconds ago + expect(hasTimedOut(startTime, 1000)).toBe(true); // 1 second max + expect(hasTimedOut(startTime, 5000)).toBe(false); // 5 second max + }); + }); + + describe('determinePipelineId', () => { + it('should return execution pipeline id when available', () => { + const result = determinePipelineId(123, 456); + expect(result).toBe(123); + }); + + it('should return fallback pipeline id when execution id not available', () => { + const result = determinePipelineId(undefined, 456); + expect(result).toBe(456); + }); + + it('should throw error when no pipeline id available', () => { + expect(() => determinePipelineId(undefined, undefined)).toThrow( + 'No pipeline ID available', + ); + }); + }); + + describe('getChildPipelineId', () => { + it('should return parent pipeline id + 1', () => { + expect(getChildPipelineId(100)).toBe(101); + expect(getChildPipelineId(999)).toBe(1000); + }); + }); + + describe('isPipelineCompleted', () => { + it('should return true for completed statuses', () => { + expect(isPipelineCompleted('success')).toBe(true); + expect(isPipelineCompleted('failed')).toBe(true); + }); + + it('should return false for non-completed statuses', () => { + expect(isPipelineCompleted('running')).toBe(false); + expect(isPipelineCompleted('pending')).toBe(false); + expect(isPipelineCompleted('canceled')).toBe(false); + }); + }); + + describe('isPipelineRunning', () => { + it('should return true for running statuses', () => { + expect(isPipelineRunning('running')).toBe(true); + expect(isPipelineRunning('pending')).toBe(true); + }); + + it('should return false for non-running statuses', () => { + expect(isPipelineRunning('success')).toBe(false); + expect(isPipelineRunning('failed')).toBe(false); + expect(isPipelineRunning('canceled')).toBe(false); + }); + }); + + describe('shouldContinuePolling', () => { + const recentTime = Date.now() - 1000; // 1 second ago + const oldTime = Date.now() - 15 * 60 * 1000; // 15 minutes ago + + it('should return false for completed status', () => { + expect(shouldContinuePolling('success', recentTime)).toBe(false); + expect(shouldContinuePolling('failed', recentTime)).toBe(false); + }); + + it('should return false for timed out execution', () => { + expect(shouldContinuePolling('running', oldTime)).toBe(false); + }); + + it('should return true for running status within time limit', () => { + expect(shouldContinuePolling('running', recentTime)).toBe(true); + expect(shouldContinuePolling('pending', recentTime)).toBe(true); + }); + + it('should return false for unknown status', () => { + expect(shouldContinuePolling('unknown', recentTime)).toBe(false); + }); + }); + + describe('getPollingInterval', () => { + it('should return the polling interval constant', () => { + const interval = getPollingInterval(); + expect(typeof interval).toBe('number'); + expect(interval).toBeGreaterThan(0); + }); + }); +}); diff --git a/client/test/unit/model/backend/gitlab/execution/statusChecking.test.ts b/client/test/unit/model/backend/gitlab/execution/statusChecking.test.ts new file mode 100644 index 000000000..466531b98 --- /dev/null +++ b/client/test/unit/model/backend/gitlab/execution/statusChecking.test.ts @@ -0,0 +1,183 @@ +import { + mapGitlabStatusToExecutionStatus, + isSuccessStatus, + isFailureStatus, + isRunningStatus, + isCanceledStatus, + isFinishedStatus, + getStatusDescription, + getStatusSeverity, +} from 'model/backend/gitlab/execution/statusChecking'; +import { ExecutionStatus } from 'model/backend/gitlab/types/executionHistory'; + +describe('statusChecking', () => { + describe('mapGitlabStatusToExecutionStatus', () => { + it('should map success status', () => { + expect(mapGitlabStatusToExecutionStatus('success')).toBe( + ExecutionStatus.COMPLETED, + ); + expect(mapGitlabStatusToExecutionStatus('SUCCESS')).toBe( + ExecutionStatus.COMPLETED, + ); + }); + + it('should map failed status', () => { + expect(mapGitlabStatusToExecutionStatus('failed')).toBe( + ExecutionStatus.FAILED, + ); + expect(mapGitlabStatusToExecutionStatus('FAILED')).toBe( + ExecutionStatus.FAILED, + ); + }); + + it('should map running statuses', () => { + expect(mapGitlabStatusToExecutionStatus('running')).toBe( + ExecutionStatus.RUNNING, + ); + expect(mapGitlabStatusToExecutionStatus('pending')).toBe( + ExecutionStatus.RUNNING, + ); + }); + + it('should map canceled statuses', () => { + expect(mapGitlabStatusToExecutionStatus('canceled')).toBe( + ExecutionStatus.CANCELED, + ); + expect(mapGitlabStatusToExecutionStatus('cancelled')).toBe( + ExecutionStatus.CANCELED, + ); + }); + + it('should map skipped as failed', () => { + expect(mapGitlabStatusToExecutionStatus('skipped')).toBe( + ExecutionStatus.FAILED, + ); + }); + + it('should default unknown statuses to running', () => { + expect(mapGitlabStatusToExecutionStatus('unknown')).toBe( + ExecutionStatus.RUNNING, + ); + expect(mapGitlabStatusToExecutionStatus('created')).toBe( + ExecutionStatus.RUNNING, + ); + }); + }); + + describe('isSuccessStatus', () => { + it('should return true for success status', () => { + expect(isSuccessStatus('success')).toBe(true); + expect(isSuccessStatus('SUCCESS')).toBe(true); + }); + + it('should return false for non-success statuses', () => { + expect(isSuccessStatus('failed')).toBe(false); + expect(isSuccessStatus('running')).toBe(false); + expect(isSuccessStatus('pending')).toBe(false); + }); + + it('should return false for null/undefined status', () => { + expect(isSuccessStatus(null)).toBe(false); + expect(isSuccessStatus(undefined)).toBe(false); + }); + }); + + describe('isFailureStatus', () => { + it('should return true for failure statuses', () => { + expect(isFailureStatus('failed')).toBe(true); + expect(isFailureStatus('FAILED')).toBe(true); + expect(isFailureStatus('skipped')).toBe(true); + expect(isFailureStatus('SKIPPED')).toBe(true); + }); + + it('should return false for non-failure statuses', () => { + expect(isFailureStatus('success')).toBe(false); + expect(isFailureStatus('running')).toBe(false); + expect(isFailureStatus('canceled')).toBe(false); + }); + + it('should return false for null/undefined status', () => { + expect(isFailureStatus(null)).toBe(false); + expect(isFailureStatus(undefined)).toBe(false); + }); + }); + + describe('isRunningStatus', () => { + it('should return true for running statuses', () => { + expect(isRunningStatus('running')).toBe(true); + expect(isRunningStatus('RUNNING')).toBe(true); + expect(isRunningStatus('pending')).toBe(true); + expect(isRunningStatus('PENDING')).toBe(true); + }); + + it('should return false for non-running statuses', () => { + expect(isRunningStatus('success')).toBe(false); + expect(isRunningStatus('failed')).toBe(false); + expect(isRunningStatus('canceled')).toBe(false); + }); + }); + + describe('isCanceledStatus', () => { + it('should return true for canceled statuses', () => { + expect(isCanceledStatus('canceled')).toBe(true); + expect(isCanceledStatus('CANCELED')).toBe(true); + expect(isCanceledStatus('cancelled')).toBe(true); + expect(isCanceledStatus('CANCELLED')).toBe(true); + }); + + it('should return false for non-canceled statuses', () => { + expect(isCanceledStatus('success')).toBe(false); + expect(isCanceledStatus('failed')).toBe(false); + expect(isCanceledStatus('running')).toBe(false); + }); + }); + + describe('isFinishedStatus', () => { + it('should return true for finished statuses', () => { + expect(isFinishedStatus('success')).toBe(true); + expect(isFinishedStatus('failed')).toBe(true); + expect(isFinishedStatus('canceled')).toBe(true); + expect(isFinishedStatus('cancelled')).toBe(true); + expect(isFinishedStatus('skipped')).toBe(true); + }); + + it('should return false for non-finished statuses', () => { + expect(isFinishedStatus('running')).toBe(false); + expect(isFinishedStatus('pending')).toBe(false); + expect(isFinishedStatus('unknown')).toBe(false); + }); + }); + + describe('getStatusDescription', () => { + it('should return correct descriptions', () => { + expect(getStatusDescription('success')).toBe( + 'Pipeline completed successfully', + ); + expect(getStatusDescription('failed')).toBe('Pipeline failed'); + expect(getStatusDescription('running')).toBe('Pipeline is running'); + expect(getStatusDescription('pending')).toBe('Pipeline is pending'); + expect(getStatusDescription('canceled')).toBe('Pipeline was canceled'); + expect(getStatusDescription('cancelled')).toBe('Pipeline was canceled'); + expect(getStatusDescription('skipped')).toBe('Pipeline was skipped'); + expect(getStatusDescription('unknown')).toBe('Pipeline status: unknown'); + }); + + it('should handle null/undefined status', () => { + expect(getStatusDescription(null)).toBe('Pipeline status: unknown'); + expect(getStatusDescription(undefined)).toBe('Pipeline status: unknown'); + }); + }); + + describe('getStatusSeverity', () => { + it('should return correct severity levels', () => { + expect(getStatusSeverity('success')).toBe('success'); + expect(getStatusSeverity('failed')).toBe('error'); + expect(getStatusSeverity('skipped')).toBe('error'); + expect(getStatusSeverity('canceled')).toBe('warning'); + expect(getStatusSeverity('cancelled')).toBe('warning'); + expect(getStatusSeverity('running')).toBe('info'); + expect(getStatusSeverity('pending')).toBe('info'); + expect(getStatusSeverity('unknown')).toBe('info'); + }); + }); +});