diff --git a/apps/gitness/config/vitest-setup.ts b/apps/gitness/config/vitest-setup.ts new file mode 100644 index 0000000000..6538addaf1 --- /dev/null +++ b/apps/gitness/config/vitest-setup.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest' + +Object.defineProperty(document, 'queryCommandSupported', { + value: vi.fn().mockImplementation((command: string) => { + return command === 'copy' || command === 'cut' + }), + writable: true +}) diff --git a/apps/gitness/src/components/GitBlame.tsx b/apps/gitness/src/components/GitBlame.tsx index df5817117b..927c6349db 100644 --- a/apps/gitness/src/components/GitBlame.tsx +++ b/apps/gitness/src/components/GitBlame.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react' import { useGetBlameQuery } from '@harnessio/code-service-client' +import { getInitials } from '@harnessio/ui/utils' import { BlameEditor, BlameEditorProps, ThemeDefinition } from '@harnessio/yaml-editor' import { BlameItem } from '@harnessio/yaml-editor/dist/types/blame' import { useGetRepoRef } from '../framework/hooks/useGetRepoPath' import useCodePathDetails from '../hooks/useCodePathDetails' import { timeAgoFromISOTime } from '../pages/pipeline-edit/utils/time-utils' -import { getInitials } from '../utils/common-utils' import { normalizeGitRef } from '../utils/git-utils' interface GitBlameProps { diff --git a/apps/gitness/src/utils/__tests__/common-utils.test.ts b/apps/gitness/src/utils/__tests__/common-utils.test.ts new file mode 100644 index 0000000000..750ae0d8b7 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/common-utils.test.ts @@ -0,0 +1,144 @@ +import { SummaryItemType, type RepoFile } from '@harnessio/ui/views' + +import { getLogsText, sortFilesByType } from '../common-utils' + +// Mock data for testing +const mockLogs = [{ out: 'Log line 1\n' }, { out: 'Log line 2\n' }, { out: 'Log line 3\n' }] + +const mockFiles: RepoFile[] = [ + { + name: 'file1.txt', + type: SummaryItemType.File, + id: '1', + path: 'file1.txt', + lastCommitMessage: 'file1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'folder1', + type: SummaryItemType.Folder, + id: '2', + path: 'folder1', + lastCommitMessage: 'folder1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'file2.txt', + type: SummaryItemType.File, + id: '3', + path: 'file2.txt', + lastCommitMessage: 'file2 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'folder2', + type: SummaryItemType.Folder, + id: '4', + path: 'folder2', + lastCommitMessage: 'folder2 commit', + timestamp: '2021-09-01T00:00:00Z' + } +] + +describe('getLogsText', () => { + it('should concatenate log lines into a single string', () => { + const result = getLogsText(mockLogs) + expect(result).toBe('Log line 1\nLog line 2\nLog line 3\n') + }) + + it('should return an empty string if logs array is empty', () => { + const result = getLogsText([]) + expect(result).toBe('') + }) +}) + +describe('sortFilesByType', () => { + it('should sort files by type, with folders first', () => { + const result = sortFilesByType(mockFiles) + expect(result).toEqual([ + { + name: 'folder1', + type: SummaryItemType.Folder, + id: '2', + path: 'folder1', + lastCommitMessage: 'folder1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'folder2', + type: SummaryItemType.Folder, + id: '4', + path: 'folder2', + lastCommitMessage: 'folder2 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'file1.txt', + type: SummaryItemType.File, + id: '1', + path: 'file1.txt', + lastCommitMessage: 'file1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'file2.txt', + type: SummaryItemType.File, + id: '3', + path: 'file2.txt', + lastCommitMessage: 'file2 commit', + timestamp: '2021-09-01T00:00:00Z' + } + ]) + }) + + it('should handle an empty array', () => { + const result = sortFilesByType([]) + expect(result).toEqual([]) + }) + + it('should handle an array with only files', () => { + const filesOnly = [ + { + name: 'file1.txt', + type: SummaryItemType.File, + id: '1', + path: 'file1.txt', + lastCommitMessage: 'file1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'file2.txt', + type: SummaryItemType.File, + id: '2', + path: 'file2.txt', + lastCommitMessage: 'file2 commit', + timestamp: '2021-10-01T00:00:00Z' + } + ] + const result = sortFilesByType(filesOnly) + expect(result).toEqual(filesOnly) + }) + + it('should handle an array with only folders', () => { + const foldersOnly = [ + { + name: 'folder1', + type: SummaryItemType.Folder, + id: '2', + path: 'folder1', + lastCommitMessage: 'folder1 commit', + timestamp: '2021-09-01T00:00:00Z' + }, + { + name: 'folder2', + type: SummaryItemType.Folder, + id: '2', + path: 'folder2', + lastCommitMessage: 'folder2 commit', + timestamp: '2021-10-01T00:00:00Z' + } + ] + const result = sortFilesByType(foldersOnly) + expect(result).toEqual(foldersOnly) + }) +}) diff --git a/apps/gitness/src/utils/__tests__/error-utils.test.ts b/apps/gitness/src/utils/__tests__/error-utils.test.ts new file mode 100644 index 0000000000..19fdb87498 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/error-utils.test.ts @@ -0,0 +1,40 @@ +import { getErrorMessage } from '../error-utils' + +// Define a custom error type for testing +interface CustomError { + data?: { + error?: string + message?: string + } + message?: string +} + +describe('getErrorMessage', () => { + it('should handle undefined error', () => { + expect(getErrorMessage(undefined)).toBeUndefined() + }) + + it('should handle null error', () => { + expect(getErrorMessage(null)).toBeUndefined() + }) + + it('should extract error from data.error', () => { + const error: CustomError = { data: { error: 'Test error message' } } + expect(getErrorMessage(error)).toBe('Test error message') + }) + + it('should extract error from data.message', () => { + const error: CustomError = { data: { message: 'Test message' } } + expect(getErrorMessage(error)).toBe('Test message') + }) + + it('should extract error from message', () => { + const error: CustomError = { message: 'Direct message' } + expect(getErrorMessage(error)).toBe('Direct message') + }) + + it('should return error as string if no structured format is found', () => { + const error = 'Plain error string' + expect(getErrorMessage(error)).toBe('Plain error string') + }) +}) diff --git a/apps/gitness/src/utils/__tests__/execution-utils.test.tsx b/apps/gitness/src/utils/__tests__/execution-utils.test.tsx new file mode 100644 index 0000000000..eeb81bfebc --- /dev/null +++ b/apps/gitness/src/utils/__tests__/execution-utils.test.tsx @@ -0,0 +1,121 @@ +import { EnumCiStatus, TypesExecution } from '@harnessio/code-service-client' +import { MeterState } from '@harnessio/ui/components' +import { PipelineExecutionStatus } from '@harnessio/ui/views' + +import { getExecutionStatus, getLabel, getMeterState } from '../execution-utils' + +describe('getLabel', () => { + it('should return empty string for missing author_name or event', () => { + const baseExecution: TypesExecution = { + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + + expect(getLabel({ ...baseExecution, author_name: '', event: undefined })).toBe('') + expect(getLabel({ ...baseExecution, author_name: 'test', event: undefined })).toBe('') + expect(getLabel({ ...baseExecution, author_name: '', event: 'manual' })).toBe('') + }) + + it('should handle manual event', () => { + const execution: TypesExecution = { + author_name: 'John Doe', + event: 'manual', + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + expect(getLabel(execution)).toBe('John Doe triggered manually') + }) + + it('should handle pull_request event with source and target', () => { + const execution: TypesExecution = { + author_name: 'John Doe', + event: 'pull_request', + source: 'feature', + target: 'main', + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + const result = getLabel(execution) + expect(result).toBeTruthy() // Since it returns a React element, we just verify it's not null + }) +}) +describe('getExecutionStatus', () => { + it('should map running status correctly', () => { + expect(getExecutionStatus('running')).toBe(PipelineExecutionStatus.RUNNING) + }) + + it('should map success status correctly', () => { + expect(getExecutionStatus('success')).toBe(PipelineExecutionStatus.SUCCESS) + }) + + it('should map failure status correctly', () => { + expect(getExecutionStatus('failure')).toBe(PipelineExecutionStatus.FAILURE) + }) + + it('should map error status correctly', () => { + expect(getExecutionStatus('error')).toBe(PipelineExecutionStatus.ERROR) + }) + + it('should map killed status correctly', () => { + expect(getExecutionStatus('killed')).toBe(PipelineExecutionStatus.KILLED) + }) + + it('should return UNKNOWN for undefined status', () => { + expect(getExecutionStatus(undefined)).toBe(PipelineExecutionStatus.UNKNOWN) + }) + + it('should return UNKNOWN for invalid status', () => { + const invalidStatus = 'invalid' as EnumCiStatus + expect(getExecutionStatus(invalidStatus)).toBe(PipelineExecutionStatus.UNKNOWN) + }) +}) + +describe('getMeterState', () => { + it('should return Error state for failure status', () => { + expect(getMeterState(PipelineExecutionStatus.FAILURE)).toBe(MeterState.Error) + }) + + it('should return Error state for killed status', () => { + expect(getMeterState(PipelineExecutionStatus.KILLED)).toBe(MeterState.Error) + }) + + it('should return Error state for error status', () => { + expect(getMeterState(PipelineExecutionStatus.ERROR)).toBe(MeterState.Error) + }) + + it('should return Success state for success status', () => { + expect(getMeterState(PipelineExecutionStatus.SUCCESS)).toBe(MeterState.Success) + }) + + it('should return Warning state for skipped status', () => { + expect(getMeterState(PipelineExecutionStatus.SKIPPED)).toBe(MeterState.Warning) + }) + + it('should return Warning state for blocked status', () => { + expect(getMeterState(PipelineExecutionStatus.BLOCKED)).toBe(MeterState.Warning) + }) + + it('should return Empty state for pending status', () => { + expect(getMeterState(PipelineExecutionStatus.PENDING)).toBe(MeterState.Empty) + }) + + it('should return Empty state for waiting on dependencies status', () => { + expect(getMeterState(PipelineExecutionStatus.WAITING_ON_DEPENDENCIES)).toBe(MeterState.Empty) + }) + + it('should return Empty state for undefined status', () => { + expect(getMeterState(undefined)).toBe(MeterState.Empty) + }) +}) diff --git a/apps/gitness/src/utils/__tests__/git-utils.test.ts b/apps/gitness/src/utils/__tests__/git-utils.test.ts new file mode 100644 index 0000000000..70535aabce --- /dev/null +++ b/apps/gitness/src/utils/__tests__/git-utils.test.ts @@ -0,0 +1,185 @@ +import langMap from 'lang-map' + +import { + decodeGitContent, + filenameToLanguage, + formatBytes, + getTrimmedSha, + GitCommitAction, + isGitRev, + isRefABranch, + isRefATag, + normalizeGitRef, + REFS_BRANCH_PREFIX, + REFS_TAGS_PREFIX +} from '../git-utils' + +describe('formatBytes', () => { + it('should format bytes correctly', () => { + expect(formatBytes(0)).toBe('0 Bytes') + expect(formatBytes(1024)).toBe('1 KB') + expect(formatBytes(1024 * 1024)).toBe('1 MB') + expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB') + }) + + it('should handle decimal places correctly', () => { + expect(formatBytes(1234, 1)).toBe('1.2 KB') + expect(formatBytes(1234, 0)).toBe('1 KB') + expect(formatBytes(1234, 3)).toBe('1.205 KB') + }) + + it('should handle negative decimals', () => { + expect(formatBytes(1234, -1)).toBe('1 KB') + }) +}) + +describe('decodeGitContent', () => { + let mockConsoleError: ReturnType + let mockAtob: ReturnType + + beforeEach(() => { + // Spy on console.error + mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Spy on window.atob with explicit parameter/return signatures + mockAtob = vi + .spyOn(window, 'atob') + .mockImplementation((str: unknown) => Buffer.from(str as string, 'base64').toString()) as never + }) + + afterEach(() => { + mockConsoleError.mockRestore() + }) + + it('should decode base64 content correctly', () => { + const base64Content = Buffer.from('Hello World').toString('base64') + expect(decodeGitContent(base64Content)).toBe('Hello World') + }) + + it('should handle empty content', () => { + expect(decodeGitContent()).toBe('') + expect(decodeGitContent('')).toBe('') + }) + + it('should handle invalid base64 content', () => { + expect(decodeGitContent('invalid-base64')).toBe('invalid-base64') + }) + + it('should handle decoding errors', () => { + mockAtob.mockImplementation(() => { + throw new Error('Decoding error') + }) + expect(decodeGitContent('error-content')).toBe('error-content') + }) +}) + +describe('filenameToLanguage', () => { + const mockLanguagesMap = new Map([ + ['js', ['javascript']], + ['py', ['python']], + ['go', ['go']], + ['ts', ['typescript']] + ]) + + const mockLanguages = vi.spyOn(langMap, 'languages') + + beforeEach(() => { + // Mock the langMap.languages function + mockLanguages.mockImplementation(ext => mockLanguagesMap.get(ext) || []) + }) + + afterEach(() => { + mockLanguages.mockRestore() + }) + + it('should detect common file extensions', () => { + expect(filenameToLanguage('test.js')).toBe('javascript') + expect(filenameToLanguage('test.py')).toBe('python') + expect(filenameToLanguage('test.go')).toBe('go') + expect(filenameToLanguage('test.ts')).toBe('typescript') + }) + + it('should map special extensions correctly', () => { + expect(filenameToLanguage('test.jsx')).toBe('typescript') + expect(filenameToLanguage('test.tsx')).toBe('typescript') + expect(filenameToLanguage('.gitignore')).toBe('shell') + expect(filenameToLanguage('Dockerfile')).toBe('dockerfile') + }) + + it('should handle extensions from lang-map that match Monaco supported languages', () => { + expect(filenameToLanguage('test.custom')).toBe('plaintext') + }) + + it('should return plaintext for unknown extensions', () => { + expect(filenameToLanguage('test.unknown')).toBe('plaintext') + expect(filenameToLanguage('')).toBe('plaintext') + expect(filenameToLanguage(undefined)).toBe('plaintext') + }) +}) + +describe('Git Reference Functions', () => { + describe('isRefATag', () => { + it('should identify tag references', () => { + expect(isRefATag(REFS_TAGS_PREFIX + 'v1.0.0')).toBe(true) + expect(isRefATag('v1.0.0')).toBe(false) + expect(isRefATag(undefined)).toBe(false) + }) + }) + + describe('isRefABranch', () => { + it('should identify branch references', () => { + expect(isRefABranch(REFS_BRANCH_PREFIX + 'main')).toBe(true) + expect(isRefABranch('main')).toBe(false) + expect(isRefABranch(undefined)).toBe(false) + }) + }) + + describe('isGitRev', () => { + it('should identify valid git commit hashes', () => { + expect(isGitRev('1234567')).toBe(true) + expect(isGitRev('1234567890abcdef1234567890abcdef12345678')).toBe(true) + expect(isGitRev('123456')).toBe(false) // Too short + expect(isGitRev('123456g')).toBe(false) // Invalid character + expect(isGitRev('')).toBe(false) + }) + }) + + describe('normalizeGitRef', () => { + it('should handle undefined input', () => { + expect(normalizeGitRef(undefined)).toBe('refs/heads/undefined') + }) + + it('should normalize git references correctly', () => { + const tag = REFS_TAGS_PREFIX + 'v1.0.0' + const branch = REFS_BRANCH_PREFIX + 'main' + const commit = '1234567890abcdef1234567890abcdef12345678' + + expect(normalizeGitRef(tag)).toBe(tag) + expect(normalizeGitRef(branch)).toBe(branch) + expect(normalizeGitRef(commit)).toBe(commit) + expect(normalizeGitRef('main')).toBe('refs/heads/main') + expect(normalizeGitRef('')).toBe('') + }) + }) +}) + +describe('getTrimmedSha', () => { + it('should trim SHA to 7 characters', () => { + const fullSha = '1234567890abcdef1234567890abcdef12345678' + expect(getTrimmedSha(fullSha)).toBe('1234567') + }) + + it('should handle short SHA', () => { + const shortSha = '1234567' + expect(getTrimmedSha(shortSha)).toBe('1234567') + }) +}) + +describe('GitCommitAction enum', () => { + it('should have correct enum values', () => { + expect(GitCommitAction.DELETE).toBe('DELETE') + expect(GitCommitAction.CREATE).toBe('CREATE') + expect(GitCommitAction.UPDATE).toBe('UPDATE') + expect(GitCommitAction.MOVE).toBe('MOVE') + }) +}) diff --git a/apps/gitness/src/utils/__tests__/path-utils.test.ts b/apps/gitness/src/utils/__tests__/path-utils.test.ts new file mode 100644 index 0000000000..7e4a902865 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/path-utils.test.ts @@ -0,0 +1,85 @@ +import { splitPathWithParents } from '../path-utils' + +describe('splitPathWithParents', () => { + it('should return empty array for empty path', () => { + expect(splitPathWithParents('', 'repo')).toEqual([]) + }) + + it('should split single level path correctly', () => { + const result = splitPathWithParents('file.txt', 'repo') + expect(result).toEqual([ + { + path: 'file.txt', + parentPath: 'repo/~/file.txt' + } + ]) + }) + + it('should split multi-level path correctly', () => { + const result = splitPathWithParents('folder/subfolder/file.txt', 'repo') + expect(result).toEqual([ + { + path: 'folder', + parentPath: 'repo/~/folder' + }, + { + path: 'subfolder', + parentPath: 'repo/~/folder/subfolder' + }, + { + path: 'file.txt', + parentPath: 'repo/~/folder/subfolder/file.txt' + } + ]) + }) + + it('should handle paths with leading slash', () => { + const result = splitPathWithParents('/folder/file.txt', 'repo') + expect(result).toEqual([ + { + path: '', + parentPath: 'repo/~/' + }, + { + path: 'folder', + parentPath: 'repo/~//folder' + }, + { + path: 'file.txt', + parentPath: 'repo/~//folder/file.txt' + } + ]) + }) + + it('should handle paths with trailing slash', () => { + const result = splitPathWithParents('folder/subfolder/', 'repo') + expect(result).toEqual([ + { + path: 'folder', + parentPath: 'repo/~/folder' + }, + { + path: 'subfolder', + parentPath: 'repo/~/folder/subfolder' + }, + { + path: '', + parentPath: 'repo/~/folder/subfolder/' + } + ]) + }) + + it('should handle special characters in paths', () => { + const result = splitPathWithParents('folder-name/file_name.txt', 'repo-name') + expect(result).toEqual([ + { + path: 'folder-name', + parentPath: 'repo-name/~/folder-name' + }, + { + path: 'file_name.txt', + parentPath: 'repo-name/~/folder-name/file_name.txt' + } + ]) + }) +}) diff --git a/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts b/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts new file mode 100644 index 0000000000..8779d83321 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts @@ -0,0 +1,195 @@ +import { EnumRuleState, type RepoRuleGetOkResponse } from '@harnessio/code-service-client' +import { BranchRuleId, MergeStrategy, PatternsButtonType, Rule } from '@harnessio/ui/views' + +import { getTotalRulesApplied, transformDataFromApi, transformFormOutput } from '../repo-branch-rules-utils' + +const mockApiResponse: RepoRuleGetOkResponse = { + identifier: 'test-rule', + description: 'Test rule description', + state: 'active' as EnumRuleState, + pattern: { + default: true, + include: ['main/*'], + exclude: ['main/excluded'] + }, + definition: { + bypass: { + user_ids: [1, 2], + repo_owners: true + }, + lifecycle: { + create_forbidden: true, + delete_forbidden: false, + update_forbidden: true + }, + pullreq: { + approvals: { + require_code_owners: true, + require_latest_commit: true, + require_no_change_request: false, + require_minimum_count: 2 + }, + comments: { + require_resolve_all: true + }, + merge: { + strategies_allowed: ['merge', 'rebase'] as MergeStrategy[], + delete_branch: true + }, + status_checks: { + require_identifiers: ['check1', 'check2'] + } + } + }, + users: { + user1: { display_name: 'User One' }, + user2: { display_name: 'User Two' } + } +} + +const mockFormOutput = { + identifier: 'test-rule', + description: 'Test rule description', + pattern: '', + patterns: [ + { pattern: 'main/*', option: PatternsButtonType.INCLUDE }, + { pattern: 'main/excluded', option: PatternsButtonType.EXCLUDE } + ], + state: true, + bypass: [ + { id: 1, display_name: 'User One' }, + { id: 2, display_name: 'User Two' } + ], + default: true, + repo_owners: true, + rules: [ + { id: BranchRuleId.REQUIRE_LATEST_COMMIT, checked: true }, + { id: BranchRuleId.REQUIRE_NO_CHANGE_REQUEST, checked: false }, + { id: BranchRuleId.COMMENTS, checked: true }, + { id: BranchRuleId.STATUS_CHECKS, checked: true, selectOptions: ['check1', 'check2'] }, + { id: BranchRuleId.MERGE, checked: true, submenu: ['merge', 'rebase'] }, + { id: BranchRuleId.DELETE_BRANCH, checked: true }, + { id: BranchRuleId.BLOCK_BRANCH_CREATION, checked: true }, + { id: BranchRuleId.BLOCK_BRANCH_DELETION, checked: false }, + { id: BranchRuleId.REQUIRE_PULL_REQUEST, checked: true }, + { id: BranchRuleId.REQUIRE_CODE_REVIEW, checked: true, input: '2' }, + { id: BranchRuleId.REQUIRE_CODE_OWNERS, checked: true } + ] as unknown as Rule[] +} + +describe('transformDataFromApi', () => { + it('should transform API response to form data correctly', () => { + const result = transformDataFromApi(mockApiResponse) + + expect(result.identifier).toBe('test-rule') + expect(result.description).toBe('Test rule description') + expect(result.state).toBe(true) + expect(result.default).toBe(true) + expect(result.repo_owners).toBe(true) + + // Check patterns + expect(result.patterns).toEqual([ + { pattern: 'main/*', option: PatternsButtonType.INCLUDE }, + { pattern: 'main/excluded', option: PatternsButtonType.EXCLUDE } + ]) + + // Check bypass users + expect(result.bypass).toEqual([]) + + // Check rules + const ruleMap = new Map(result.rules.map(rule => [rule.id, rule])) + + expect(ruleMap.get(BranchRuleId.REQUIRE_LATEST_COMMIT)?.checked).toBe(true) + expect(ruleMap.get(BranchRuleId.REQUIRE_CODE_REVIEW)?.checked).toBe(true) + expect(ruleMap.get(BranchRuleId.REQUIRE_CODE_REVIEW)?.input).toBe('2') + expect(ruleMap.get(BranchRuleId.STATUS_CHECKS)?.selectOptions).toEqual(['check1', 'check2']) + expect(ruleMap.get(BranchRuleId.MERGE)?.submenu).toEqual(['merge', 'rebase']) + }) + + it('should handle empty API response', () => { + const emptyResponse = { + identifier: '', + state: 'disabled' as EnumRuleState + } + + const result = transformDataFromApi(emptyResponse) + + expect(result.identifier).toBe('') + expect(result.description).toBe('') + expect(result.state).toBe(false) + expect(result.patterns).toEqual([]) + expect(result.bypass).toEqual([]) + }) +}) + +describe('transformFormOutput', () => { + it('should transform form data to API request format correctly', () => { + const result = transformFormOutput(mockFormOutput) + + expect(result?.identifier).toBe('test-rule') + expect(result?.description).toBe('Test rule description') + expect(result?.state).toBe('active') + expect(result?.pattern?.default).toBe(true) + + // Check patterns + expect(result?.pattern?.include).toEqual(['main/*']) + expect(result?.pattern?.exclude).toEqual(['main/excluded']) + + // Check bypass + expect(result?.definition?.bypass?.user_ids).toEqual([1, 2]) + expect(result?.definition?.bypass?.repo_owners).toBe(true) + + // Check lifecycle rules + expect(result?.definition?.lifecycle?.create_forbidden).toBe(true) + expect(result?.definition?.lifecycle?.delete_forbidden).toBe(false) + expect(result?.definition?.lifecycle?.update_forbidden).toBe(true) + + // Check pull request rules + expect(result?.definition?.pullreq?.approvals?.require_code_owners).toBe(true) + expect(result?.definition?.pullreq?.approvals?.require_minimum_count).toBe(2) + expect(result?.definition?.pullreq?.merge?.strategies_allowed).toEqual(['merge', 'rebase']) + expect(result?.definition?.pullreq?.status_checks?.require_identifiers).toEqual(['check1', 'check2']) + }) +}) + +describe('getTotalRulesApplied', () => { + it('should count total number of enabled rules correctly', () => { + const total = getTotalRulesApplied(mockApiResponse) + expect(total).toBe(9) // Count of all rules with checked=true in mockApiResponse + }) + + it('should return 0 for no enabled rules', () => { + const emptyRules = { + ...mockApiResponse, + definition: { + ...mockApiResponse.definition, + lifecycle: { + create_forbidden: false, + delete_forbidden: false, + update_forbidden: false + }, + pullreq: { + approvals: { + require_code_owners: false, + require_latest_commit: false, + require_no_change_request: false, + require_minimum_count: 0 + }, + comments: { + require_resolve_all: false + }, + merge: { + strategies_allowed: [], + delete_branch: false + }, + status_checks: { + require_identifiers: [] + } + } + } + } + + const total = getTotalRulesApplied(emptyRules) + expect(total).toBe(0) + }) +}) diff --git a/apps/gitness/src/utils/common-utils.ts b/apps/gitness/src/utils/common-utils.ts index ded490a68c..9d69d16e5e 100644 --- a/apps/gitness/src/utils/common-utils.ts +++ b/apps/gitness/src/utils/common-utils.ts @@ -22,19 +22,6 @@ export const getLogsText = (logs: LivelogLine[]) => { return output } -export const getInitials = (name: string, length = 2) => { - // Split the name into an array of words, ignoring empty strings - const words = name.split(' ').filter(Boolean) - - // Get the initials from the words - const initials = words - .map(word => word[0].toUpperCase()) // Get the first letter of each word - .join('') - - // If length is provided, truncate the initials to the desired length - return length ? initials.slice(0, length) : initials -} - export const sortFilesByType = (entries: RepoFile[]): RepoFile[] => { return entries.sort((a, b) => { if (a.type === SummaryItemType.Folder && b.type === SummaryItemType.File) { diff --git a/apps/gitness/vitest.config.ts b/apps/gitness/vitest.config.ts index b2a8c013b7..174a35a561 100644 --- a/apps/gitness/vitest.config.ts +++ b/apps/gitness/vitest.config.ts @@ -4,6 +4,8 @@ import viteConfig from './vite.config' export default mergeConfig(viteConfig, { test: { + environment: 'jsdom', + setupFiles: ['./config/vitest-setup.ts'], include: ['**/*.test.{ts,tsx}'], globals: true, coverage: { diff --git a/packages/ui/config/vitest-setup.ts b/packages/ui/config/vitest-setup.ts index 33fb0d8476..7cd6d9af2b 100644 --- a/packages/ui/config/vitest-setup.ts +++ b/packages/ui/config/vitest-setup.ts @@ -5,3 +5,5 @@ import '@testing-library/jest-dom' afterEach(() => { cleanup() }) + +process.env.TZ = 'UTC' diff --git a/packages/ui/src/utils/__tests__/TimeUtils.test.ts b/packages/ui/src/utils/__tests__/TimeUtils.test.ts new file mode 100644 index 0000000000..b8c6f13bdf --- /dev/null +++ b/packages/ui/src/utils/__tests__/TimeUtils.test.ts @@ -0,0 +1,69 @@ +import { formatDuration, formatTimestamp, getFormattedDuration } from '../TimeUtils' + +describe('getFormattedDuration', () => { + it('should return "0s" if startTs is greater than or equal to endTs', () => { + expect(getFormattedDuration(1000, 500)).toBe('0s') + expect(getFormattedDuration(1000, 1000)).toBe('0s') + }) + + it('should return formatted duration in "1h 2m 3s" format', () => { + expect(getFormattedDuration(0, 3723000)).toBe('1h 2m 3s') + expect(getFormattedDuration(0, 7200000)).toBe('2h') + expect(getFormattedDuration(0, 60000)).toBe('1m') + expect(getFormattedDuration(0, 1000)).toBe('1s') + }) + + it('should handle durations with only seconds', () => { + expect(getFormattedDuration(0, 5000)).toBe('5s') + }) + + it('should handle durations with hours, minutes, and seconds', () => { + expect(getFormattedDuration(0, 3661000)).toBe('1h 1m 1s') + }) +}) + +describe('formatDuration', () => { + it('should return "0s" if duration is 0', () => { + expect(formatDuration(0)).toBe('0s') + }) + + it('should return formatted duration in "1h 2m 3s" format for milliseconds', () => { + expect(formatDuration(3723000, 'ms')).toBe('1h 2m 3s') + }) + + it('should return formatted duration in "1h 2m 3s" format for nanoseconds', () => { + expect(formatDuration(3723000000000, 'ns')).toBe('1h 2m 3s') + }) + + it('should handle durations with only milliseconds', () => { + expect(formatDuration(500, 'ms')).toBe('500ms') + }) + + it('should handle durations with only nanoseconds', () => { + expect(formatDuration(500000000, 'ns')).toBe('500ms') + }) + + it('should handle durations with hours, minutes, and seconds', () => { + expect(formatDuration(3661000, 'ms')).toBe('1h 1m 1s') + }) +}) + +describe('formatTimestamp', () => { + const fixedDate = new Date('2023-01-01T12:34:56.789Z') + + beforeAll(() => { + vi.setSystemTime(fixedDate) + }) + + it('should format epoch timestamp into "HH:mm:ss.SSS" format', () => { + const timestamp = fixedDate.getTime() + expect(formatTimestamp(timestamp)).toBe('12:34:56.789') + }) + + it('should handle different times of the day', () => { + const morningTimestamp = new Date('2023-01-01T08:00:00.000Z').getTime() + const eveningTimestamp = new Date('2023-01-01T20:00:00.000Z').getTime() + expect(formatTimestamp(morningTimestamp)).toBe('08:00:00.000') + expect(formatTimestamp(eveningTimestamp)).toBe('20:00:00.000') + }) +}) diff --git a/packages/ui/src/utils/__tests__/stringUtils.test.ts b/packages/ui/src/utils/__tests__/stringUtils.test.ts new file mode 100644 index 0000000000..4f71852f33 --- /dev/null +++ b/packages/ui/src/utils/__tests__/stringUtils.test.ts @@ -0,0 +1,34 @@ +import { getInitials } from '../stringUtils' + +describe('getInitials', () => { + it('should return the initials of a single word', () => { + expect(getInitials('John')).toBe('J') + }) + + it('should return the initials of multiple words', () => { + expect(getInitials('John Doe')).toBe('JD') + }) + + it('should return the initials truncated to the specified length', () => { + expect(getInitials('John Doe', 1)).toBe('J') + expect(getInitials('John Doe', 2)).toBe('JD') + expect(getInitials('John Doe', 3)).toBe('JD') + }) + + it('should ignore extra spaces between words', () => { + expect(getInitials(' John Doe ')).toBe('JD') + }) + + it('should handle empty strings', () => { + expect(getInitials('')).toBe('') + }) + + it('should handle names with more than two words', () => { + expect(getInitials('John Michael Doe')).toBe('JM') + expect(getInitials('John Michael Doe', 2)).toBe('JM') + }) + + it('should handle names with special characters', () => { + expect(getInitials('John-Michael Doe')).toBe('JD') + }) +}) diff --git a/packages/ui/src/utils/__tests__/utils.skip_test.ts b/packages/ui/src/utils/__tests__/utils.skip_test.ts new file mode 100644 index 0000000000..141c69b823 --- /dev/null +++ b/packages/ui/src/utils/__tests__/utils.skip_test.ts @@ -0,0 +1,25 @@ +import { formatDate } from '../utils' + +describe('formatDate', () => { + it('should format a Unix timestamp to a localized date string', () => { + const timestamp = 1642774800000 + const result = formatDate(timestamp) + expect(result).toBe('Jan 21, 2022') + }) + + it('should format an ISO date string to a localized date string with full style', () => { + const timestamp = '2022-01-21' + const result = formatDate(timestamp, 'full') + expect(result).toBe('Friday, January 21, 2022') + }) + + it('should return an empty string if timestamp is falsy', () => { + const result = formatDate('') + expect(result).toBe('') + }) + + it('should handle invalid date gracefully and return an empty string', () => { + const result = formatDate('invalid-date') + expect(result).toBe('') + }) +}) diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 9c56149efa..f0d8d06243 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1 +1,2 @@ export * from './utils' +export * from './stringUtils' diff --git a/packages/ui/src/utils/utils.ts b/packages/ui/src/utils/utils.ts index ae02fbea5e..517e560e06 100644 --- a/packages/ui/src/utils/utils.ts +++ b/packages/ui/src/utils/utils.ts @@ -3,18 +3,6 @@ import { createElement, ReactNode } from 'react' import { TimeAgoHoverCard } from '@views/repo/components/time-ago-hover-card' import { formatDistance, formatDistanceToNow } from 'date-fns' -export const getInitials = (name: string, length = 2) => { - // Split the name into an array of words, ignoring empty strings - const words = name.split(' ').filter(Boolean) - - // Get the initials from the words - const initials = words - .map(word => word[0].toUpperCase()) // Get the first letter of each word - .join('') - - // If length is provided, truncate the initials to the desired length - return length ? initials.slice(0, length) : initials -} export const INITIAL_ZOOM_LEVEL = 1 export const ZOOM_INC_DEC_LEVEL = 0.1 @@ -110,19 +98,6 @@ export const timeAgo = (timestamp?: number | null, cutoffDays: number = 3): Reac } } -//generate random password -export function generateAlphaNumericHash(length: number) { - let result = '' - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - const charactersLength = characters.length - - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)) - } - - return result -} - /** * Format a number with current locale. * @param num number @@ -131,6 +106,7 @@ export function generateAlphaNumericHash(length: number) { export function formatNumber(num: number | bigint): string { return num ? new Intl.NumberFormat(LOCALE).format(num) : '' } + export interface Violation { violation: string } diff --git a/packages/ui/src/views/repo/components/commits-list.tsx b/packages/ui/src/views/repo/components/commits-list.tsx index 1fbab40334..93bc185f12 100644 --- a/packages/ui/src/views/repo/components/commits-list.tsx +++ b/packages/ui/src/views/repo/components/commits-list.tsx @@ -2,8 +2,9 @@ import { FC, useMemo } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Avatar, Button, CommitCopyActions, Icon, NodeGroup, StackedList } from '@/components' -import { formatDate, getInitials } from '@/utils/utils' +import { formatDate } from '@/utils/utils' import { TypesCommit } from '@/views' +import { getInitials } from '@utils/stringUtils' type CommitsGroupedByDate = Record diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx index 78484c294e..b7ab770dc5 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx @@ -14,7 +14,8 @@ import { import { DiffFile, DiffModeEnum, DiffView, DiffViewProps, SplitSide } from '@git-diff-view/react' import { useCustomEventListener } from '@hooks/use-event-listener' import { useMemoryCleanup } from '@hooks/use-memory-cleanup' -import { getInitials, timeAgo } from '@utils/utils' +import { getInitials } from '@utils/stringUtils' +import { timeAgo } from '@utils/utils' import { DiffBlock } from 'diff2html/lib/types' import { debounce, get } from 'lodash-es' import { OverlayScrollbars } from 'overlayscrollbars' diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx index 75c490d1bb..afd29e8af9 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx @@ -3,7 +3,7 @@ import { Children, FC, memo, ReactElement, ReactNode, useEffect, useState } from import { Avatar, Button, Card, Icon, Input, MoreActionsTooltip, NodeGroup } from '@/components' import { HandleUploadType, PullRequestCommentBox } from '@/views' import { cn } from '@utils/cn' -import { getInitials } from '@utils/utils' +import { getInitials } from '@utils/stringUtils' interface ItemHeaderProps { avatar?: ReactNode diff --git a/packages/ui/src/views/user-management/components/users-list.tsx b/packages/ui/src/views/user-management/components/users-list.tsx index c90d9ecad9..ce5e8e86d0 100644 --- a/packages/ui/src/views/user-management/components/users-list.tsx +++ b/packages/ui/src/views/user-management/components/users-list.tsx @@ -1,5 +1,5 @@ import { Avatar, Badge, MoreActionsTooltip, Table, Text } from '@/components' -import { getInitials } from '@/utils/utils' +import { getInitials } from '@utils/stringUtils' import { DialogLabels, UsersProps } from '../types'