diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ae31c75..dd37084 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,6 +10,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -21,6 +26,9 @@ jobs: - name: Lint run: bun run lint + - name: Test + run: bun run test:run + - name: Build run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a561551..755c324 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,11 @@ jobs: with: fetch-depth: 0 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..695dfec --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24 diff --git a/bun.lockb b/bun.lockb index ea79766..166595a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index fc599c5..2f58449 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite --port 3001", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@ant-design/icons": "^6.0.0", @@ -26,6 +28,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react-swc": "^4.2.2", @@ -33,8 +38,11 @@ "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^17.0.0", + "happy-dom": "^20.9.0", + "jsdom": "^29.1.1", "typescript": "~6.0.0", "typescript-eslint": "^8.52.0", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^3" } } diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..bc28927 --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,25 @@ +import {describe, it, expect, beforeEach} from 'vitest' +import {api} from './api' + +type RequestHandlers = Array<{ + fulfilled: (config: {headers: Record}) => {headers: Record} | Promise<{headers: Record}> +}> + +const requestHandlers = (api.interceptors.request as unknown as {handlers: RequestHandlers}).handlers + +describe('api axios instance', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('attaches Bearer token from localStorage to outgoing requests', async () => { + localStorage.setItem('authToken', 'test-jwt-token') + const config = await requestHandlers[0].fulfilled({headers: {}}) + expect(config.headers['Authorization']).toBe('Bearer test-jwt-token') + }) + + it('omits the Authorization header when no token is present', async () => { + const config = await requestHandlers[0].fulfilled({headers: {}}) + expect(config.headers['Authorization']).toBeUndefined() + }) +}) diff --git a/src/components/ProtectedRoute/ProtectedRoute.test.tsx b/src/components/ProtectedRoute/ProtectedRoute.test.tsx new file mode 100644 index 0000000..5990e5c --- /dev/null +++ b/src/components/ProtectedRoute/ProtectedRoute.test.tsx @@ -0,0 +1,34 @@ +import {describe, it, expect} from 'vitest' +import {render, screen} from '@testing-library/react' +import {MemoryRouter, Route, Routes} from 'react-router' +import {AuthStateContext} from '../../context/auth/context' +import type {AuthState} from '../../context/auth/types' +import {ProtectedRoute} from './ProtectedRoute' + +const renderWithAuth = (authState: AuthState, initialPath: string) => + render( + + + + }> + secret payload} /> + + login page} /> + + + + ) + +describe('ProtectedRoute', () => { + it('renders the child route when the user is authenticated', () => { + renderWithAuth({isAuthenticated: true, token: 'tkn'}, '/secret') + expect(screen.getByText('secret payload')).toBeInTheDocument() + expect(screen.queryByText('login page')).not.toBeInTheDocument() + }) + + it('redirects unauthenticated users to /login', () => { + renderWithAuth({isAuthenticated: false, token: undefined}, '/secret') + expect(screen.getByText('login page')).toBeInTheDocument() + expect(screen.queryByText('secret payload')).not.toBeInTheDocument() + }) +}) diff --git a/src/context/auth/axios.test.tsx b/src/context/auth/axios.test.tsx new file mode 100644 index 0000000..8827f59 --- /dev/null +++ b/src/context/auth/axios.test.tsx @@ -0,0 +1,55 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' +import {renderHook} from '@testing-library/react' +import type {ReactNode} from 'react' +import useAxios from './axios' +import {AuthProvider} from './provider' + +describe('useAxios 401 handler', () => { + beforeEach(() => { + localStorage.clear() + }) + + const wrapper = ({children}: {children: ReactNode}) => ( + {children} + ) + + it('clears authState and redirects to /login on a 401 response', async () => { + localStorage.setItem('authState', JSON.stringify({isAuthenticated: true, token: 'tkn'})) + const assign = vi.fn() + vi.stubGlobal('location', {assign}) + + const {result} = renderHook(() => useAxios(), {wrapper}) + // `handlers` is an axios runtime internal not exposed in the public types. + const handlers = (result.current.interceptors.response as unknown as { + handlers: Array<{rejected?: (err: unknown) => unknown}> + }).handlers + const rejected = handlers[0].rejected! + + await expect(rejected({response: {status: 401}})).rejects.toBeDefined() + + expect(localStorage.getItem('authState')).toBeNull() + expect(assign).toHaveBeenCalledWith('/login') + + vi.unstubAllGlobals() + }) + + it('passes through non-401 errors without touching auth state', async () => { + localStorage.setItem('authState', JSON.stringify({isAuthenticated: true, token: 'tkn'})) + const assign = vi.fn() + vi.stubGlobal('location', {assign}) + + const {result} = renderHook(() => useAxios(), {wrapper}) + // `handlers` is an axios runtime internal not exposed in the public types. + const handlers = (result.current.interceptors.response as unknown as { + handlers: Array<{rejected?: (err: unknown) => unknown}> + }).handlers + const rejected = handlers[0].rejected! + + await expect(rejected({response: {status: 500}})).rejects.toBeDefined() + + expect(localStorage.getItem('authState')).not.toBeNull() + expect(assign).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + }) +}) diff --git a/src/pages/repository/RepositoryPage.tsx b/src/pages/repository/RepositoryPage.tsx index 95b015e..0442a90 100644 --- a/src/pages/repository/RepositoryPage.tsx +++ b/src/pages/repository/RepositoryPage.tsx @@ -7,11 +7,24 @@ import {BarChartOutlined, GithubOutlined, HomeOutlined, LineChartOutlined, Reloa import {useQuery} from "@tanstack/react-query"; import {isOk} from "../../utils/axios.ts"; import useAxios from "../../context/auth/axios.ts"; -import {RepoConfigTab} from "./tabs/RepoConfigTab.tsx"; -import {RepoResultsTab} from "./tabs/RepoResultsTab.tsx"; -import {RepoTrendsTab} from "./tabs/RepoTrendsTab.tsx"; import {colors} from "../../theme/theme.ts"; +const RepoResultsTab = React.lazy(() => + import("./tabs/RepoResultsTab.tsx").then(m => ({default: m.RepoResultsTab})) +); +const RepoTrendsTab = React.lazy(() => + import("./tabs/RepoTrendsTab.tsx").then(m => ({default: m.RepoTrendsTab})) +); +const RepoConfigTab = React.lazy(() => + import("./tabs/RepoConfigTab.tsx").then(m => ({default: m.RepoConfigTab})) +); + +const TabFallback = () => ( +
+ +
+); + enum RepoPageTabs { RESULTS = 'results', TRENDS = 'trends', @@ -166,17 +179,29 @@ export const RepositoryPage: React.FC = () => { { key: RepoPageTabs.RESULTS, label: Results, - children: + children: ( + }> + + + ) }, { key: RepoPageTabs.TRENDS, label: Trends, - children: + children: ( + }> + + + ) }, { key: RepoPageTabs.CONFIGS, label: Settings, - children: + children: ( + }> + + + ) } ]} /> diff --git a/src/routes.tsx b/src/routes.tsx index 4f7804c..6a2a548 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,33 +1,46 @@ +import {lazy, Suspense} from "react"; import {Route, Routes} from "react-router"; +import {Spin} from "antd"; import {LoginPage} from "./pages/login/Login.tsx"; import {LoginSuccessPage} from "./pages/login/Success.tsx"; import {OrganizationsPage} from "./pages/organizations/OrganizationsPage.tsx"; -import RunResultPage from "./pages/run_result/RunResult.tsx"; import {RepositoriesPage} from "./pages/repositories/RepositoriesPage.tsx"; import {RepositoryPage} from "./pages/repository/RepositoryPage.tsx"; import {AppLayout} from "./components/AppLayout/AppLayout.tsx"; import {ProtectedRoute} from "./components/ProtectedRoute/ProtectedRoute.tsx"; import {RootRedirect} from "./components/RootRedirect/RootRedirect.tsx"; +// RunResultPage pulls in react-syntax-highlighter; defer it until a user +// actually navigates to a run. +const RunResultPage = lazy(() => import("./pages/run_result/RunResult.tsx")); + +const RouteFallback = () => ( +
+ +
+); + export const AppRoutes = () => { return ( - - } /> - }/> - }/> + }> + + } /> + }/> + }/> - {/* Authenticated routes with AppLayout */} - }> - }> - }/> - }/> - }/> - }/> - }/> + {/* Authenticated routes with AppLayout */} + }> + }> + }/> + }/> + }/> + }/> + }/> + - - Not Found}/> - + Not Found}/> + + ) } diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..2bf819e --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,25 @@ +import '@testing-library/jest-dom/vitest' +import {afterEach} from 'vitest' +import {cleanup} from '@testing-library/react' + +if (typeof globalThis.localStorage === 'undefined') { + class InMemoryStorage { + private store = new Map() + get length() { return this.store.size } + key(i: number) { return Array.from(this.store.keys())[i] ?? null } + getItem(k: string) { return this.store.get(k) ?? null } + setItem(k: string, v: string) { this.store.set(k, String(v)) } + removeItem(k: string) { this.store.delete(k) } + clear() { this.store.clear() } + } + const storage = new InMemoryStorage() + Object.defineProperty(globalThis, 'localStorage', {value: storage, writable: true, configurable: true}) + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', {value: storage, writable: true, configurable: true}) + } +} + +afterEach(() => { + cleanup() + localStorage.clear() +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..80d6072 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from 'vitest/config' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'happy-dom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + css: false, + }, +})