Compare commits

..

No commits in common. "main" and "codex/pegasus-platform-gate-metrics" have entirely different histories.

22 changed files with 763 additions and 4543 deletions

1
.gitignore vendored
View File

@ -1,6 +1,5 @@
node_modules node_modules
frontend/node_modules frontend/node_modules
frontend/junit.xml
frontend/dist frontend/dist
build/ build/
backend/web/dist/* backend/web/dist/*

279
Jenkinsfile vendored
View File

@ -1,6 +1,7 @@
pipeline { pipeline {
agent { agent {
kubernetes { kubernetes {
label 'pegasus-tests'
defaultContainer 'go-tester' defaultContainer 'go-tester'
yaml """ yaml """
apiVersion: v1 apiVersion: v1
@ -11,28 +12,21 @@ spec:
node-role.kubernetes.io/worker: "true" node-role.kubernetes.io/worker: "true"
containers: containers:
- name: go-tester - name: go-tester
image: registry.bstein.dev/bstein/golang:1.22-bookworm image: golang:1.22-bookworm
command: ["cat"] command: ["cat"]
tty: true tty: true
volumeMounts: volumeMounts:
- name: workspace-volume - name: workspace-volume
mountPath: /home/jenkins/agent mountPath: /home/jenkins/agent
- name: node-tester - name: node-tester
image: registry.bstein.dev/bstein/node:20-bookworm image: node:20-bookworm
command: ["cat"] command: ["cat"]
tty: true tty: true
volumeMounts: volumeMounts:
- name: workspace-volume - name: workspace-volume
mountPath: /home/jenkins/agent mountPath: /home/jenkins/agent
- name: publisher - name: publisher
image: registry.bstein.dev/bstein/python:3.12-slim image: python:3.12-slim
command: ["cat"]
tty: true
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
- name: quality-tools
image: registry.bstein.dev/bstein/quality-tools:sonar8.0.1-trivy0.70.0-db20260422-arm64
command: ["cat"] command: ["cat"]
tty: true tty: true
volumeMounts: volumeMounts:
@ -48,19 +42,12 @@ spec:
environment { environment {
SUITE_NAME = 'pegasus' SUITE_NAME = 'pegasus'
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
SONARQUBE_HOST_URL = 'http://sonarqube.quality.svc.cluster.local:9000'
SONARQUBE_PROJECT_KEY = 'pegasus'
SONARQUBE_TOKEN = credentials('sonarqube-token')
QUALITY_GATE_SONARQUBE_ENFORCE = '1'
QUALITY_GATE_SONARQUBE_REPORT = 'build/sonarqube-quality-gate.json' QUALITY_GATE_SONARQUBE_REPORT = 'build/sonarqube-quality-gate.json'
QUALITY_GATE_IRONBANK_ENFORCE = '1'
QUALITY_GATE_IRONBANK_REQUIRED = '0'
QUALITY_GATE_IRONBANK_REPORT = 'build/ironbank-compliance.json' QUALITY_GATE_IRONBANK_REPORT = 'build/ironbank-compliance.json'
} }
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '200', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '120'))
} }
triggers { triggers {
@ -76,28 +63,6 @@ spec:
stage('Collect SonarQube evidence') { stage('Collect SonarQube evidence') {
steps { steps {
container('quality-tools') {
sh '''#!/usr/bin/env bash
set -euo pipefail
mkdir -p build
args=(
"-Dsonar.host.url=${SONARQUBE_HOST_URL}"
"-Dsonar.login=${SONARQUBE_TOKEN}"
"-Dsonar.projectKey=${SONARQUBE_PROJECT_KEY}"
"-Dsonar.projectName=${SONARQUBE_PROJECT_KEY}"
"-Dsonar.sources=."
"-Dsonar.exclusions=**/.git/**,**/build/**,**/dist/**,**/node_modules/**,**/.venv/**,**/__pycache__/**,**/coverage/**,**/test-results/**,**/playwright-report/**"
"-Dsonar.test.inclusions=**/tests/**,**/testing/**,**/*_test.go,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx"
)
[ -f build/coverage-backend.out ] && args+=("-Dsonar.go.coverage.reportPaths=build/coverage-backend.out")
[ -f build/frontend-coverage/lcov.info ] && args+=("-Dsonar.javascript.lcov.reportPaths=build/frontend-coverage/lcov.info")
set +e
sonar-scanner "${args[@]}" | tee build/sonar-scanner.log
rc=${PIPESTATUS[0]}
set -e
printf '%s\n' "${rc}" > build/sonarqube-analysis.rc
'''
}
container('publisher') { container('publisher') {
sh ''' sh '''
set -eu set -eu
@ -136,34 +101,6 @@ PY
stage('Collect Supply Chain evidence') { stage('Collect Supply Chain evidence') {
steps { steps {
container('quality-tools') {
sh '''#!/usr/bin/env bash
set -euo pipefail
mkdir -p build
set +e
trivy fs --cache-dir "${TRIVY_CACHE_DIR}" --skip-db-update --timeout 5m --no-progress --format json --output build/trivy-fs.json --scanners vuln,secret,misconfig --severity HIGH,CRITICAL .
trivy_rc=$?
set -e
if [ ! -s build/trivy-fs.json ]; then
cat > build/ironbank-compliance.json <<EOF
{"status":"failed","compliant":false,"scanner":"trivy","scan_type":"filesystem","error":"trivy did not produce JSON output","trivy_rc":${trivy_rc}}
EOF
exit 0
fi
critical="$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' build/trivy-fs.json)"
high="$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity=="HIGH")] | length' build/trivy-fs.json)"
secrets="$(jq '[.Results[]? | .Secrets[]?] | length' build/trivy-fs.json)"
misconfigs="$(jq '[.Results[]? | .Misconfigurations[]? | select(.Status=="FAIL" and (.Severity=="CRITICAL" or .Severity=="HIGH"))] | length' build/trivy-fs.json)"
status=ok
compliant=true
if [ "${critical}" -gt 0 ] || [ "${secrets}" -gt 0 ] || [ "${misconfigs}" -gt 0 ]; then
status=failed
compliant=false
fi
jq -n --arg status "${status}" --argjson compliant "${compliant}" --argjson critical "${critical}" --argjson high "${high}" --argjson secrets "${secrets}" --argjson misconfigs "${misconfigs}" --argjson trivy_rc "${trivy_rc}" \
'{status:$status, compliant:$compliant, category:"artifact_security", scan_type:"filesystem", scanner:"trivy", critical_vulnerabilities:$critical, high_vulnerabilities:$high, secrets:$secrets, high_or_critical_misconfigurations:$misconfigs, trivy_rc:$trivy_rc, high_vulnerability_policy:"observe"}' > build/ironbank-compliance.json
'''
}
container('publisher') { container('publisher') {
sh ''' sh '''
set -eu set -eu
@ -200,46 +137,13 @@ PY
export PEGASUS_COOKIE_INSECURE=1 export PEGASUS_COOKIE_INSECURE=1
mkdir -p build mkdir -p build
cd backend cd backend
export GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}" go install github.com/jstemmer/go-junit-report/v2@latest
retry_command() {
attempts=4
delay=8
attempt=1
while [ "${attempt}" -le "${attempts}" ]; do
"$@"
rc=$?
if [ "${rc}" -eq 0 ]; then
return 0
fi
if [ "${attempt}" -eq "${attempts}" ]; then
return "${rc}"
fi
echo "command failed with rc=${rc}; retrying in ${delay}s (${attempt}/${attempts})"
sleep "${delay}"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
}
set +e set +e
retry_command go install github.com/jstemmer/go-junit-report/v2@latest go test -coverprofile=../build/coverage-backend.out ./... > ../build/backend-test.out 2>&1
tool_rc=$?
if [ "${tool_rc}" -eq 0 ]; then
retry_command go test -v -coverprofile=../build/coverage-backend.out ./... > ../build/backend-test.out 2>&1
test_rc=$? test_rc=$?
else
test_rc=1
printf 'go-junit-report install failed with rc=%s; skipping backend go test so metrics can publish\\n' "${tool_rc}" > ../build/backend-test.out
fi
set -e set -e
cat ../build/backend-test.out cat ../build/backend-test.out
if [ "${tool_rc}" -eq 0 ] && [ -x "$(go env GOPATH)/bin/go-junit-report" ]; then
"$(go env GOPATH)/bin/go-junit-report" < ../build/backend-test.out > ../build/junit-backend.xml "$(go env GOPATH)/bin/go-junit-report" < ../build/backend-test.out > ../build/junit-backend.xml
else
cat > ../build/junit-backend.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="0" failures="0" errors="0" skipped="0"></testsuites>
EOF
fi
coverage="0" coverage="0"
if [ -f ../build/coverage-backend.out ]; then if [ -f ../build/coverage-backend.out ]; then
coverage="$(go tool cover -func=../build/coverage-backend.out | awk '/^total:/ {gsub("%","",$3); print $3}')" coverage="$(go tool cover -func=../build/coverage-backend.out | awk '/^total:/ {gsub("%","",$3); print $3}')"
@ -258,43 +162,12 @@ EOF
set -eu set -eu
mkdir -p build mkdir -p build
cd frontend cd frontend
retry_command() { npm ci
attempts=4
delay=8
attempt=1
while [ "${attempt}" -le "${attempts}" ]; do
"$@"
rc=$?
if [ "${rc}" -eq 0 ]; then
return 0
fi
if [ "${attempt}" -eq "${attempts}" ]; then
return "${rc}"
fi
echo "command failed with rc=${rc}; retrying in ${delay}s (${attempt}/${attempts})"
sleep "${delay}"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
}
set +e set +e
retry_command npm ci npm run test:ci > ../build/frontend-test.out 2>&1
npm_ci_rc=$?
if [ "${npm_ci_rc}" -eq 0 ]; then
retry_command npm run test:ci > ../build/frontend-test.out 2>&1
test_rc=$? test_rc=$?
else
test_rc=1
printf 'npm ci failed with rc=%s; skipping frontend tests so metrics can publish\\n' "${npm_ci_rc}" > ../build/frontend-test.out
fi
set -e set -e
cat ../build/frontend-test.out cat ../build/frontend-test.out
if [ ! -f ../build/junit-frontend.xml ]; then
cat > ../build/junit-frontend.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="0" failures="0" errors="0" skipped="0"></testsuites>
EOF
fi
if [ -f ../build/frontend-coverage/coverage-summary.json ]; then if [ -f ../build/frontend-coverage/coverage-summary.json ]; then
node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("../build/frontend-coverage/coverage-summary.json","utf8"));const pct=((p.total||{}).lines||{}).pct||0;process.stdout.write(String(pct));' > ../build/coverage-frontend-percent.txt node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("../build/frontend-coverage/coverage-summary.json","utf8"));const pct=((p.total||{}).lines||{}).pct||0;process.stdout.write(String(pct));' > ../build/coverage-frontend-percent.txt
else else
@ -311,50 +184,9 @@ EOF
container('publisher') { container('publisher') {
sh ''' sh '''
set -eu set -eu
mkdir -p build
set +e
apt-get update apt-get update
apt_rc=$?
if [ "${apt_rc}" -eq 0 ]; then
apt-get install -y --no-install-recommends golang-go nodejs npm apt-get install -y --no-install-recommends golang-go nodejs npm
apt_rc=$?
fi
if [ "${apt_rc}" -eq 0 ]; then
python -m testing.pegasus_gate report python -m testing.pegasus_gate report
gate_rc=$?
else
gate_rc="${apt_rc}"
fi
set -e
if [ ! -f build/gate-summary.json ]; then
python3 - <<'PY'
import json
from pathlib import Path
Path("build/gate-summary.json").write_text(
json.dumps(
{
"ok": False,
"issues": [
{
"check": "gate_glue",
"path": "Jenkinsfile",
"detail": "quality gate dependencies or report command failed before summary generation",
}
],
"file_count": 0,
"backend_coverage": {},
"frontend_coverage": {},
},
indent=2,
sort_keys=True,
)
+ "\\n",
encoding="utf-8",
)
PY
fi
printf '%s\n' "${gate_rc}" > build/quality-report.rc
''' '''
} }
} }
@ -378,102 +210,7 @@ PY
set -eu set -eu
apt-get update apt-get update
apt-get install -y --no-install-recommends golang-go nodejs npm apt-get install -y --no-install-recommends golang-go nodejs npm
set +e
python -m testing.pegasus_gate enforce python -m testing.pegasus_gate enforce
gate_rc=$?
set -e
fail=0
if [ "${gate_rc}" -ne 0 ]; then
echo "quality gate failed with rc=${gate_rc}" >&2
fail=1
fi
enabled() {
case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
if enabled "${QUALITY_GATE_SONARQUBE_ENFORCE:-1}"; then
sonar_status="$(python3 - <<'PY'
import json
from pathlib import Path
path = Path("build/sonarqube-quality-gate.json")
if not path.exists():
print("missing")
raise SystemExit(0)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
print("error")
raise SystemExit(0)
status = (payload.get("status") or payload.get("projectStatus", {}).get("status") or payload.get("qualityGate", {}).get("status") or "").strip().lower()
print(status or "missing")
PY
)"
case "${sonar_status}" in
ok|pass|passed|success) ;;
*)
echo "SonarQube gate failed: ${sonar_status}" >&2
fail=1
;;
esac
fi
ironbank_required=0
if enabled "${QUALITY_GATE_IRONBANK_REQUIRED:-0}"; then
ironbank_required=1
fi
if enabled "${PUBLISH_IMAGES:-0}"; then
ironbank_required=1
fi
if enabled "${QUALITY_GATE_IRONBANK_ENFORCE:-1}" || [ "${ironbank_required}" -eq 1 ]; then
supply_status="$(python3 - <<'PY'
import json
from pathlib import Path
path = Path("build/ironbank-compliance.json")
if not path.exists():
print("missing")
raise SystemExit(0)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
print("error")
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")
if isinstance(compliant, bool):
print("ok" if compliant else "failed")
raise SystemExit(0)
print("unknown")
PY
)"
case "${supply_status}" in
ok|pass|passed|success|compliant)
;;
not_applicable)
if [ "${ironbank_required}" -eq 1 ]; then
echo "Supply-chain check is not applicable but required for this build" >&2
fail=1
fi
;;
*)
echo "Supply-chain check failed: ${supply_status}" >&2
fail=1
;;
esac
fi
if [ "${fail}" -ne 0 ]; then
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_FILE=../build/junit-frontend.xml jest --ci --runInBand --coverage --coverageReporters=text --coverageReporters=lcov --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', () => ({
default: function MockUploader() {
return <div data-testid="uploader">uploader</div> return <div data-testid="uploader">uploader</div>
}) },
}))
jest.mock('./Login', () => function MockLogin({ onLogin }: { onLogin: () => void }) { vi.mock('./Login', () => ({
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(() => {
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()
}, 15000)
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', () => ({
default: function MockApp() {
return null 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,16 +1,17 @@
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()
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 { class UploadMock {
opts: any opts: any
file: File file: File
@ -20,71 +21,53 @@ const mockTusUpload = class MockTusUpload {
} }
start() { start() {
if (mockUploadState.mode === 'error') { if (uploadState.mode === 'error') {
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } }) this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
return return
} }
if (mockUploadState.pauseUpload) { if (uploadState.pauseUpload) {
mockUploadState.finishUpload = () => { uploadState.finishUpload = () => {
this.opts.onProgress?.(5, 10) this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.() this.opts.onSuccess?.()
} }
return return
} }
if (mockUploadState.dispatchBeforeUnload) { if (uploadState.dispatchBeforeUnload) {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
mockUploadState.lastBeforeUnloadEvent = event uploadState.lastBeforeUnloadEvent = event
window.dispatchEvent(event) window.dispatchEvent(event)
} }
this.opts.onProgress?.(5, 10) this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.() 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

@ -25,7 +25,6 @@ from pathlib import Path
SOURCE_SCAN_ROOTS = ("backend", "frontend/src", "scripts", "testing") SOURCE_SCAN_ROOTS = ("backend", "frontend/src", "scripts", "testing")
SOURCE_EXTENSIONS = {".go", ".py", ".ts", ".tsx", ".sh"} SOURCE_EXTENSIONS = {".go", ".py", ".ts", ".tsx", ".sh"}
QUALITY_SUCCESS_STATES = {"ok", "pass", "passed", "success", "compliant"} QUALITY_SUCCESS_STATES = {"ok", "pass", "passed", "success", "compliant"}
STYLE_ISSUE_CHECKS = {"go-doc", "ts-doc", "go-vet", "tsc", "docs", "naming", "docs_naming", "hygiene", "lint"}
def _escape_label(value: str) -> str: def _escape_label(value: str) -> str:
@ -198,8 +197,6 @@ def _count_source_files_over_limit(repo_root: Path, max_lines: int = 500) -> int
continue continue
if path.suffix not in SOURCE_EXTENSIONS: if path.suffix not in SOURCE_EXTENSIONS:
continue continue
if path.name.endswith("_test.go") or path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"):
continue
lines = len(path.read_text(encoding="utf-8", errors="ignore").splitlines()) lines = len(path.read_text(encoding="utf-8", errors="ignore").splitlines())
if lines > max_lines: if lines > max_lines:
count += 1 count += 1
@ -266,8 +263,6 @@ def main() -> int:
b = _load_junit(backend_junit) b = _load_junit(backend_junit)
f = _load_junit(frontend_junit) f = _load_junit(frontend_junit)
test_cases = _load_junit_cases(backend_junit) + _load_junit_cases(frontend_junit) test_cases = _load_junit_cases(backend_junit) + _load_junit_cases(frontend_junit)
if not test_cases:
test_cases = [("__no_test_cases__", "skipped")]
totals = { totals = {
"tests": b["tests"] + f["tests"], "tests": b["tests"] + f["tests"],
"failures": b["failures"] + f["failures"], "failures": b["failures"] + f["failures"],
@ -284,47 +279,34 @@ def main() -> int:
frontend_rc = _read_test_exit_code(frontend_rc_file) frontend_rc = _read_test_exit_code(frontend_rc_file)
backend_suite_result = "passed" if backend_rc == 0 else "failed" backend_suite_result = "passed" if backend_rc == 0 else "failed"
frontend_suite_result = "passed" if frontend_rc == 0 else "failed" frontend_suite_result = "passed" if frontend_rc == 0 else "failed"
branch = os.getenv("BRANCH_NAME") or os.getenv("GIT_BRANCH") or "unknown" branch = os.getenv("BRANCH_NAME", "")
if branch.startswith("origin/"):
branch = branch[len("origin/") :]
build_number = os.getenv("BUILD_NUMBER", "") build_number = os.getenv("BUILD_NUMBER", "")
jenkins_job = os.getenv("JOB_NAME", "pegasus")
commit = os.getenv("GIT_COMMIT", "") commit = os.getenv("GIT_COMMIT", "")
labels = { labels = {
"suite": suite, "suite": suite,
"branch": branch, "branch": branch,
"build_number": build_number, "build_number": build_number,
"jenkins_job": jenkins_job,
"commit": commit, "commit": commit,
} }
test_case_base_labels = {
"suite": suite,
"branch": branch,
"build_number": build_number or "unknown",
"jenkins_job": jenkins_job,
}
gate_ok = bool(gate_summary.get("ok")) gate_ok = bool(gate_summary.get("ok"))
gate_issues = gate_summary.get("issues") or [] gate_issues = gate_summary.get("issues") or []
source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500) source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500)
issue_checks = { outcome = (
str(issue.get("check") or "").strip().lower() "ok"
for issue in gate_issues if gate_ok
if isinstance(issue, dict) and backend_rc == 0
}
tests_ok = (
backend_rc == 0
and frontend_rc == 0 and frontend_rc == 0
and totals["tests"] > 0 and totals["tests"] > 0
and totals["failures"] == 0 and totals["failures"] == 0
and totals["errors"] == 0 and totals["errors"] == 0
else "failed"
) )
outcome = "ok" if gate_ok and tests_ok else "failed"
checks = { checks = {
"tests": "ok" if tests_ok else "failed", "tests": "ok" if outcome == "ok" else "failed",
"coverage": "ok" if coverage_pct >= 95.0 and "coverage" not in issue_checks else "failed", "coverage": "ok" if coverage_pct >= 95.0 else "failed",
"loc": "ok" if source_lines_over_500 == 0 and "loc" not in issue_checks else "failed", "loc": "ok" if source_lines_over_500 == 0 else "failed",
"docs_naming": "ok" if not (issue_checks & STYLE_ISSUE_CHECKS) else "failed", "docs_naming": "ok" if not gate_issues else "failed",
"gate_glue": "ok", "gate_glue": "ok",
"sonarqube": _sonarqube_check_status(build_dir), "sonarqube": _sonarqube_check_status(build_dir),
"supply_chain": _supply_chain_check_status(build_dir), "supply_chain": _supply_chain_check_status(build_dir),
@ -369,15 +351,13 @@ def main() -> int:
f'pegasus_quality_gate_status{{suite="{suite}",result="{"ok" if gate_ok else "failed"}"}} 1', f'pegasus_quality_gate_status{{suite="{suite}",result="{"ok" if gate_ok else "failed"}"}} 1',
"# TYPE pegasus_quality_gate_issues_total gauge", "# TYPE pegasus_quality_gate_issues_total gauge",
f'pegasus_quality_gate_issues_total{{suite="{suite}"}} {len(gate_issues)}', f'pegasus_quality_gate_issues_total{{suite="{suite}"}} {len(gate_issues)}',
"# TYPE platform_quality_gate_build_info gauge",
f"platform_quality_gate_build_info{_label_str(labels)} 1",
"# TYPE pegasus_quality_gate_checks_total gauge", "# TYPE pegasus_quality_gate_checks_total gauge",
"# TYPE platform_quality_gate_test_case_result gauge", "# TYPE platform_quality_gate_test_case_result gauge",
"# 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( payload_lines.extend(
f"platform_quality_gate_test_case_result{_label_str({**test_case_base_labels, 'test': test_name, 'status': test_status})} 1" 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 for test_name, test_status in test_cases
) )
payload_lines.extend( payload_lines.extend(

View File

@ -220,9 +220,9 @@ def _check_coverage(files: Iterable[Path]) -> list[GateIssue]:
def evaluate() -> GateReport: def evaluate() -> GateReport:
files = _production_files() files = _production_files()
issues = [] issues = []
issues.extend(_check_loc(files))
issues.extend(_go_exported_comment_issues(files)) issues.extend(_go_exported_comment_issues(files))
issues.extend(_ts_export_comment_issues(files)) issues.extend(_ts_export_comment_issues(files))
issues.extend(_check_loc(files))
issues.extend(_go_vet()) issues.extend(_go_vet())
issues.extend(_tsc_check()) issues.extend(_tsc_check())
issues.extend(_check_coverage(files)) issues.extend(_check_coverage(files))