Compare commits
No commits in common. "main" and "codex/pegasus-platform-gate-metrics" have entirely different histories.
main
...
codex/pega
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
279
Jenkinsfile
vendored
@ -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
|
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/**',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
4446
frontend/package-lock.json
generated
4446
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@
|
|||||||
"dev": "vite",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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'
|
||||||
|
|
||||||
|
|||||||
@ -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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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: '',
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export {}
|
|
||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user