test(pegasus): finish frontend gate coverage

This commit is contained in:
codex 2026-04-20 21:59:42 -03:00
parent 2cf7fcba50
commit 7d11941895
19 changed files with 4237 additions and 740 deletions

1
Jenkinsfile vendored
View File

@ -48,6 +48,7 @@ spec:
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '200', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '120'))
} }
triggers { triggers {

26
frontend/jest.config.cjs Normal file
View File

@ -0,0 +1,26 @@
/** @type {import('jest').Config} */
module.exports = {
rootDir: '.',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
testMatch: ['<rootDir>/src/**/*.test.ts', '<rootDir>/src/**/*.test.tsx'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.jest.json',
diagnostics: false,
},
],
},
moduleNameMapper: {
'\\.(css|less|sass|scss)$': '<rootDir>/src/test/styleMock.ts',
},
clearMocks: true,
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.test.{ts,tsx}',
'!src/test/**',
],
}

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5173", "preview": "vite preview --port 5173",
"test": "vitest run", "test": "jest --runInBand",
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile=../build/junit-frontend.xml --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=../build/frontend-coverage" "test:ci": "mkdir -p ../build && JEST_JUNIT_OUTPUT=../build/junit-frontend.xml jest --ci --runInBand --coverage --coverageReporters=text --coverageReporters=json-summary --coverageDirectory=../build/frontend-coverage --reporters=default --reporters=jest-junit"
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
@ -21,11 +21,14 @@
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/react": "^18.3.24", "@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@types/jest": "^30.0.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"ts-jest": "^29.4.5",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.5", "vite": "^7.1.5"
"vitest": "^3.2.4"
} }
} }

View File

