Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -21,6 +26,9 @@ jobs:
- name: Lint
run: bun run lint

- name: Test
run: bun run test:run

- name: Build
run: bun run build

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 24
Binary file modified bun.lockb
Binary file not shown.
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,15 +28,21 @@
},
"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",
"eslint": "^9.39.2",
"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"
}
}
25 changes: 25 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {describe, it, expect, beforeEach} from 'vitest'
import {api} from './api'

type RequestHandlers = Array<{
fulfilled: (config: {headers: Record<string, string>}) => {headers: Record<string, string>} | Promise<{headers: Record<string, string>}>
}>

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()
})
})
34 changes: 34 additions & 0 deletions src/components/ProtectedRoute/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthStateContext.Provider value={authState}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/secret" element={<div>secret payload</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
</Routes>
</MemoryRouter>
</AuthStateContext.Provider>
)

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()
})
})
55 changes: 55 additions & 0 deletions src/context/auth/axios.test.tsx
Original file line number Diff line number Diff line change
@@ -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}) => (
<AuthProvider>{children}</AuthProvider>
)

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()
})
})
37 changes: 31 additions & 6 deletions src/pages/repository/RepositoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
<Spin />
</div>
);

enum RepoPageTabs {
RESULTS = 'results',
TRENDS = 'trends',
Expand Down Expand Up @@ -166,17 +179,29 @@ export const RepositoryPage: React.FC = () => {
{
key: RepoPageTabs.RESULTS,
label: <Space><BarChartOutlined />Results</Space>,
children: <RepoResultsTab repository={repoQuery.data} organization={orgQuery.data}/>
children: (
<React.Suspense fallback={<TabFallback />}>
<RepoResultsTab repository={repoQuery.data} organization={orgQuery.data}/>
</React.Suspense>
)
},
{
key: RepoPageTabs.TRENDS,
label: <Space><LineChartOutlined />Trends</Space>,
children: <RepoTrendsTab repository={repoQuery.data} organization={orgQuery.data}/>
children: (
<React.Suspense fallback={<TabFallback />}>
<RepoTrendsTab repository={repoQuery.data} organization={orgQuery.data}/>
</React.Suspense>
)
},
{
key: RepoPageTabs.CONFIGS,
label: <Space><SettingOutlined />Settings</Space>,
children: <RepoConfigTab repository={repoQuery.data} organization={orgQuery.data}/>
children: (
<React.Suspense fallback={<TabFallback />}>
<RepoConfigTab repository={repoQuery.data} organization={orgQuery.data}/>
</React.Suspense>
)
}
]}
/>
Expand Down
45 changes: 29 additions & 16 deletions src/routes.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
<Spin size="large" />
</div>
);

export const AppRoutes = () => {
return (
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage/>}/>
<Route path="/login/success" element={<LoginSuccessPage/>}/>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage/>}/>
<Route path="/login/success" element={<LoginSuccessPage/>}/>

{/* Authenticated routes with AppLayout */}
<Route element={<ProtectedRoute/>}>
<Route element={<AppLayout />}>
<Route path="/:provider/orgs" element={<OrganizationsPage/>}/>
<Route path="/:provider/:org" element={<RepositoriesPage/>}/>
<Route path="/:provider/:org/:repo" element={<RepositoryPage/>}/>
<Route path="/:provider/:org/:repo/run/:run" element={<RunResultPage/>}/>
<Route path="/results" element={<RunResultPage/>}/>
{/* Authenticated routes with AppLayout */}
<Route element={<ProtectedRoute/>}>
<Route element={<AppLayout />}>
<Route path="/:provider/orgs" element={<OrganizationsPage/>}/>
<Route path="/:provider/:org" element={<RepositoriesPage/>}/>
<Route path="/:provider/:org/:repo" element={<RepositoryPage/>}/>
<Route path="/:provider/:org/:repo/run/:run" element={<RunResultPage/>}/>
<Route path="/results" element={<RunResultPage/>}/>
</Route>
</Route>
</Route>

<Route path="*" element={<div>Not Found</div>}/>
</Routes>
<Route path="*" element={<div>Not Found</div>}/>
</Routes>
</Suspense>
)
}
25 changes: 25 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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()
})
12 changes: 12 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
Loading