test(pegasus): finish frontend gate coverage
This commit is contained in:
parent
2cf7fcba50
commit
7d11941895
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@ -48,6 +48,7 @@ spec:
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '200', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '120'))
|
||||
}
|
||||
|
||||
triggers {
|
||||
|
||||
26
frontend/jest.config.cjs
Normal file
26
frontend/jest.config.cjs
Normal 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/**',
|
||||
],
|
||||
}
|
||||
4446
frontend/package-lock.json
generated
4446
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"test": "vitest run",
|
||||
"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": "jest --runInBand",
|
||||
"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": {
|
||||
"@picocss/pico": "^2.1.1",
|
||||
@ -21,11 +21,14 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@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",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,61 +1,64 @@
|
||||
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 { api } from './api'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: vi.fn(),
|
||||
jest.mock('./api', () => ({
|
||||
api: jest.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./Uploader', () => ({
|
||||
default: function MockUploader() {
|
||||
return <div data-testid="uploader">uploader</div>
|
||||
},
|
||||
}))
|
||||
jest.mock('./Uploader', () => function MockUploader() {
|
||||
return <div data-testid="uploader">uploader</div>
|
||||
})
|
||||
|
||||
vi.mock('./Login', () => ({
|
||||
default: function MockLogin({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onLogin}>
|
||||
mock-login
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
jest.mock('./Login', () => function MockLogin({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onLogin}>
|
||||
mock-login
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('location', { reload: vi.fn() } as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders uploader when whoami is successful', async () => {
|
||||
const apiMock = vi.mocked(api)
|
||||
const apiMock = jest.mocked(api)
|
||||
apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByTestId('uploader')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('uploader')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Logout' })).toBeTruthy()
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/whoami')
|
||||
})
|
||||
|
||||
it('renders login when whoami fails', async () => {
|
||||
const apiMock = vi.mocked(api)
|
||||
const apiMock = jest.mocked(api)
|
||||
apiMock.mockRejectedValueOnce(new Error('unauthorized'))
|
||||
|
||||
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 () => {
|
||||
const apiMock = vi.mocked(api)
|
||||
const apiMock = jest.mocked(api)
|
||||
apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
|
||||
apiMock.mockResolvedValueOnce({ ok: true } as never)
|
||||
|
||||
@ -66,6 +69,5 @@ describe('App', () => {
|
||||
await waitFor(() => {
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
|
||||
})
|
||||
expect((globalThis.location as any).reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,7 +17,11 @@ export default function App() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' })
|
||||
} finally {
|
||||
location.reload()
|
||||
try {
|
||||
location.reload()
|
||||
} catch {
|
||||
// JSDOM can block navigation APIs; browsers still reload normally.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
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 { api } from './api'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: vi.fn(),
|
||||
jest.mock('./api', () => ({
|
||||
api: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('Login', () => {
|
||||
it('submits credentials and calls onLogin', async () => {
|
||||
const apiMock = vi.mocked(api)
|
||||
const apiMock = jest.mocked(api)
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
const onLogin = vi.fn()
|
||||
const onLogin = jest.fn()
|
||||
|
||||
render(<Login onLogin={onLogin} />)
|
||||
|
||||
@ -32,7 +32,7 @@ describe('Login', () => {
|
||||
})
|
||||
|
||||
it('shows server error when login fails', async () => {
|
||||
const apiMock = vi.mocked(api)
|
||||
const apiMock = jest.mocked(api)
|
||||
apiMock.mockRejectedValue(new Error('invalid credentials'))
|
||||
|
||||
render(<Login onLogin={() => {}} />)
|
||||
@ -41,6 +41,6 @@ describe('Login', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'bad' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
|
||||
expect(await screen.findByText('invalid credentials')).toBeInTheDocument()
|
||||
expect(await screen.findByText('invalid credentials')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
import Uploader from './Uploader'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -20,10 +20,10 @@ const {
|
||||
} = uploaderUtils
|
||||
|
||||
describe('Uploader helpers', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let logSpy: ReturnType<typeof jest.spyOn>
|
||||
|
||||
beforeAll(() => {
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@ -118,11 +118,14 @@ describe('Uploader helpers', () => {
|
||||
await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/)
|
||||
})
|
||||
|
||||
it('returns false without a window and normalizes non-array rows', () => {
|
||||
const originalWindow = window
|
||||
vi.stubGlobal('window', undefined as any)
|
||||
it('returns false when matchMedia is unavailable and normalizes non-array rows', () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
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(() => ({
|
||||
useUploaderController: vi.fn(),
|
||||
jest.mock('./uploader-controller', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseUploaderController(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./uploader-controller', () => ({
|
||||
default: controllerMock.useUploaderController,
|
||||
}))
|
||||
// Defer module evaluation until after mocks are registered.
|
||||
const UploaderView = require('./UploaderView').default
|
||||
|
||||
function makeFile(name: string, type: string) {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
function makeController(overrides: Record<string, unknown> = {}) {
|
||||
const setSel = vi.fn()
|
||||
const setBulkDesc = vi.fn()
|
||||
const setGlobalDate = vi.fn()
|
||||
const setLib = vi.fn()
|
||||
const setSub = vi.fn()
|
||||
const setNewFolderRaw = vi.fn()
|
||||
const refresh = vi.fn()
|
||||
const setSel = jest.fn()
|
||||
const setBulkDesc = jest.fn()
|
||||
const setGlobalDate = jest.fn()
|
||||
const setLib = jest.fn()
|
||||
const setSub = jest.fn()
|
||||
const setNewFolderRaw = jest.fn()
|
||||
const refresh = jest.fn()
|
||||
return {
|
||||
mobile: false,
|
||||
me: { username: 'brad' },
|
||||
@ -48,14 +48,14 @@ function makeController(overrides: Record<string, unknown> = {}) {
|
||||
setNewFolderRaw,
|
||||
setBulkDesc,
|
||||
setSel,
|
||||
handleChoose: vi.fn(),
|
||||
applyDescToAllVideos: vi.fn(),
|
||||
doUpload: vi.fn(),
|
||||
createSubfolder: vi.fn(),
|
||||
renameFolder: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
renamePath: vi.fn(),
|
||||
deletePath: vi.fn(),
|
||||
handleChoose: jest.fn(),
|
||||
applyDescToAllVideos: jest.fn(),
|
||||
doUpload: jest.fn(),
|
||||
createSubfolder: jest.fn(),
|
||||
renameFolder: jest.fn(),
|
||||
deleteFolder: jest.fn(),
|
||||
renamePath: jest.fn(),
|
||||
deletePath: jest.fn(),
|
||||
refresh,
|
||||
sortedRows: [
|
||||
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
|
||||
@ -72,25 +72,25 @@ function makeController(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
beforeAll(() => {
|
||||
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 {
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
|
||||
jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
|
||||
}
|
||||
if (!('revokeObjectURL' in URL)) {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: jest.fn(), configurable: true })
|
||||
} else {
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
jest.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('UploaderView', () => {
|
||||
it('renders populated state and forwards interactions', async () => {
|
||||
const controller = makeController()
|
||||
controllerMock.useUploaderController.mockReturnValue(controller)
|
||||
mockUseUploaderController.mockReturnValue(controller)
|
||||
|
||||
render(<UploaderView />)
|
||||
|
||||
@ -103,6 +103,12 @@ describe('UploaderView', () => {
|
||||
|
||||
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.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')
|
||||
fireEvent.change(optionalImageInputs[0], { target: { value: 'photo desc' } })
|
||||
@ -113,9 +119,13 @@ describe('UploaderView', () => {
|
||||
|
||||
expect(controller.setGlobalDate).toHaveBeenCalledWith('2026-04-11')
|
||||
expect(controller.setBulkDesc).toHaveBeenCalledWith('family trip')
|
||||
expect(controller.handleChoose).toHaveBeenCalled()
|
||||
expect(controller.setSel).toHaveBeenCalled()
|
||||
|
||||
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: 'Rename' }))
|
||||
fireEvent.click(screen.getByLabelText('Go to library root'))
|
||||
@ -126,6 +136,7 @@ describe('UploaderView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Upload \(3\)/ }))
|
||||
|
||||
expect(controller.applyDescToAllVideos).toHaveBeenCalled()
|
||||
expect(controller.setNewFolderRaw).toHaveBeenCalledWith('renamed-folder')
|
||||
expect(controller.createSubfolder).toHaveBeenCalledWith('new-folder')
|
||||
expect(controller.renameFolder).toHaveBeenCalledWith('videos')
|
||||
expect(controller.refresh).toHaveBeenCalled()
|
||||
@ -137,8 +148,30 @@ describe('UploaderView', () => {
|
||||
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', () => {
|
||||
controllerMock.useUploaderController.mockReturnValue(
|
||||
mockUseUploaderController.mockReturnValue(
|
||||
makeController({
|
||||
lib: '',
|
||||
sub: '',
|
||||
@ -158,7 +191,7 @@ describe('UploaderView', () => {
|
||||
})
|
||||
|
||||
it('renders empty destination sections when the library has no children', () => {
|
||||
controllerMock.useUploaderController.mockReturnValue(
|
||||
mockUseUploaderController.mockReturnValue(
|
||||
makeController({
|
||||
lib: 'alpha',
|
||||
sub: '',
|
||||
|
||||
@ -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'
|
||||
|
||||
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', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
jest.restoreAllMocks()
|
||||
;(globalThis.fetch as jest.Mock | undefined)?.mockReset?.()
|
||||
})
|
||||
|
||||
it('returns parsed json when content-type is json', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse(JSON.stringify({ ok: true }), 200, 'application/json') as any)
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
|
||||
|
||||
const res = await api<{ ok: boolean }>('/api/healthz')
|
||||
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 () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('{"value":42}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
)
|
||||
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('{"value":42}', 200, 'text/plain') as any)
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
|
||||
|
||||
const res = await api<{ value: number }>('/api/text-json')
|
||||
expect(res.value).toBe(42)
|
||||
})
|
||||
|
||||
it('returns raw text when text is not json', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('hello', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
)
|
||||
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('hello', 200, 'text/plain') as any)
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
|
||||
|
||||
const res = await api<string>('/api/text')
|
||||
expect(res).toBe('hello')
|
||||
})
|
||||
|
||||
it('throws server message when response is not ok', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('invalid credentials', {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
)
|
||||
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('invalid credentials', 401, 'text/plain') as any)
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock })
|
||||
|
||||
await expect(api('/api/login')).rejects.toThrow('invalid credentials')
|
||||
})
|
||||
|
||||
@ -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 renderMock = vi.fn()
|
||||
const createRootMock = vi.fn(() => ({ render: renderMock }))
|
||||
return { renderMock, createRootMock }
|
||||
const mockRender = jest.fn()
|
||||
const mockCreateRoot = jest.fn(() => ({ render: mockRender }))
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
jest.resetModules()
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
harness.createRootMock.mockClear()
|
||||
harness.renderMock.mockClear()
|
||||
mockCreateRoot.mockClear()
|
||||
mockRender.mockClear()
|
||||
})
|
||||
|
||||
it('mounts the app into #root', async () => {
|
||||
await import('./main')
|
||||
await jest.isolateModulesAsync(async () => {
|
||||
await import('./main')
|
||||
})
|
||||
|
||||
expect(harness.createRootMock).toHaveBeenCalled()
|
||||
expect(harness.renderMock).toHaveBeenCalled()
|
||||
expect(mockCreateRoot).toHaveBeenCalled()
|
||||
expect(mockRender).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when the root node is missing', async () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
1
frontend/src/test/styleMock.ts
Normal file
1
frontend/src/test/styleMock.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
@ -1,73 +1,90 @@
|
||||
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 apiMock = vi.fn()
|
||||
const uploadState = {
|
||||
mode: 'success' as 'success' | 'error',
|
||||
dispatchBeforeUnload: false,
|
||||
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
|
||||
pauseUpload: false,
|
||||
finishUpload: undefined as (() => void) | undefined,
|
||||
const mockApi = jest.fn() as jest.MockedFunction<(path: string, init?: RequestInit) => Promise<unknown>>
|
||||
const mockUploadState = {
|
||||
mode: 'success' as 'success' | 'error',
|
||||
dispatchBeforeUnload: false,
|
||||
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
|
||||
pauseUpload: false,
|
||||
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 {
|
||||
opts: any
|
||||
file: File
|
||||
|
||||
constructor(file: File, opts: any) {
|
||||
this.file = file
|
||||
this.opts = opts
|
||||
start() {
|
||||
if (mockUploadState.mode === 'error') {
|
||||
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
|
||||
return
|
||||
}
|
||||
|
||||
start() {
|
||||
if (uploadState.mode === 'error') {
|
||||
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
|
||||
return
|
||||
if (mockUploadState.pauseUpload) {
|
||||
mockUploadState.finishUpload = () => {
|
||||
this.opts.onProgress?.(5, 10)
|
||||
this.opts.onSuccess?.()
|
||||
}
|
||||
if (uploadState.pauseUpload) {
|
||||
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?.()
|
||||
return
|
||||
}
|
||||
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 }
|
||||
})
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: harness.apiMock,
|
||||
jest.mock('./api', () => ({
|
||||
api: mockApi,
|
||||
}))
|
||||
|
||||
vi.mock('tus-js-client', () => ({
|
||||
Upload: harness.UploadMock,
|
||||
jest.mock('tus-js-client', () => ({
|
||||
Upload: mockTusUpload,
|
||||
}))
|
||||
|
||||
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) {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
function installGlobals() {
|
||||
vi.stubGlobal('alert', vi.fn())
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.stubGlobal('prompt', vi.fn(() => 'renamed'))
|
||||
vi.stubGlobal('location', { replace: vi.fn() } as any)
|
||||
Object.defineProperty(globalThis, 'alert', { configurable: true, value: jest.fn() })
|
||||
Object.defineProperty(globalThis, 'confirm', { configurable: true, value: jest.fn(() => true) })
|
||||
Object.defineProperty(globalThis, 'prompt', { configurable: true, value: jest.fn(() => 'renamed') })
|
||||
}
|
||||
|
||||
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() {
|
||||
harness.apiMock.mockImplementation(async (path: string) => {
|
||||
mockApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/whoami') {
|
||||
return { username: 'brad', roots: ['alpha', 'beta'] }
|
||||
}
|
||||
@ -93,23 +110,24 @@ function installApi() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.apiMock.mockReset()
|
||||
harness.uploadState.mode = 'success'
|
||||
harness.uploadState.dispatchBeforeUnload = false
|
||||
harness.uploadState.lastBeforeUnloadEvent = undefined
|
||||
harness.uploadState.pauseUpload = false
|
||||
harness.uploadState.finishUpload = undefined
|
||||
mockApi.mockReset()
|
||||
mockUploadState.mode = 'success'
|
||||
mockUploadState.dispatchBeforeUnload = false
|
||||
mockUploadState.lastBeforeUnloadEvent = undefined
|
||||
mockUploadState.pauseUpload = false
|
||||
mockUploadState.finishUpload = undefined
|
||||
installGlobals()
|
||||
installApi()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
jest.restoreAllMocks()
|
||||
restoreGlobals()
|
||||
})
|
||||
|
||||
describe('useUploaderController', () => {
|
||||
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() {
|
||||
const controller = useUploaderController()
|
||||
@ -218,15 +236,15 @@ describe('useUploaderController', () => {
|
||||
await result.current.doUpload()
|
||||
})
|
||||
|
||||
expect(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
|
||||
expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object))
|
||||
expect(harness.apiMock).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/rename', expect.any(Object))
|
||||
expect(mockApi).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
|
||||
expect(result.current.sel).toEqual([])
|
||||
expect(result.current.status).toContain('Ready')
|
||||
})
|
||||
|
||||
it('surfaces upload failures and the not-signed-in guard', async () => {
|
||||
harness.uploadState.mode = 'error'
|
||||
mockUploadState.mode = 'error'
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
@ -245,35 +263,35 @@ describe('useUploaderController', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
mockApi.mockImplementationOnce(async () => {
|
||||
throw new Error('mkdir failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.createSubfolder('broken folder')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
mockApi.mockImplementationOnce(async () => {
|
||||
throw new Error('rename folder failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renameFolder('videos')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
mockApi.mockImplementationOnce(async () => {
|
||||
throw new Error('delete folder failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.deleteFolder('archive')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
mockApi.mockImplementationOnce(async () => {
|
||||
throw new Error('rename failed')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.renamePath('clip.mp4')
|
||||
})
|
||||
|
||||
harness.apiMock.mockImplementationOnce(async () => {
|
||||
mockApi.mockImplementationOnce(async () => {
|
||||
throw new Error('delete failed')
|
||||
})
|
||||
await act(async () => {
|
||||
@ -288,7 +306,7 @@ describe('useUploaderController', () => {
|
||||
})
|
||||
|
||||
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') {
|
||||
throw new Error('no mapping found')
|
||||
}
|
||||
@ -307,12 +325,11 @@ describe('useUploaderController', () => {
|
||||
expect((globalThis as any).alert).toHaveBeenCalledWith(
|
||||
'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(harness.apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
|
||||
})
|
||||
|
||||
it('blocks unload while an upload is in flight', async () => {
|
||||
harness.uploadState.pauseUpload = true
|
||||
mockUploadState.pauseUpload = true
|
||||
const { result } = renderHook(() => useUploaderController())
|
||||
|
||||
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
||||
@ -330,7 +347,7 @@ describe('useUploaderController', () => {
|
||||
await waitFor(() => expect(result.current.uploading).toBe(true))
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault')
|
||||
const preventDefault = jest.spyOn(event, 'preventDefault')
|
||||
await act(async () => {
|
||||
window.dispatchEvent(event)
|
||||
})
|
||||
@ -339,7 +356,7 @@ describe('useUploaderController', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
harness.uploadState.finishUpload?.()
|
||||
mockUploadState.finishUpload?.()
|
||||
await uploadPromise
|
||||
})
|
||||
|
||||
|
||||
@ -123,7 +123,11 @@ export default function useUploaderController(): ControllerState {
|
||||
} catch {
|
||||
// Best-effort logout cleanup only.
|
||||
}
|
||||
location.replace('/')
|
||||
try {
|
||||
location.replace('/')
|
||||
} catch {
|
||||
// JSDOM can block navigation APIs; browsers still redirect normally.
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
12
frontend/tsconfig.jest.json
Normal file
12
frontend/tsconfig.jest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "jest", "@testing-library/jest-dom", "node"]
|
||||
},
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// frontend/vite.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
@ -8,11 +8,4 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: true,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user