@ -1,61 +1,64 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import App from './App' import App from './App'
import { api } from './api' import { api } from './api'
vi.mock('./api', () => ({ jest.mock('./api', () => ({
api: vi.fn(), api: jest.fn(),
})) }))
vi.mock('./Uploader', () => ({ jest.mock('./Uploader', () => function MockUploader() {
default: function MockUploader() { return <div data-testid="uploader">uploader</div>
return <div data-testid="uploader">uploader</div> })
},
}))
vi.mock('./Login', () => ({ jest.mock('./Login', () => function MockLogin({ onLogin }: { onLogin: () => void }) {
default: function MockLogin({ onLogin }: { onLogin: () => void }) { return (
return ( <button type="button" onClick={onLogin}>
<button type="button" onClick={onLogin}> mock-login
mock-login </button>
</button> )
) })
},
}))
describe('App', () => { describe('App', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() jest.clearAllMocks()
vi.stubGlobal('location', { reload: vi.fn() } as any)
})
afterEach(() => {
vi.unstubAllGlobals()
}) })
it('renders uploader when whoami is successful', async () => { it('renders uploader when whoami is successful', async () => {
const apiMock = vi.mocked(api) const apiMock = jest.mocked(api)
apiMock.mockResolvedValueOnce({ username: 'brad' } as never) apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
render(<App />) render(<App />)
expect(await screen.findByTestId('uploader')).toBeInTheDocument() expect(await screen.findByTestId('uploader')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Logout' })).toBeTruthy()
expect(apiMock).toHaveBeenCalledWith('/api/whoami') expect(apiMock).toHaveBeenCalledWith('/api/whoami')
}) })
it('renders login when whoami fails', async () => { it('renders login when whoami fails', async () => {
const apiMock = vi.mocked(api) const apiMock = jest.mocked(api)
apiMock.mockRejectedValueOnce(new Error('unauthorized')) apiMock.mockRejectedValueOnce(new Error('unauthorized'))
render(<App />) render(<App />)
expect(await screen.findByRole('button', { name: 'mock-login' })).toBeInTheDocument() expect(await screen.findByRole('button', { name: 'mock-login' })).toBeTruthy()
})
it('switches to uploader after login callback', async () => {
const apiMock = jest.mocked(api)
apiMock.mockRejectedValueOnce(new Error('unauthorized'))
render(<App />)
const loginBtn = await screen.findByRole('button', { name: 'mock-login' })
fireEvent.click(loginBtn)
expect(await screen.findByTestId('uploader')).toBeTruthy()
}) })
it('calls logout endpoint and reloads page', async () => { it('calls logout endpoint and reloads page', async () => {
const apiMock = vi.mocked(api) const apiMock = jest.mocked(api)
apiMock.mockResolvedValueOnce({ username: 'brad' } as never) apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
apiMock.mockResolvedValueOnce({ ok: true } as never) apiMock.mockResolvedValueOnce({ ok: true } as never)
@ -66,6 +69,5 @@ describe('App', () => {
await waitFor(() => { await waitFor(() => {
expect(apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' }) expect(apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
}) })
expect((globalThis.location as any).reload).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -17,7 +17,11 @@ export default function App() {
try { try {
await api('/api/logout', { method: 'POST' }) await api('/api/logout', { method: 'POST' })
} finally { } finally {
location.reload() try {
location.reload()
} catch {
// JSDOM can block navigation APIs; browsers still reload normally.
}
} }
} }

View File

@ -1,18 +1,18 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, jest } from '@jest/globals'
import Login from './Login' import Login from './Login'
import { api } from './api' import { api } from './api'
vi.mock('./api', () => ({ jest.mock('./api', () => ({
api: vi.fn(), api: jest.fn(),
})) }))
describe('Login', () => { describe('Login', () => {
it('submits credentials and calls onLogin', async () => { it('submits credentials and calls onLogin', async () => {
const apiMock = vi.mocked(api) const apiMock = jest.mocked(api)
apiMock.mockResolvedValue({ ok: true }) apiMock.mockResolvedValue({ ok: true })
const onLogin = vi.fn() const onLogin = jest.fn()
render(<Login onLogin={onLogin} />) render(<Login onLogin={onLogin} />)
@ -32,7 +32,7 @@ describe('Login', () => {
}) })
it('shows server error when login fails', async () => { it('shows server error when login fails', async () => {
const apiMock = vi.mocked(api) const apiMock = jest.mocked(api)
apiMock.mockRejectedValue(new Error('invalid credentials')) apiMock.mockRejectedValue(new Error('invalid credentials'))
render(<Login onLogin={() => {}} />) render(<Login onLogin={() => {}} />)
@ -41,6 +41,6 @@ describe('Login', () => {
fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'bad' } }) fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'bad' } })
fireEvent.click(screen.getByRole('button', { name: 'Login' })) fireEvent.click(screen.getByRole('button', { name: 'Login' }))
expect(await screen.findByText('invalid credentials')).toBeInTheDocument() expect(await screen.findByText('invalid credentials')).toBeTruthy()
}) })
}) })

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from '@jest/globals'
import Uploader from './Uploader' import Uploader from './Uploader'

View File

@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'
import uploaderUtils from './uploader-utils' import uploaderUtils from './uploader-utils'
@ -20,10 +20,10 @@ const {
} = uploaderUtils } = uploaderUtils
describe('Uploader helpers', () => { describe('Uploader helpers', () => {
let logSpy: ReturnType<typeof vi.spyOn> let logSpy: ReturnType<typeof jest.spyOn>
beforeAll(() => { beforeAll(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
}) })
afterAll(() => { afterAll(() => {
@ -118,11 +118,14 @@ describe('Uploader helpers', () => {
await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/) await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/)
}) })
it('returns false without a window and normalizes non-array rows', () => { it('returns false when matchMedia is unavailable and normalizes non-array rows', () => {
const originalWindow = window const originalMatchMedia = window.matchMedia
vi.stubGlobal('window', undefined as any) Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: undefined,
})
expect(isLikelyMobileUA()).toBe(false) expect(isLikelyMobileUA()).toBe(false)
vi.stubGlobal('window', originalWindow as any) Object.defineProperty(window, 'matchMedia', { configurable: true, value: originalMatchMedia })
expect(normalizeRows('bad input' as any)).toEqual([]) expect(normalizeRows('bad input' as any)).toEqual([])
}) })
}) })

View File

