Compare commits

..

No commits in common. "c1aabff63a23257c96820fe26d4cd38e52f90bcf" and "30d22371160d5c4110694519cda609eccd206712" have entirely different histories.

20 changed files with 775 additions and 4266 deletions

48
Jenkinsfile vendored
View File

@ -254,20 +254,17 @@ PY
case "${sonar_status}" in case "${sonar_status}" in
ok|pass|passed|success) ;; ok|pass|passed|success) ;;
*) *)
echo "SonarQube gate failed: ${sonar_status}" >&2 echo "sonarqube gate failed: ${sonar_status}" >&2
fail=1 fail=1
;; ;;
esac esac
fi fi
ironbank_required=0 ironbank_required="${QUALITY_GATE_IRONBANK_REQUIRED:-0}"
if enabled "${QUALITY_GATE_IRONBANK_REQUIRED:-0}"; then if [ "${PUBLISH_IMAGES:-false}" = "true" ]; then
ironbank_required=1 ironbank_required=1
fi fi
if enabled "${PUBLISH_IMAGES:-0}"; then if enabled "${QUALITY_GATE_IRONBANK_ENFORCE:-1}"; then
ironbank_required=1
fi
if enabled "${QUALITY_GATE_IRONBANK_ENFORCE:-1}" || [ "${ironbank_required}" -eq 1 ]; then
supply_status="$(python3 - <<'PY' supply_status="$(python3 - <<'PY'
import json import json
from pathlib import Path from pathlib import Path
@ -281,37 +278,36 @@ try:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
print("error") print("error")
raise SystemExit(0) raise SystemExit(0)
status = payload.get("status")
if isinstance(status, str) and status.strip():
print(status.strip().lower())
raise SystemExit(0)
compliant = payload.get("compliant") compliant = payload.get("compliant")
if isinstance(compliant, bool): if compliant is True:
print("ok" if compliant else "failed") print("ok")
raise SystemExit(0) elif compliant is False:
print("unknown") print("failed")
else:
status = str(payload.get("status") or payload.get("result") or payload.get("compliance") or "").strip().lower()
print(status or "missing")
PY PY
)" )"
case "${supply_status}" in case "${supply_status}" in
ok|pass|passed|success|compliant) ok|pass|passed|success|compliant) ;;
;; not_applicable|na|n/a)
not_applicable) if enabled "${ironbank_required}"; then
if [ "${ironbank_required}" -eq 1 ]; then echo "supply chain gate required but status=${supply_status}" >&2
echo "Supply-chain check is not applicable but required for this build" >&2
fail=1 fail=1
fi fi
;; ;;
*) *)
echo "Supply-chain check failed: ${supply_status}" >&2 if enabled "${ironbank_required}"; then
fail=1 echo "supply chain gate failed: ${supply_status}" >&2
fail=1
else
echo "supply chain gate not passing (${supply_status}) but not required for this run" >&2
fi
;; ;;
esac esac
fi fi
if [ "${fail}" -ne 0 ]; then exit "${fail}"
exit 1
fi
''' '''
} }
} }

View File

