- {runs.map((run) => (
+ {sortedRuns.map((run) => (
{
+ let apiService: ApiService;
+
+ beforeEach(() => {
+ apiService = new ApiService();
+ vi.clearAllMocks();
+ });
+
+ describe('getWorkflowRuns', () => {
+ it('should fetch workflow runs successfully', async () => {
+ const mockRuns = [
+ {
+ id: 1,
+ name: 'Test Workflow',
+ display_title: 'Test Run',
+ status: 'completed',
+ conclusion: 'success',
+ created_at: '2024-01-01T10:00:00Z',
+ repository: {
+ id: 1,
+ name: 'test-repo',
+ full_name: 'test/test-repo',
+ owner: {
+ login: 'test-owner',
+ avatar_url: 'https://github.com/test-owner.png'
+ }
+ },
+ actor: {
+ login: 'test-actor',
+ avatar_url: 'https://github.com/test-actor.png'
+ }
+ }
+ ];
+
+ mockedAxios.get.mockResolvedValueOnce({ data: mockRuns });
+
+ const result = await apiService.getWorkflowRuns();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith('/api/workflow-runs');
+ expect(result).toEqual(mockRuns);
+ });
+
+ it('should handle API errors', async () => {
+ const errorMessage = 'Network Error';
+ mockedAxios.get.mockRejectedValueOnce(new Error(errorMessage));
+
+ await expect(apiService.getWorkflowRuns()).rejects.toThrow(errorMessage);
+ });
+ });
+
+ describe('getRepositoryWorkflowRuns', () => {
+ it('should fetch repository-specific workflow runs', async () => {
+ const mockRuns = [
+ {
+ id: 1,
+ name: 'Test Workflow',
+ display_title: 'Test Run',
+ status: 'completed',
+ conclusion: 'success',
+ created_at: '2024-01-01T10:00:00Z'
+ }
+ ];
+
+ mockedAxios.get.mockResolvedValueOnce({ data: mockRuns });
+
+ const result = await apiService.getRepositoryWorkflowRuns('test-owner', 'test-repo');
+
+ expect(mockedAxios.get).toHaveBeenCalledWith('/api/repository/test-owner/test-repo/workflow-runs', {
+ params: { limit: 10 }
+ });
+ expect(result).toEqual(mockRuns);
+ });
+
+ it('should handle repository not found', async () => {
+ mockedAxios.get.mockRejectedValueOnce({
+ response: { status: 404 }
+ });
+
+ await expect(apiService.getRepositoryWorkflowRuns('invalid', 'repo')).rejects.toMatchObject({
+ response: { status: 404 }
+ });
+ });
+ });
+
+ describe('getConfig', () => {
+ it('should fetch configuration successfully', async () => {
+ const mockConfig = {
+ repositories: [
+ {
+ owner: 'test-owner',
+ name: 'test-repo',
+ full_name: 'test-owner/test-repo'
+ }
+ ]
+ };
+
+ mockedAxios.get.mockResolvedValueOnce({ data: mockConfig });
+
+ const result = await apiService.getConfig();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith('/api/config');
+ expect(result).toEqual(mockConfig);
+ });
+ });
+
+ describe('getRateLimit', () => {
+ it('should fetch rate limit information', async () => {
+ const mockRateLimit = {
+ limit: 5000,
+ remaining: 4999,
+ resetTime: 1640995200000,
+ used: 1,
+ resetTimeFormatted: '2022-01-01T00:00:00.000Z',
+ timeUntilReset: 3600000
+ };
+
+ mockedAxios.get.mockResolvedValueOnce({ data: mockRateLimit });
+
+ const result = await apiService.getRateLimitInfo();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith('/api/rate-limit');
+ expect(result).toEqual(mockRateLimit);
+ });
+ });
+
+ describe('getCacheStats', () => {
+ it('should fetch cache statistics', async () => {
+ const mockStats = {
+ size: 10,
+ entries: ['key1', 'key2', 'key3']
+ };
+
+ mockedAxios.get.mockResolvedValueOnce({ data: mockStats });
+
+ const result = await apiService.getCacheStats();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith('/api/cache/stats');
+ expect(result).toEqual(mockStats);
+ });
+ });
+
+ describe('clearCache', () => {
+ it('should clear cache successfully', async () => {
+ const mockResponse = { message: 'Cache cleared successfully' };
+
+ mockedAxios.delete.mockResolvedValueOnce({ data: mockResponse });
+
+ const result = await apiService.clearCache();
+
+ expect(mockedAxios.delete).toHaveBeenCalledWith('/api/cache');
+ expect(result).toEqual(mockResponse);
+ });
+ });
+
+ describe('error handling', () => {
+ it('should handle network errors', async () => {
+ mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'));
+
+ await expect(apiService.getWorkflowRuns()).rejects.toThrow('Network Error');
+ });
+
+ it('should handle HTTP errors', async () => {
+ mockedAxios.get.mockRejectedValueOnce({
+ response: {
+ status: 500,
+ data: { error: 'Internal Server Error' }
+ }
+ });
+
+ await expect(apiService.getWorkflowRuns()).rejects.toMatchObject({
+ response: {
+ status: 500,
+ data: { error: 'Internal Server Error' }
+ }
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/test-setup.ts b/src/test-setup.ts
new file mode 100644
index 0000000..570e366
--- /dev/null
+++ b/src/test-setup.ts
@@ -0,0 +1,42 @@
+import { expect, afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+import * as matchers from '@testing-library/jest-dom/matchers';
+
+// Extend Vitest's expect with testing-library matchers
+expect.extend(matchers);
+
+// Clean up after each test
+afterEach(() => {
+ cleanup();
+});
+
+// Mock window.matchMedia for components that use it
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => {},
+ }),
+});
+
+// Mock IntersectionObserver
+global.IntersectionObserver = class IntersectionObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+} as any;
+
+// Mock ResizeObserver
+global.ResizeObserver = class ResizeObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+} as any;
\ No newline at end of file
diff --git a/src/utils/sorting.test.ts b/src/utils/sorting.test.ts
new file mode 100644
index 0000000..1768970
--- /dev/null
+++ b/src/utils/sorting.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect } from 'vitest';
+import { WorkflowRun } from '../types/github';
+
+// Helper function to create a mock workflow run
+const createMockWorkflowRun = (id: number, created_at: string): WorkflowRun => ({
+ id,
+ name: `Test Workflow ${id}`,
+ display_title: `Test ${id}`,
+ status: 'completed',
+ conclusion: 'success',
+ workflow_id: 1,
+ head_branch: 'main',
+ head_sha: 'abc123',
+ run_number: id,
+ event: 'push',
+ created_at,
+ updated_at: created_at,
+ html_url: `https://github.com/test/repo/actions/runs/${id}`,
+ repository: {
+ id: 1,
+ name: 'test-repo',
+ full_name: 'test/test-repo',
+ owner: {
+ login: 'test-owner',
+ avatar_url: 'https://github.com/test-owner.png'
+ }
+ },
+ head_commit: {
+ id: 'abc123',
+ message: 'Test commit',
+ author: {
+ name: 'Test Author',
+ email: 'test@example.com'
+ }
+ },
+ actor: {
+ login: 'test-actor',
+ avatar_url: 'https://github.com/test-actor.png'
+ }
+});
+
+// Sorting function (extracted from components)
+const sortWorkflowRunsByDate = (runs: WorkflowRun[]): WorkflowRun[] => {
+ return [...runs].sort((a, b) => {
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
+ });
+};
+
+describe('Workflow Run Sorting', () => {
+ it('should sort workflow runs by created_at date (newest first)', () => {
+ const runs = [
+ createMockWorkflowRun(1, '2024-01-01T10:00:00Z'),
+ createMockWorkflowRun(2, '2024-01-03T10:00:00Z'),
+ createMockWorkflowRun(3, '2024-01-02T10:00:00Z')
+ ];
+
+ const sortedRuns = sortWorkflowRunsByDate(runs);
+
+ expect(sortedRuns).toHaveLength(3);
+ expect(sortedRuns[0].id).toBe(2); // 2024-01-03 (newest)
+ expect(sortedRuns[1].id).toBe(3); // 2024-01-02
+ expect(sortedRuns[2].id).toBe(1); // 2024-01-01 (oldest)
+ });
+
+ it('should handle empty array', () => {
+ const runs: WorkflowRun[] = [];
+ const sortedRuns = sortWorkflowRunsByDate(runs);
+ expect(sortedRuns).toHaveLength(0);
+ });
+
+ it('should handle single item', () => {
+ const runs = [createMockWorkflowRun(1, '2024-01-01T10:00:00Z')];
+ const sortedRuns = sortWorkflowRunsByDate(runs);
+ expect(sortedRuns).toHaveLength(1);
+ expect(sortedRuns[0].id).toBe(1);
+ });
+
+ it('should handle identical timestamps', () => {
+ const timestamp = '2024-01-01T10:00:00Z';
+ const runs = [
+ createMockWorkflowRun(1, timestamp),
+ createMockWorkflowRun(2, timestamp),
+ createMockWorkflowRun(3, timestamp)
+ ];
+
+ const sortedRuns = sortWorkflowRunsByDate(runs);
+ expect(sortedRuns).toHaveLength(3);
+ // Order should be stable for identical timestamps
+ });
+
+ it('should not mutate the original array', () => {
+ const runs = [
+ createMockWorkflowRun(1, '2024-01-01T10:00:00Z'),
+ createMockWorkflowRun(2, '2024-01-02T10:00:00Z')
+ ];
+ const originalOrder = runs.map(r => r.id);
+
+ sortWorkflowRunsByDate(runs);
+
+ expect(runs.map(r => r.id)).toEqual(originalOrder);
+ });
+
+ it('should handle various date formats', () => {
+ const runs = [
+ createMockWorkflowRun(1, '2024-01-01T10:00:00.000Z'),
+ createMockWorkflowRun(2, '2024-01-02T10:00:00Z'),
+ createMockWorkflowRun(3, '2024-01-01T15:00:00.500Z')
+ ];
+
+ const sortedRuns = sortWorkflowRunsByDate(runs);
+
+ expect(sortedRuns[0].id).toBe(2); // 2024-01-02
+ expect(sortedRuns[1].id).toBe(3); // 2024-01-01 15:00
+ expect(sortedRuns[2].id).toBe(1); // 2024-01-01 10:00
+ });
+});
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..e4d4e18
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['src/test-setup.ts'],
+ globals: true,
+ css: true,
+ },
+ resolve: {
+ alias: {
+ '@': '/src',
+ },
+ },
+});
\ No newline at end of file