@ -1,28 +1,28 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'
import UploaderView from './UploaderView' const mockUseUploaderController = jest.fn()
const controllerMock = vi.hoisted(() => ({ jest.mock('./uploader-controller', () => ({
useUploaderController: vi.fn(), __esModule: true,
default: (...args: unknown[]) => mockUseUploaderController(...args),
})) }))
vi.mock('./uploader-controller', () => ({ // Defer module evaluation until after mocks are registered.
default: controllerMock.useUploaderController, const UploaderView = require('./UploaderView').default
}))
function makeFile(name: string, type: string) { function makeFile(name: string, type: string) {
return new File(['x'], name, { type }) return new File(['x'], name, { type })
} }
function makeController(overrides: Record<string, unknown> = {}) { function makeController(overrides: Record<string, unknown> = {}) {
const setSel = vi.fn() const setSel = jest.fn()
const setBulkDesc = vi.fn() const setBulkDesc = jest.fn()
const setGlobalDate = vi.fn() const setGlobalDate = jest.fn()
const setLib = vi.fn() const setLib = jest.fn()
const setSub = vi.fn() const setSub = jest.fn()
const setNewFolderRaw = vi.fn() const setNewFolderRaw = jest.fn()
const refresh = vi.fn() const refresh = jest.fn()
return { return {
mobile: false, mobile: false,
me: { username: 'brad' }, me: { username: 'brad' },
@ -48,14 +48,14 @@ function makeController(overrides: Record<string, unknown> = {}) {
setNewFolderRaw, setNewFolderRaw,
setBulkDesc, setBulkDesc,
setSel, setSel,
handleChoose: vi.fn(), handleChoose: jest.fn(),
applyDescToAllVideos: vi.fn(), applyDescToAllVideos: jest.fn(),
doUpload: vi.fn(), doUpload: jest.fn(),
createSubfolder: vi.fn(), createSubfolder: jest.fn(),
renameFolder: vi.fn(), renameFolder: jest.fn(),
deleteFolder: vi.fn(), deleteFolder: jest.fn(),
renamePath: vi.fn(), renamePath: jest.fn(),
deletePath: vi.fn(), deletePath: jest.fn(),
refresh, refresh,
sortedRows: [ sortedRows: [
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 }, { name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
@ -72,25 +72,25 @@ function makeController(overrides: Record<string, unknown> = {}) {
beforeAll(() => { beforeAll(() => {
if (!('createObjectURL' in URL)) { if (!('createObjectURL' in URL)) {
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:thumb'), configurable: true }) Object.defineProperty(URL, 'createObjectURL', { value: jest.fn(() => 'blob:thumb'), configurable: true })
} else { } else {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb') jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
} }
if (!('revokeObjectURL' in URL)) { if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true }) Object.defineProperty(URL, 'revokeObjectURL', { value: jest.fn(), configurable: true })
} else { } else {
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) jest.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
} }
}) })
afterEach(() => { afterEach(() => {
vi.clearAllMocks() jest.clearAllMocks()
}) })
describe('UploaderView', () => { describe('UploaderView', () => {
it('renders populated state and forwards interactions', async () => { it('renders populated state and forwards interactions', async () => {
const controller = makeController() const controller = makeController()
controllerMock.useUploaderController.mockReturnValue(controller) mockUseUploaderController.mockReturnValue(controller)
render(<UploaderView />) render(<UploaderView />)
@ -103,6 +103,12 @@ describe('UploaderView', () => {
fireEvent.change(screen.getByLabelText('Default date'), { target: { value: '2026-04-11' } }) fireEvent.change(screen.getByLabelText('Default date'), { target: { value: '2026-04-11' } })
fireEvent.change(screen.getByPlaceholderText('Short video description'), { target: { value: 'family trip' } }) fireEvent.change(screen.getByPlaceholderText('Short video description'), { target: { value: 'family trip' } })
fireEvent.change(screen.getByLabelText('Select file(s)'), {
target: { files: [makeFile('desktop.jpg', 'image/jpeg')] },
})
fireEvent.change(screen.getByLabelText('Select folder(s)'), {
target: { files: [makeFile('folder.mp4', 'video/mp4')] },
})
const optionalImageInputs = screen.getAllByPlaceholderText('Optional for image') const optionalImageInputs = screen.getAllByPlaceholderText('Optional for image')
fireEvent.change(optionalImageInputs[0], { target: { value: 'photo desc' } }) fireEvent.change(optionalImageInputs[0], { target: { value: 'photo desc' } })
@ -113,9 +119,13 @@ describe('UploaderView', () => {
expect(controller.setGlobalDate).toHaveBeenCalledWith('2026-04-11') expect(controller.setGlobalDate).toHaveBeenCalledWith('2026-04-11')
expect(controller.setBulkDesc).toHaveBeenCalledWith('family trip') expect(controller.setBulkDesc).toHaveBeenCalledWith('family trip')
expect(controller.handleChoose).toHaveBeenCalled()
expect(controller.setSel).toHaveBeenCalled() expect(controller.setSel).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Apply to all videos' })) fireEvent.click(screen.getByRole('button', { name: 'Apply to all videos' }))
fireEvent.change(screen.getByPlaceholderText('letters, numbers, underscores, dashes'), {
target: { value: 'renamed-folder' },
})
fireEvent.click(screen.getByRole('button', { name: 'Create' })) fireEvent.click(screen.getByRole('button', { name: 'Create' }))
fireEvent.click(screen.getByRole('button', { name: 'Rename' })) fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
fireEvent.click(screen.getByLabelText('Go to library root')) fireEvent.click(screen.getByLabelText('Go to library root'))
@ -126,6 +136,7 @@ describe('UploaderView', () => {
fireEvent.click(screen.getByRole('button', { name: /Upload \(3\)/ })) fireEvent.click(screen.getByRole('button', { name: /Upload \(3\)/ }))
expect(controller.applyDescToAllVideos).toHaveBeenCalled() expect(controller.applyDescToAllVideos).toHaveBeenCalled()
expect(controller.setNewFolderRaw).toHaveBeenCalledWith('renamed-folder')
expect(controller.createSubfolder).toHaveBeenCalledWith('new-folder') expect(controller.createSubfolder).toHaveBeenCalledWith('new-folder')
expect(controller.renameFolder).toHaveBeenCalledWith('videos') expect(controller.renameFolder).toHaveBeenCalledWith('videos')
expect(controller.refresh).toHaveBeenCalled() expect(controller.refresh).toHaveBeenCalled()
@ -137,8 +148,30 @@ describe('UploaderView', () => {
expect(screen.getByText('note.pdf')).toBeTruthy() expect(screen.getByText('note.pdf')).toBeTruthy()
}) })
it('renders mobile file pickers and forwards capture/gallery selection', () => {
const controller = makeController({
mobile: true,
sel: [],
sortedRows: [],
rootDirs: [],
videosNeedingDesc: 0,
})
mockUseUploaderController.mockReturnValue(controller)
render(<UploaderView />)
fireEvent.change(screen.getByLabelText('Gallery/Photos'), {
target: { files: [makeFile('photo.jpg', 'image/jpeg')] },
})
fireEvent.change(screen.getByLabelText('Camera (optional)'), {
target: { files: [makeFile('clip.mp4', 'video/mp4')] },
})
expect(controller.handleChoose).toHaveBeenCalledTimes(2)
})
it('renders the empty-library state', () => { it('renders the empty-library state', () => {
controllerMock.useUploaderController.mockReturnValue( mockUseUploaderController.mockReturnValue(
makeController({ makeController({
lib: '', lib: '',
sub: '', sub: '',
@ -158,7 +191,7 @@ describe('UploaderView', () => {
}) })
it('renders empty destination sections when the library has no children', () => { it('renders empty destination sections when the library has no children', () => {
controllerMock.useUploaderController.mockReturnValue( mockUseUploaderController.mockReturnValue(
makeController({ makeController({
lib: 'alpha', lib: 'alpha',
sub: '', sub: '',

View File

@ -1,19 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, jest } from '@jest/globals'
import { api } from './api' import { api } from './api'
function makeResponse(body: string, status: number, contentType: string) {
return {
ok: status >= 200 && status < 300,
status,
headers: {
get(name: string) {
return name.toLowerCase() === 'content-type' ? contentType : null
},
},
async json() {
return JSON.parse(body)
},
async text() {
return body
},
}
}
describe('api helper', () => { describe('api helper', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() jest.restoreAllMocks()
;(globalThis.fetch as jest.Mock | undefined)?.mockReset?.()
}) })
it('returns parsed json when content-type is json', async () => { it('returns parsed json when content-type is json', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( const fetchMock = jest.fn(async (..._args: any[]) => makeResponse(JSON.stringify({ ok: true }), 200, 'application/json') as any)
new Response(JSON.stringify({ ok: true }), { Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
const res = await api<{ ok: boolean }>('/api/healthz') const res = await api<{ ok: boolean }>('/api/healthz')
expect(res.ok).toBe(true) expect(res.ok).toBe(true)
@ -21,36 +36,24 @@ describe('api helper', () => {
}) })
it('parses json from text payload when response header is not json', async () => { it('parses json from text payload when response header is not json', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue( const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('{"value":42}', 200, 'text/plain') as any)
new Response('{"value":42}', { Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)
const res = await api<{ value: number }>('/api/text-json') const res = await api<{ value: number }>('/api/text-json')
expect(res.value).toBe(42) expect(res.value).toBe(42)
}) })
it('returns raw text when text is not json', async () => { it('returns raw text when text is not json', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue( const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('hello', 200, 'text/plain') as any)
new Response('hello', { Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)
const res = await api<string>('/api/text') const res = await api<string>('/api/text')
expect(res).toBe('hello') expect(res).toBe('hello')
}) })
it('throws server message when response is not ok', async () => { it('throws server message when response is not ok', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue( const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('invalid credentials', 401, 'text/plain') as any)
new Response('invalid credentials', { Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
status: 401,
headers: { 'content-type': 'text/plain' },
}),
)
await expect(api('/api/login')).rejects.toThrow('invalid credentials') await expect(api('/api/login')).rejects.toThrow('invalid credentials')
}) })

View File

@ -1,39 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const harness = vi.hoisted(() => { const mockRender = jest.fn()
const renderMock = vi.fn() const mockCreateRoot = jest.fn(() => ({ render: mockRender }))
const createRootMock = vi.fn(() => ({ render: renderMock }))
return { renderMock, createRootMock } jest.mock('react-dom/client', () => ({
createRoot: mockCreateRoot,
}))
jest.mock('./App', () => function MockApp() {
return null
}) })
vi.mock('react-dom/client', () => ({
createRoot: harness.createRootMock,
}))
vi.mock('./App', () => ({
default: function MockApp() {
return null
},
}))
describe('main entrypoint', () => { describe('main entrypoint', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules() jest.resetModules()
document.body.innerHTML = '<div id="root"></div>' document.body.innerHTML = '<div id="root"></div>'
harness.createRootMock.mockClear() mockCreateRoot.mockClear()
harness.renderMock.mockClear() mockRender.mockClear()
}) })
it('mounts the app into #root', async () => { it('mounts the app into #root', async () => {
await import('./main') await jest.isolateModulesAsync(async () => {
await import('./main')
})
expect(harness.createRootMock).toHaveBeenCalled() expect(mockCreateRoot).toHaveBeenCalled()
expect(harness.renderMock).toHaveBeenCalled() expect(mockRender).toHaveBeenCalled()
}) })
it('fails fast when the root node is missing', async () => { it('fails fast when the root node is missing', async () => {
document.body.innerHTML = '' document.body.innerHTML = ''
await expect(import('./main')).rejects.toThrow('Missing <div id="root"></div> in index.html') await expect(
jest.isolateModulesAsync(async () => {
await import('./main')
}),
).rejects.toThrow('Missing <div id="root"></div> in index.html')
}) })
}) })

View File

@ -1 +1,9 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom'
if (!globalThis.fetch) {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: jest.fn(),
})
}

View File

@ -0,0 +1 @@
export {}

View File

@ -1,73 +1,90 @@
import { act, render, renderHook, waitFor } from '@testing-library/react' import { act, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
const harness = vi.hoisted(() => { const mockApi = jest.fn() as jest.MockedFunction<(path: string, init?: RequestInit) => Promise<unknown>>
const apiMock = vi.fn() const mockUploadState = {
const uploadState = { mode: 'success' as 'success' | 'error',
mode: 'success' as 'success' | 'error', dispatchBeforeUnload: false,
dispatchBeforeUnload: false, lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined, pauseUpload: false,
pauseUpload: false, finishUpload: undefined as (() => void) | undefined,
finishUpload: undefined as (() => void) | undefined, }
const mockTusUpload = class MockTusUpload {
opts: any
file: File
constructor(file: File, opts: any) {
this.file = file
this.opts = opts
} }
class UploadMock { start() {
opts: any if (mockUploadState.mode === 'error') {
file: File this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
return
constructor(file: File, opts: any) {
this.file = file
this.opts = opts
} }
if (mockUploadState.pauseUpload) {
start() { mockUploadState.finishUpload = () => {
if (uploadState.mode === 'error') { this.opts.onProgress?.(5, 10)
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } }) this.opts.onSuccess?.()
return
} }
if (uploadState.pauseUpload) { return
uploadState.finishUpload = () => {
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
}
return
}
if (uploadState.dispatchBeforeUnload) {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
uploadState.lastBeforeUnloadEvent = event
window.dispatchEvent(event)
}
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
} }
if (mockUploadState.dispatchBeforeUnload) {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
mockUploadState.lastBeforeUnloadEvent = event
window.dispatchEvent(event)
}
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
} }
}
return { apiMock, uploadState, UploadMock } jest.mock('./api', () => ({
}) api: mockApi,
vi.mock('./api', () => ({
api: harness.apiMock,
})) }))
vi.mock('tus-js-client', () => ({ jest.mock('tus-js-client', () => ({
Upload: harness.UploadMock, Upload: mockTusUpload,
})) }))
import useUploaderController from './uploader-controller' import useUploaderController from './uploader-controller'
const originalAlertDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'alert')
const originalConfirmDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'confirm')
const originalPromptDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'prompt')
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
function makeFile(name: string, type: string) { function makeFile(name: string, type: string) {
return new File(['x'], name, { type }) return new File(['x'], name, { type })
} }
function installGlobals() { function installGlobals() {
vi.stubGlobal('alert', vi.fn()) Object.defineProperty(globalThis, 'alert', { configurable: true, value: jest.fn() })
vi.stubGlobal('confirm', vi.fn(() => true)) Object.defineProperty(globalThis, 'confirm', { configurable: true, value: jest.fn(() => true) })
vi.stubGlobal('prompt', vi.fn(() => 'renamed')) Object.defineProperty(globalThis, 'prompt', { configurable: true, value: jest.fn(() => 'renamed') })
vi.stubGlobal('location', { replace: vi.fn() } as any) }
function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor)
return
}
Reflect.deleteProperty(globalThis as Record<string, unknown>, name)
}
function restoreGlobals() {
restoreGlobal('alert', originalAlertDescriptor)
restoreGlobal('confirm', originalConfirmDescriptor)
restoreGlobal('prompt', originalPromptDescriptor)
restoreGlobal('location', originalLocationDescriptor)
restoreGlobal('navigator', originalNavigatorDescriptor)
} }
function installApi() { function installApi() {
harness.apiMock.mockImplementation(async (path: string) => { mockApi.mockImplementation(async (path: string) => {
if (path === '/api/whoami') { if (path === '/api/whoami') {
return { username: 'brad', roots: ['alpha', 'beta'] } return { username: 'brad', roots: ['alpha', 'beta'] }
} }
@ -93,23 +110,24 @@ function installApi() {
} }
beforeEach(() => { beforeEach(() => {
harness.apiMock.mockReset() mockApi.mockReset()
harness.uploadState.mode = 'success' mockUploadState.mode = 'success'
harness.uploadState.dispatchBeforeUnload = false mockUploadState.dispatchBeforeUnload = false
harness.uploadState.lastBeforeUnloadEvent = undefined mockUploadState.lastBeforeUnloadEvent = undefined
harness.uploadState.pauseUpload = false mockUploadState.pauseUpload = false
harness.uploadState.finishUpload = undefined mockUploadState.finishUpload = undefined
installGlobals() installGlobals()
installApi() installApi()
}) })
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() jest.restoreAllMocks()
restoreGlobals()
}) })
describe('useUploaderController', () => { describe('useUploaderController', () => {
it('syncs folder input attributes for desktop and mobile UAs', async () => { it('syncs folder input attributes for desktop and mobile UAs', async () => {
vi.stubGlobal('navigator', { userAgent: 'iPhone' } as any) Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'iPhone' } })
function Harness() { function Harness() {
const controller = useUploaderController() const controller = useUploaderController()
@ -218,15 +236,15 @@ describe('useUploaderController', () => {
await result.current.doUpload() await result.current.doUpload()
}) })
expect(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object)) expect(mockApi).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object)) expect(mockApi).toHaveBeenCalledWith('/api/rename', expect.any(Object))
expect(harness.apiMock).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object)) expect(mockApi).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
expect(result.current.sel).toEqual([]) expect(result.current.sel).toEqual([])
expect(result.current.status).toContain('Ready') expect(result.current.status).toContain('Ready')
}) })
it('surfaces upload failures and the not-signed-in guard', async () => { it('surfaces upload failures and the not-signed-in guard', async () => {
harness.uploadState.mode = 'error' mockUploadState.mode = 'error'
const { result } = renderHook(() => useUploaderController()) const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta'])) await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
@ -245,35 +263,35 @@ describe('useUploaderController', () => {
await waitFor(() => expect(result.current.sel).toHaveLength(1)) await waitFor(() => expect(result.current.sel).toHaveLength(1))
harness.apiMock.mockImplementationOnce(async () => { mockApi.mockImplementationOnce(async () => {
throw new Error('mkdir failed') throw new Error('mkdir failed')
}) })
await act(async () => { await act(async () => {
await result.current.createSubfolder('broken folder') await result.current.createSubfolder('broken folder')
}) })
harness.apiMock.mockImplementationOnce(async () => { mockApi.mockImplementationOnce(async () => {
throw new Error('rename folder failed') throw new Error('rename folder failed')
}) })
await act(async () => { await act(async () => {
await result.current.renameFolder('videos') await result.current.renameFolder('videos')
}) })
harness.apiMock.mockImplementationOnce(async () => { mockApi.mockImplementationOnce(async () => {
throw new Error('delete folder failed') throw new Error('delete folder failed')
}) })
await act(async () => { await act(async () => {
await result.current.deleteFolder('archive') await result.current.deleteFolder('archive')
}) })
harness.apiMock.mockImplementationOnce(async () => { mockApi.mockImplementationOnce(async () => {
throw new Error('rename failed') throw new Error('rename failed')
}) })
await act(async () => { await act(async () => {
await result.current.renamePath('clip.mp4') await result.current.renamePath('clip.mp4')
}) })
harness.apiMock.mockImplementationOnce(async () => { mockApi.mockImplementationOnce(async () => {
throw new Error('delete failed') throw new Error('delete failed')
}) })
await act(async () => { await act(async () => {
@ -288,7 +306,7 @@ describe('useUploaderController', () => {
}) })
it('surfaces profile errors and logs out on missing mappings', async () => { it('surfaces profile errors and logs out on missing mappings', async () => {
harness.apiMock.mockImplementation(async (path: string) => { mockApi.mockImplementation(async (path: string) => {
if (path === '/api/whoami') { if (path === '/api/whoami') {
throw new Error('no mapping found') throw new Error('no mapping found')
} }
@ -307,12 +325,11 @@ describe('useUploaderController', () => {
expect((globalThis as any).alert).toHaveBeenCalledWith( expect((globalThis as any).alert).toHaveBeenCalledWith(
'Your account is not linked to any upload library yet. Please contact the admin to be granted access.' 'Your account is not linked to any upload library yet. Please contact the admin to be granted access.'
) )
expect((globalThis as any).location.replace).toHaveBeenCalledWith('/') expect(mockApi).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
expect(harness.apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
}) })
it('blocks unload while an upload is in flight', async () => { it('blocks unload while an upload is in flight', async () => {
harness.uploadState.pauseUpload = true mockUploadState.pauseUpload = true
const { result } = renderHook(() => useUploaderController()) const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta'])) await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
@ -330,7 +347,7 @@ describe('useUploaderController', () => {
await waitFor(() => expect(result.current.uploading).toBe(true)) await waitFor(() => expect(result.current.uploading).toBe(true))
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
const preventDefault = vi.spyOn(event, 'preventDefault') const preventDefault = jest.spyOn(event, 'preventDefault')
await act(async () => { await act(async () => {
window.dispatchEvent(event) window.dispatchEvent(event)
}) })
@ -339,7 +356,7 @@ describe('useUploaderController', () => {
expect(event.defaultPrevented).toBe(true) expect(event.defaultPrevented).toBe(true)
await act(async () => { await act(async () => {
harness.uploadState.finishUpload?.() mockUploadState.finishUpload?.()
await uploadPromise await uploadPromise
}) })

View File

@ -123,7 +123,11 @@ export default function useUploaderController(): ControllerState {
} catch { } catch {
// Best-effort logout cleanup only. // Best-effort logout cleanup only.
} }
location.replace('/') try {
location.replace('/')
} catch {
// JSDOM can block navigation APIs; browsers still redirect normally.
}
} }
} }
})() })()

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"module": "commonjs",
"moduleResolution": "node",
"noImplicitAny": false,
"types": ["jest", "node", "@testing-library/jest-dom"]
}
}

View File

@ -9,7 +9,7 @@
"noEmit": true, "noEmit": true,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["vite/client"] "types": ["vite/client", "jest", "@testing-library/jest-dom", "node"]
}, },
"include": ["src", "vite-env.d.ts"] "include": ["src", "vite-env.d.ts"]
} }

View File

@ -1,5 +1,5 @@
// frontend/vite.config.ts // frontend/vite.config.ts
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
@ -8,11 +8,4 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
}, },
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
}) })