@ -1,26 +0,0 @@
/** @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": "jest --runInBand", "test": "vitest run",
"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" "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"
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
@ -21,14 +21,11 @@
"@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",
"jest": "^30.2.0", "@vitest/coverage-v8": "^3.2.4",
"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,64 +1,61 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App' import App from './App'
import { api } from './api' import { api } from './api'
jest.mock('./api', () => ({ vi.mock('./api', () => ({
api: jest.fn(), api: vi.fn(),
})) }))
jest.mock('./Uploader', () => function MockUploader() { vi.mock('./Uploader', () => ({
return <div data-testid="uploader">uploader</div> default: function MockUploader() {
}) return <div data-testid="uploader">uploader</div>
},
}))
jest.mock('./Login', () => function MockLogin({ onLogin }: { onLogin: () => void }) { vi.mock('./Login', () => ({
return ( default: function MockLogin({ onLogin }: { onLogin: () => void }) {
<button type="button" onClick={onLogin}> return (
mock-login <button type="button" onClick={onLogin}>
</button> mock-login
) </button>
}) )
},
}))
describe('App', () => { describe('App', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() vi.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 = jest.mocked(api) const apiMock = vi.mocked(api)
apiMock.mockResolvedValueOnce({ username: 'brad' } as never) apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
render(<App />) render(<App />)
expect(await screen.findByTestId('uploader')).toBeTruthy() expect(await screen.findByTestId('uploader')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Logout' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument()
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 = jest.mocked(api) const apiMock = vi.mocked(api)
apiMock.mockRejectedValueOnce(new Error('unauthorized')) apiMock.mockRejectedValueOnce(new Error('unauthorized'))
render(<App />) render(<App />)
expect(await screen.findByRole('button', { name: 'mock-login' })).toBeTruthy() expect(await screen.findByRole('button', { name: 'mock-login' })).toBeInTheDocument()
})
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 = jest.mocked(api) const apiMock = vi.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)
@ -69,5 +66,6 @@ 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,11 +17,7 @@ export default function App() {
try { try {
await api('/api/logout', { method: 'POST' }) await api('/api/logout', { method: 'POST' })
} finally { } finally {
try { location.reload()
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, jest } from '@jest/globals' import { describe, expect, it, vi } from 'vitest'
import Login from './Login' import Login from './Login'
import { api } from './api' import { api } from './api'
jest.mock('./api', () => ({ vi.mock('./api', () => ({
api: jest.fn(), api: vi.fn(),
})) }))
describe('Login', () => { describe('Login', () => {
it('submits credentials and calls onLogin', async () => { it('submits credentials and calls onLogin', async () => {
const apiMock = jest.mocked(api) const apiMock = vi.mocked(api)
apiMock.mockResolvedValue({ ok: true }) apiMock.mockResolvedValue({ ok: true })
const onLogin = jest.fn() const onLogin = vi.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 = jest.mocked(api) const apiMock = vi.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')).toBeTruthy() expect(await screen.findByText('invalid credentials')).toBeInTheDocument()
}) })
}) })

View File

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

View File

@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
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 jest.spyOn> let logSpy: ReturnType<typeof vi.spyOn>
beforeAll(() => { beforeAll(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
}) })
afterAll(() => { afterAll(() => {
@ -118,14 +118,11 @@ describe('Uploader helpers', () => {
await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/) await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/)
}) })
it('returns false when matchMedia is unavailable and normalizes non-array rows', () => { it('returns false without a window and normalizes non-array rows', () => {
const originalMatchMedia = window.matchMedia const originalWindow = window
Object.defineProperty(window, 'matchMedia', { vi.stubGlobal('window', undefined as any)
configurable: true,
value: undefined,
})
expect(isLikelyMobileUA()).toBe(false) expect(isLikelyMobileUA()).toBe(false)
Object.defineProperty(window, 'matchMedia', { configurable: true, value: originalMatchMedia }) vi.stubGlobal('window', originalWindow as any)
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, jest } from '@jest/globals' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const mockUseUploaderController = jest.fn() import UploaderView from './UploaderView'
jest.mock('./uploader-controller', () => ({ const controllerMock = vi.hoisted(() => ({
__esModule: true, useUploaderController: vi.fn(),
default: (...args: unknown[]) => mockUseUploaderController(...args),
})) }))
// Defer module evaluation until after mocks are registered. vi.mock('./uploader-controller', () => ({
const UploaderView = require('./UploaderView').default default: controllerMock.useUploaderController,
}))
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 = jest.fn() const setSel = vi.fn()
const setBulkDesc = jest.fn() const setBulkDesc = vi.fn()
const setGlobalDate = jest.fn() const setGlobalDate = vi.fn()
const setLib = jest.fn() const setLib = vi.fn()
const setSub = jest.fn() const setSub = vi.fn()
const setNewFolderRaw = jest.fn() const setNewFolderRaw = vi.fn()
const refresh = jest.fn() const refresh = vi.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: jest.fn(), handleChoose: vi.fn(),
applyDescToAllVideos: jest.fn(), applyDescToAllVideos: vi.fn(),
doUpload: jest.fn(), doUpload: vi.fn(),
createSubfolder: jest.fn(), createSubfolder: vi.fn(),
renameFolder: jest.fn(), renameFolder: vi.fn(),
deleteFolder: jest.fn(), deleteFolder: vi.fn(),
renamePath: jest.fn(), renamePath: vi.fn(),
deletePath: jest.fn(), deletePath: vi.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: jest.fn(() => 'blob:thumb'), configurable: true }) Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:thumb'), configurable: true })
} else { } else {
jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb') vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
} }
if (!('revokeObjectURL' in URL)) { if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', { value: jest.fn(), configurable: true }) Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true })
} else { } else {
jest.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
} }
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() vi.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()
mockUseUploaderController.mockReturnValue(controller) controllerMock.useUploaderController.mockReturnValue(controller)
render(<UploaderView />) render(<UploaderView />)
@ -103,12 +103,6 @@ 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' } })
@ -119,13 +113,9 @@ 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'))
@ -136,7 +126,6 @@ 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()
@ -146,32 +135,10 @@ describe('UploaderView', () => {
expect(screen.getByText('photo.jpg')).toBeTruthy() expect(screen.getByText('photo.jpg')).toBeTruthy()
expect(screen.getByText('clip.mp4')).toBeTruthy() expect(screen.getByText('clip.mp4')).toBeTruthy()
expect(screen.getByText('note.pdf')).toBeTruthy() expect(screen.getByText('note.pdf')).toBeTruthy()
}) }, 20000)
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', () => {
mockUseUploaderController.mockReturnValue( controllerMock.useUploaderController.mockReturnValue(
makeController({ makeController({
lib: '', lib: '',
sub: '', sub: '',
@ -191,7 +158,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', () => {
mockUseUploaderController.mockReturnValue( controllerMock.useUploaderController.mockReturnValue(
makeController({ makeController({
lib: 'alpha', lib: 'alpha',
sub: '', sub: '',

View File

@ -1,34 +1,19 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals' import { afterEach, describe, expect, it, vi } from 'vitest'
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(() => {
jest.restoreAllMocks() vi.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 = jest.fn(async (..._args: any[]) => makeResponse(JSON.stringify({ ok: true }), 200, 'application/json') as any) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock }) new Response(JSON.stringify({ ok: true }), {
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)
@ -36,24 +21,36 @@ 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 () => {
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('{"value":42}', 200, 'text/plain') as any) vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock }) new Response('{"value":42}', {
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 () => {
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('hello', 200, 'text/plain') as any) vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock }) new Response('hello', {
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 () => {
const fetchMock = jest.fn(async (..._args: any[]) => makeResponse('invalid credentials', 401, 'text/plain') as any) vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: fetchMock }) new Response('invalid credentials', {
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,40 +1,39 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockRender = jest.fn() const harness = vi.hoisted(() => {
const mockCreateRoot = jest.fn(() => ({ render: mockRender })) const renderMock = vi.fn()
const createRootMock = vi.fn(() => ({ render: renderMock }))
return { renderMock, createRootMock }
})
jest.mock('react-dom/client', () => ({ vi.mock('react-dom/client', () => ({
createRoot: mockCreateRoot, createRoot: harness.createRootMock,
})) }))
jest.mock('./App', () => function MockApp() { vi.mock('./App', () => ({
return null default: function MockApp() {
}) return null
},
}))
describe('main entrypoint', () => { describe('main entrypoint', () => {
beforeEach(() => { beforeEach(() => {
jest.resetModules() vi.resetModules()
document.body.innerHTML = '<div id="root"></div>' document.body.innerHTML = '<div id="root"></div>'
mockCreateRoot.mockClear() harness.createRootMock.mockClear()
mockRender.mockClear() harness.renderMock.mockClear()
}) })
it('mounts the app into #root', async () => { it('mounts the app into #root', async () => {
await jest.isolateModulesAsync(async () => { await import('./main')
await import('./main')
})
expect(mockCreateRoot).toHaveBeenCalled() expect(harness.createRootMock).toHaveBeenCalled()
expect(mockRender).toHaveBeenCalled() expect(harness.renderMock).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( await expect(import('./main')).rejects.toThrow('Missing <div id="root"></div> in index.html')
jest.isolateModulesAsync(async () => {
await import('./main')
}),
).rejects.toThrow('Missing <div id="root"></div> in index.html')
}) })
}) })

View File

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

View File

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

View File

@ -1,90 +1,73 @@
import { act, render, renderHook, waitFor } from '@testing-library/react' import { act, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockApi = jest.fn() as jest.MockedFunction<(path: string, init?: RequestInit) => Promise<unknown>> const harness = vi.hoisted(() => {
const mockUploadState = { const apiMock = vi.fn()
mode: 'success' as 'success' | 'error', const uploadState = {
dispatchBeforeUnload: false, mode: 'success' as 'success' | 'error',
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined, dispatchBeforeUnload: false,
pauseUpload: false, lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
finishUpload: undefined as (() => void) | 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
} }
start() { class UploadMock {
if (mockUploadState.mode === 'error') { opts: any
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } }) file: File
return
constructor(file: File, opts: any) {
this.file = file
this.opts = opts
} }
if (mockUploadState.pauseUpload) {
mockUploadState.finishUpload = () => { start() {
this.opts.onProgress?.(5, 10) if (uploadState.mode === 'error') {
this.opts.onSuccess?.() this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
return
} }
return 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?.()
} }
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?.()
} }
}
jest.mock('./api', () => ({ return { apiMock, uploadState, UploadMock }
api: mockApi, })
vi.mock('./api', () => ({
api: harness.apiMock,
})) }))
jest.mock('tus-js-client', () => ({ vi.mock('tus-js-client', () => ({
Upload: mockTusUpload, Upload: harness.UploadMock,
})) }))
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() {
Object.defineProperty(globalThis, 'alert', { configurable: true, value: jest.fn() }) vi.stubGlobal('alert', vi.fn())
Object.defineProperty(globalThis, 'confirm', { configurable: true, value: jest.fn(() => true) }) vi.stubGlobal('confirm', vi.fn(() => true))
Object.defineProperty(globalThis, 'prompt', { configurable: true, value: jest.fn(() => 'renamed') }) vi.stubGlobal('prompt', vi.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() {
mockApi.mockImplementation(async (path: string) => { harness.apiMock.mockImplementation(async (path: string) => {
if (path === '/api/whoami') { if (path === '/api/whoami') {
return { username: 'brad', roots: ['alpha', 'beta'] } return { username: 'brad', roots: ['alpha', 'beta'] }
} }
@ -110,24 +93,23 @@ function installApi() {
} }
beforeEach(() => { beforeEach(() => {
mockApi.mockReset() harness.apiMock.mockReset()
mockUploadState.mode = 'success' harness.uploadState.mode = 'success'
mockUploadState.dispatchBeforeUnload = false harness.uploadState.dispatchBeforeUnload = false
mockUploadState.lastBeforeUnloadEvent = undefined harness.uploadState.lastBeforeUnloadEvent = undefined
mockUploadState.pauseUpload = false harness.uploadState.pauseUpload = false
mockUploadState.finishUpload = undefined harness.uploadState.finishUpload = undefined
installGlobals() installGlobals()
installApi() installApi()
}) })
afterEach(() => { afterEach(() => {
jest.restoreAllMocks() vi.unstubAllGlobals()
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 () => {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'iPhone' } }) vi.stubGlobal('navigator', { userAgent: 'iPhone' } as any)
function Harness() { function Harness() {
const controller = useUploaderController() const controller = useUploaderController()
@ -236,15 +218,15 @@ describe('useUploaderController', () => {
await result.current.doUpload() await result.current.doUpload()
}) })
expect(mockApi).toHaveBeenCalledWith('/api/mkdir', expect.any(Object)) expect(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
expect(mockApi).toHaveBeenCalledWith('/api/rename', expect.any(Object)) expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object))
expect(mockApi).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object)) expect(harness.apiMock).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 () => {
mockUploadState.mode = 'error' harness.uploadState.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']))
@ -263,35 +245,35 @@ describe('useUploaderController', () => {
await waitFor(() => expect(result.current.sel).toHaveLength(1)) await waitFor(() => expect(result.current.sel).toHaveLength(1))
mockApi.mockImplementationOnce(async () => { harness.apiMock.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')
}) })
mockApi.mockImplementationOnce(async () => { harness.apiMock.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')
}) })
mockApi.mockImplementationOnce(async () => { harness.apiMock.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')
}) })
mockApi.mockImplementationOnce(async () => { harness.apiMock.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')
}) })
mockApi.mockImplementationOnce(async () => { harness.apiMock.mockImplementationOnce(async () => {
throw new Error('delete failed') throw new Error('delete failed')
}) })
await act(async () => { await act(async () => {
@ -306,7 +288,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 () => {
mockApi.mockImplementation(async (path: string) => { harness.apiMock.mockImplementation(async (path: string) => {
if (path === '/api/whoami') { if (path === '/api/whoami') {
throw new Error('no mapping found') throw new Error('no mapping found')
} }
@ -325,11 +307,12 @@ 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(mockApi).toHaveBeenCalledWith('/api/logout', { method: 'POST' }) expect((globalThis as any).location.replace).toHaveBeenCalledWith('/')
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 () => {
mockUploadState.pauseUpload = true harness.uploadState.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']))
@ -347,7 +330,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 = jest.spyOn(event, 'preventDefault') const preventDefault = vi.spyOn(event, 'preventDefault')
await act(async () => { await act(async () => {
window.dispatchEvent(event) window.dispatchEvent(event)
}) })
@ -356,7 +339,7 @@ describe('useUploaderController', () => {
expect(event.defaultPrevented).toBe(true) expect(event.defaultPrevented).toBe(true)
await act(async () => { await act(async () => {
mockUploadState.finishUpload?.() harness.uploadState.finishUpload?.()
await uploadPromise await uploadPromise
}) })

View File

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

View File

@ -1,12 +0,0 @@
{
"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", "jest", "@testing-library/jest-dom", "node"] "types": ["vite/client"]
}, },
"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 'vite' import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
@ -8,4 +8,11 @@ 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'],
},
}) })

View File

@ -74,9 +74,9 @@ def _load_junit(path: Path) -> dict[str, int]:
def _load_junit_cases(path: Path) -> list[tuple[str, str]]: def _load_junit_cases(path: Path) -> list[tuple[str, str]]:
"""Return per-test outcomes from a JUnit report."""
if not path.exists(): if not path.exists():
return [] return []
tree = ET.parse(path) tree = ET.parse(path)
root = tree.getroot() root = tree.getroot()
suites: list[ET.Element] suites: list[ET.Element]
@ -356,14 +356,23 @@ def main() -> int:
"# TYPE pegasus_quality_gate_build_info gauge", "# TYPE pegasus_quality_gate_build_info gauge",
f"pegasus_quality_gate_build_info{_label_str(labels)} 1", f"pegasus_quality_gate_build_info{_label_str(labels)} 1",
] ]
payload_lines.extend(
f'platform_quality_gate_test_case_result{{suite="{suite}",test="{_escape_label(test_name)}",status="{_escape_label(test_status)}"}} 1'
for test_name, test_status in test_cases
)
payload_lines.extend( payload_lines.extend(
f'pegasus_quality_gate_checks_total{{suite="{suite}",check="{check_name}",result="{check_status}"}} 1' f'pegasus_quality_gate_checks_total{{suite="{suite}",check="{check_name}",result="{check_status}"}} 1'
for check_name, check_status in checks.items() for check_name, check_status in checks.items()
) )
if test_cases:
payload_lines.extend(
[
*[
f'platform_quality_gate_test_case_result{{suite="{suite}",test="{_escape_label(test_name)}",status="{_escape_label(test_status)}"}} 1'
for test_name, test_status in test_cases
],
]
)
else:
payload_lines.append(
f'platform_quality_gate_test_case_result{{suite="{suite}",test="__no_test_cases__",status="skipped"}} 1'
)
payload = "\n".join(payload_lines) + "\n" payload = "\n".join(payload_lines) + "\n"
push_url = f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}" push_url = f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}"