diff --git a/.gitignore b/.gitignore index 3906925..bc7fcc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules frontend/node_modules frontend/dist -backend/web/dist +build/ +backend/web/dist/* +!backend/web/dist/index.html .git diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..4aec7f4 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,195 @@ +pipeline { + agent { + kubernetes { + label 'pegasus-tests' + defaultContainer 'go-tester' + yaml """ +apiVersion: v1 +kind: Pod +spec: + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + containers: + - name: go-tester + image: golang:1.22-bookworm + command: ["cat"] + tty: true + volumeMounts: + - name: workspace-volume + mountPath: /home/jenkins/agent + - name: node-tester + image: node:20-bookworm + command: ["cat"] + tty: true + volumeMounts: + - name: workspace-volume + mountPath: /home/jenkins/agent + - name: publisher + image: python:3.12-slim + command: ["cat"] + tty: true + volumeMounts: + - name: workspace-volume + mountPath: /home/jenkins/agent + volumes: + - name: workspace-volume + emptyDir: {} +""" + } + } + + environment { + SUITE_NAME = 'pegasus' + PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' + } + + options { + disableConcurrentBuilds() + } + + triggers { + pollSCM('H/5 * * * *') + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Backend unit tests') { + steps { + container('go-tester') { + sh ''' + set -euo pipefail + mkdir -p build + cd backend + go install github.com/jstemmer/go-junit-report/v2@latest + set +e + go test -coverprofile=../build/coverage-backend.out ./... > ../build/backend-test.out 2>&1 + test_rc=$? + set -e + cat ../build/backend-test.out + "$(go env GOPATH)/bin/go-junit-report" < ../build/backend-test.out > ../build/junit-backend.xml + coverage="0" + if [ -f ../build/coverage-backend.out ]; then + coverage="$(go tool cover -func=../build/coverage-backend.out | awk '/^total:/ {gsub("%","",$3); print $3}')" + fi + printf '%s\n' "${coverage}" > ../build/coverage-backend-percent.txt + printf '%s\n' "${test_rc}" > ../build/backend-test.rc + ''' + } + } + } + + stage('Frontend unit tests') { + steps { + container('node-tester') { + sh ''' + set -euo pipefail + mkdir -p build + cd frontend + npm ci + set +e + npm run test:ci > ../build/frontend-test.out 2>&1 + test_rc=$? + set -e + cat ../build/frontend-test.out + 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 + else + echo "0" > ../build/coverage-frontend-percent.txt + fi + printf '%s\n' "${test_rc}" > ../build/frontend-test.rc + ''' + } + } + } + + stage('Combine coverage summary') { + steps { + container('publisher') { + sh ''' + set -euo pipefail + python - <<'PY' +import json +from pathlib import Path + +build = Path('build') +def read_float(path: Path) -> float: + if not path.exists(): + return 0.0 + try: + return float(path.read_text(encoding='utf-8').strip()) + except ValueError: + return 0.0 + +backend = read_float(build / 'coverage-backend-percent.txt') +frontend = read_float(build / 'coverage-frontend-percent.txt') +combined = (backend + frontend) / 2 if (backend or frontend) else 0.0 + +(build / 'coverage.json').write_text( + json.dumps( + { + 'summary': { + 'backend_percent': backend, + 'frontend_percent': frontend, + 'percent_covered': combined, + } + }, + indent=2, + ) + '\n', + encoding='utf-8', +) +print(f'Combined Pegasus coverage: {combined:.2f}%') +PY + ''' + } + } + } + + stage('Publish test metrics') { + steps { + container('publisher') { + sh ''' + set -euo pipefail + python scripts/publish_test_metrics.py + ''' + } + } + } + + stage('Quality gate') { + steps { + container('publisher') { + sh ''' + set -euo pipefail + backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)" + frontend_rc="$(cat build/frontend-test.rc 2>/dev/null || echo 1)" + if [ "${backend_rc}" -ne 0 ] || [ "${frontend_rc}" -ne 0 ]; then + echo "Pegasus test suite failed (backend=${backend_rc}, frontend=${frontend_rc})" + exit 1 + fi + ''' + } + } + } + } + + post { + always { + script { + if (fileExists('build/junit-backend.xml') || fileExists('build/junit-frontend.xml')) { + try { + junit allowEmptyResults: true, testResults: 'build/junit-backend.xml,build/junit-frontend.xml' + } catch (Throwable err) { + echo "junit step unavailable: ${err.class.simpleName}" + } + } + } + archiveArtifacts artifacts: 'build/**', allowEmptyArchive: true, fingerprint: true + } + } +} diff --git a/backend/internal/auth.go b/backend/internal/auth.go index ea11a8c..d968b32 100644 --- a/backend/internal/auth.go +++ b/backend/internal/auth.go @@ -10,9 +10,13 @@ import ( func CurrentUser(r *http.Request) (Claims, error) { c, err := r.Cookie(CookieName) - if err != nil { return Claims{}, err } + if err != nil { + return Claims{}, err + } tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil }) - if err != nil { return Claims{}, err } + if err != nil { + return Claims{}, err + } if cl, ok := tok.Claims.(*Claims); ok && tok.Valid { return *cl, nil } diff --git a/backend/internal/auth_test.go b/backend/internal/auth_test.go new file mode 100644 index 0000000..938cf11 --- /dev/null +++ b/backend/internal/auth_test.go @@ -0,0 +1,100 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func issueSessionCookie(t *testing.T, username, jfToken string) *http.Cookie { + t.Helper() + + origKey := sessionKey + sessionKey = []byte("pegasus-test-session-key") + t.Cleanup(func() { + sessionKey = origKey + }) + + rr := httptest.NewRecorder() + if err := SetSession(rr, username, jfToken); err != nil { + t.Fatalf("SetSession failed: %v", err) + } + + res := rr.Result() + defer res.Body.Close() + cookies := res.Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + return cookies[0] +} + +func TestCurrentUserValidSession(t *testing.T) { + cookie := issueSessionCookie(t, "alice", "jf-token-123") + req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + req.AddCookie(cookie) + + claims, err := CurrentUser(req) + if err != nil { + t.Fatalf("CurrentUser returned error: %v", err) + } + if claims.Username != "alice" { + t.Fatalf("unexpected username %q", claims.Username) + } + if claims.JFToken != "jf-token-123" { + t.Fatalf("unexpected jellyfin token %q", claims.JFToken) + } + if claims.ExpiresAt == nil || claims.IssuedAt == nil { + t.Fatalf("expected exp+iat claims to be present") + } +} + +func TestCurrentUserNoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + _, err := CurrentUser(req) + if err == nil { + t.Fatalf("expected unauthorized error when cookie is missing") + } +} + +func TestCurrentUserInvalidSignature(t *testing.T) { + origKey := sessionKey + sessionKey = []byte("runtime-key") + t.Cleanup(func() { + sessionKey = origKey + }) + + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + Username: "alice", + JFToken: "bad-token", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + }) + signed, err := tok.SignedString([]byte("different-key")) + if err != nil { + t.Fatalf("signing token failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: signed}) + + _, err = CurrentUser(req) + if err == nil { + t.Fatalf("expected signature validation error") + } +} + +func TestCurrentUserMalformedToken(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: "not-a-jwt"}) + + _, err := CurrentUser(req) + if err == nil { + t.Fatalf("expected parse error for malformed token") + } +} diff --git a/backend/internal/debuglog.go b/backend/internal/debuglog.go index 18fc38d..218c4ab 100644 --- a/backend/internal/debuglog.go +++ b/backend/internal/debuglog.go @@ -12,7 +12,9 @@ var Debug = os.Getenv("PEGASUS_DEBUG") == "1" var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1" func Logf(format string, args ...any) { - if Debug { log.Printf(format, args...) } + if Debug { + log.Printf(format, args...) + } } func RedactHeaders(h http.Header) http.Header { diff --git a/backend/internal/debuglog_test.go b/backend/internal/debuglog_test.go new file mode 100644 index 0000000..ea70fa1 --- /dev/null +++ b/backend/internal/debuglog_test.go @@ -0,0 +1,39 @@ +package internal + +import ( + "net/http" + "reflect" + "testing" +) + +func TestRedactHeaders(t *testing.T) { + in := http.Header{ + "Cookie": []string{"session=abc"}, + "Authorization": []string{"Bearer token"}, + "X-Api-Token": []string{"secret"}, + "Content-Type": []string{"application/json"}, + } + got := RedactHeaders(in) + + if want := []string{""}; !reflect.DeepEqual(got["Cookie"], want) { + t.Fatalf("cookie not redacted: %v", got["Cookie"]) + } + if want := []string{""}; !reflect.DeepEqual(got["Authorization"], want) { + t.Fatalf("authorization not redacted: %v", got["Authorization"]) + } + if want := []string{""}; !reflect.DeepEqual(got["X-Api-Token"], want) { + t.Fatalf("token header not redacted: %v", got["X-Api-Token"]) + } + if want := []string{"application/json"}; !reflect.DeepEqual(got["Content-Type"], want) { + t.Fatalf("safe header should remain unchanged: %v", got["Content-Type"]) + } +} + +func TestLogfNoPanic(t *testing.T) { + orig := Debug + Debug = false + Logf("this should be ignored") + Debug = true + Logf("this should log: %d", 1) + Debug = orig +} diff --git a/backend/internal/fs.go b/backend/internal/fs.go index 92888ec..74d0913 100644 --- a/backend/internal/fs.go +++ b/backend/internal/fs.go @@ -11,8 +11,14 @@ import ( func SafeJoin(root, rel string) (string, error) { rel = strings.TrimPrefix(rel, "/") p := filepath.Join(root, rel) - ap, err := filepath.Abs(p); if err != nil { return "", err } - ar, err := filepath.Abs(root); if err != nil { return "", err } + ap, err := filepath.Abs(p) + if err != nil { + return "", err + } + ar, err := filepath.Abs(root) + if err != nil { + return "", err + } if !strings.HasPrefix(ap, ar+string(os.PathSeparator)) && ap != ar { return "", errors.New("path escapes root") } diff --git a/backend/internal/fs_test.go b/backend/internal/fs_test.go new file mode 100644 index 0000000..dd0caeb --- /dev/null +++ b/backend/internal/fs_test.go @@ -0,0 +1,36 @@ +package internal + +import ( + "path/filepath" + "testing" +) + +func TestSafeJoinWithinRoot(t *testing.T) { + root := t.TempDir() + got, err := SafeJoin(root, "folder/file.txt") + if err != nil { + t.Fatalf("SafeJoin failed: %v", err) + } + want := filepath.Join(root, "folder", "file.txt") + if got != want { + t.Fatalf("SafeJoin mismatch got=%q want=%q", got, want) + } +} + +func TestSafeJoinRejectsEscape(t *testing.T) { + root := t.TempDir() + if _, err := SafeJoin(root, "../etc/passwd"); err == nil { + t.Fatalf("expected escape rejection") + } +} + +func TestSafeJoinAllowsRoot(t *testing.T) { + root := t.TempDir() + got, err := SafeJoin(root, "") + if err != nil { + t.Fatalf("SafeJoin root failed: %v", err) + } + if got != root { + t.Fatalf("expected root path %q got %q", root, got) + } +} diff --git a/backend/internal/jellyfin.go b/backend/internal/jellyfin.go index ce3495b..fff0005 100644 --- a/backend/internal/jellyfin.go +++ b/backend/internal/jellyfin.go @@ -4,7 +4,7 @@ package internal import ( "bytes" "encoding/json" - "fmt" // <-- keep this; used by fmt.Errorf + "fmt" // <-- keep this; used by fmt.Errorf "net/http" "os" "time" diff --git a/backend/internal/jellyfin_test.go b/backend/internal/jellyfin_test.go new file mode 100644 index 0000000..ffe6f44 --- /dev/null +++ b/backend/internal/jellyfin_test.go @@ -0,0 +1,92 @@ +package internal + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAuthenticateByNameSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/Users/AuthenticateByName" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("unexpected content type %q", got) + } + if got := r.Header.Get("Authorization"); !strings.Contains(got, `MediaBrowser Client="Pegasus"`) { + t.Fatalf("unexpected auth header %q", got) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + var in map[string]string + if err := json.Unmarshal(body, &in); err != nil { + t.Fatalf("decode body failed: %v", err) + } + if in["Username"] != "brad" || in["Pw"] != "hunter2" { + t.Fatalf("unexpected auth payload %#v", in) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"AccessToken":"abc123","User":{"Id":"u1","Name":"brad"}}`)) + })) + defer srv.Close() + + j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()} + out, err := j.AuthenticateByName("brad", "hunter2") + if err != nil { + t.Fatalf("AuthenticateByName failed: %v", err) + } + if out.AccessToken != "abc123" || out.User.Name != "brad" || out.User.Id != "u1" { + t.Fatalf("unexpected auth response %#v", out) + } +} + +func TestAuthenticateByNameLoginFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("nope")) + })) + defer srv.Close() + + j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()} + _, err := j.AuthenticateByName("brad", "bad") + if err == nil || !strings.Contains(err.Error(), "login failed") { + t.Fatalf("expected login failed error, got %v", err) + } +} + +func TestAuthenticateByNameBadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{not-json")) + })) + defer srv.Close() + + j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()} + _, err := j.AuthenticateByName("brad", "pw") + if err == nil { + t.Fatalf("expected decode error") + } +} + +func TestAuthenticateByNameRequestError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + baseURL := srv.URL + srv.Close() // force client request error + + j := &Jellyfin{BaseURL: baseURL, Client: &http.Client{}} + _, err := j.AuthenticateByName("brad", "pw") + if err == nil { + t.Fatalf("expected transport error") + } +} diff --git a/backend/internal/naming.go b/backend/internal/naming.go index 0ef9950..15eec5d 100644 --- a/backend/internal/naming.go +++ b/backend/internal/naming.go @@ -11,23 +11,29 @@ import ( var ( descMax = 64 - allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_` - dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // UI sends YYYY-MM-DD + allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_` + dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // UI sends YYYY-MM-DD finalNameValidate = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`) ) func SanitizeDesc(in string) string { s := strings.TrimSpace(in) - if s == "" { s = "upload" } + if s == "" { + s = "upload" + } s = allowedDesc.ReplaceAllString(s, "_") s = strings.Join(strings.Fields(s), "_") // collapse whitespace - if len(s) > descMax { s = s[:descMax] } + if len(s) > descMax { + s = s[:descMax] + } return s } func ComposeFinalName(dateStr, desc, origFilename string) (string, error) { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), ".")) - if ext == "" { ext = "bin" } + if ext == "" { + ext = "bin" + } var t time.Time var err error @@ -36,9 +42,13 @@ func ComposeFinalName(dateStr, desc, origFilename string) (string, error) { } else { t = time.Now() } - if err != nil { return "", fmt.Errorf("bad date") } + if err != nil { + return "", fmt.Errorf("bad date") + } final := fmt.Sprintf("%04d.%02d.%02d.%s.%s", t.Year(), int(t.Month()), t.Day(), SanitizeDesc(desc), ext) - if !finalNameValidate.MatchString(final) { return "", fmt.Errorf("invalid final name") } + if !finalNameValidate.MatchString(final) { + return "", fmt.Errorf("invalid final name") + } return final, nil } diff --git a/backend/internal/naming_test.go b/backend/internal/naming_test.go new file mode 100644 index 0000000..8a41220 --- /dev/null +++ b/backend/internal/naming_test.go @@ -0,0 +1,50 @@ +package internal + +import ( + "regexp" + "strings" + "testing" +) + +func TestSanitizeDesc(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "upload"}, + {" hello world ", "hello_world"}, + {"a/b*c", "a_b_c"}, + } + for _, tc := range cases { + if got := SanitizeDesc(tc.in); got != tc.want { + t.Fatalf("SanitizeDesc(%q)=%q want=%q", tc.in, got, tc.want) + } + } + + long := strings.Repeat("x", 200) + if got := SanitizeDesc(long); len(got) != descMax { + t.Fatalf("expected max len %d, got %d", descMax, len(got)) + } +} + +func TestComposeFinalName(t *testing.T) { + got, err := ComposeFinalName("2026-04-09", "My desc", "Movie.MP4") + if err != nil { + t.Fatalf("ComposeFinalName returned error: %v", err) + } + if got != "2026.04.09.My_desc.mp4" { + t.Fatalf("unexpected final name %q", got) + } + + if _, err := ComposeFinalName("2026-99-01", "desc", "x.mov"); err == nil { + t.Fatalf("expected invalid date error") + } + + fallback, err := ComposeFinalName("not-a-date", "desc", "file") + if err != nil { + t.Fatalf("unexpected fallback error: %v", err) + } + if !regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.desc\.bin$`).MatchString(fallback) { + t.Fatalf("unexpected fallback name %q", fallback) + } +} diff --git a/backend/internal/refresh_test.go b/backend/internal/refresh_test.go new file mode 100644 index 0000000..fb05333 --- /dev/null +++ b/backend/internal/refresh_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" +) + +func TestRefreshLibraryDebouncesBurst(t *testing.T) { + defer safeClearTimer() + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/Library/Refresh" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if token := r.Header.Get("X-Emby-Token"); token != "admin-token" { + t.Fatalf("expected API token header, got %q", token) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + t.Setenv("JELLYFIN_URL", srv.URL) + t.Setenv("JELLYFIN_API_KEY", "admin-token") + + j := &Jellyfin{Client: srv.Client()} + j.RefreshLibrary("fallback-user-token") + j.RefreshLibrary("fallback-user-token") + j.RefreshLibrary("fallback-user-token") + + time.Sleep(2500 * time.Millisecond) + if got := calls.Load(); got != 1 { + t.Fatalf("expected one debounced refresh request, got %d", got) + } +} + +func TestRefreshLibrarySkipsWithoutToken(t *testing.T) { + defer safeClearTimer() + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + t.Setenv("JELLYFIN_URL", srv.URL) + t.Setenv("JELLYFIN_API_KEY", "") + + j := &Jellyfin{Client: srv.Client()} + j.RefreshLibrary("") + + time.Sleep(2500 * time.Millisecond) + if got := calls.Load(); got != 0 { + t.Fatalf("expected zero refresh requests without token, got %d", got) + } +} + +func TestRefreshLibrarySkipsWithoutURL(t *testing.T) { + defer safeClearTimer() + + orig := os.Getenv("JELLYFIN_URL") + if err := os.Unsetenv("JELLYFIN_URL"); err != nil { + t.Fatalf("unset env failed: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("JELLYFIN_URL", orig) + }) + t.Setenv("JELLYFIN_API_KEY", "admin-token") + + j := &Jellyfin{Client: &http.Client{Timeout: 200 * time.Millisecond}} + j.RefreshLibrary("fallback-user-token") + + time.Sleep(2500 * time.Millisecond) + if refreshTimer != nil { + t.Fatalf("expected timer to self-clear after skip") + } +} diff --git a/backend/internal/session.go b/backend/internal/session.go index 2fbc16f..98a3b89 100644 --- a/backend/internal/session.go +++ b/backend/internal/session.go @@ -10,8 +10,8 @@ import ( ) type Claims struct { - Username string `json:"u"` - JFToken string `json:"t"` + Username string `json:"u"` + JFToken string `json:"t"` jwt.RegisteredClaims } @@ -31,27 +31,29 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error { }, }) signed, err := tok.SignedString(sessionKey) - if err != nil { return err } - http.SetCookie(w, &http.Cookie{ - Name: CookieName, - Value: signed, - Path: "/", - HttpOnly: true, - Secure: cookieSecure, - SameSite: http.SameSiteLaxMode, - }) + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: signed, + Path: "/", + HttpOnly: true, + Secure: cookieSecure, + SameSite: http.SameSiteLaxMode, + }) return nil } func ClearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ - Name: CookieName, - Value: "", - Expires: time.Unix(0,0), - MaxAge: -1, - Path: "/", - HttpOnly: true, - Secure: cookieSecure, - SameSite: http.SameSiteLaxMode, + Name: CookieName, + Value: "", + Expires: time.Unix(0, 0), + MaxAge: -1, + Path: "/", + HttpOnly: true, + Secure: cookieSecure, + SameSite: http.SameSiteLaxMode, }) } diff --git a/backend/internal/session_test.go b/backend/internal/session_test.go new file mode 100644 index 0000000..cc39e8a --- /dev/null +++ b/backend/internal/session_test.go @@ -0,0 +1,97 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func TestSetSessionSetsCookieAndJWTClaims(t *testing.T) { + origKey := sessionKey + origSecure := cookieSecure + sessionKey = []byte("session-test-key") + cookieSecure = false + t.Cleanup(func() { + sessionKey = origKey + cookieSecure = origSecure + }) + + rr := httptest.NewRecorder() + if err := SetSession(rr, "brad", "jf-access"); err != nil { + t.Fatalf("SetSession failed: %v", err) + } + + res := rr.Result() + defer res.Body.Close() + cookies := res.Cookies() + if len(cookies) != 1 { + t.Fatalf("expected one cookie, got %d", len(cookies)) + } + c := cookies[0] + + if c.Name != CookieName { + t.Fatalf("unexpected cookie name %q", c.Name) + } + if c.HttpOnly != true { + t.Fatalf("expected HttpOnly cookie") + } + if c.Path != "/" { + t.Fatalf("unexpected cookie path %q", c.Path) + } + if c.Secure { + t.Fatalf("expected insecure cookie when PEGASUS_COOKIE_INSECURE=1 behavior is enabled in test") + } + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("unexpected SameSite value %v", c.SameSite) + } + + tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { + return sessionKey, nil + }) + if err != nil { + t.Fatalf("cookie token failed to parse: %v", err) + } + claims, ok := tok.Claims.(*Claims) + if !ok || !tok.Valid { + t.Fatalf("parsed claims invalid") + } + if claims.Username != "brad" || claims.JFToken != "jf-access" { + t.Fatalf("unexpected claims payload: %#v", claims) + } +} + +func TestClearSessionExpiresCookie(t *testing.T) { + origSecure := cookieSecure + cookieSecure = true + t.Cleanup(func() { + cookieSecure = origSecure + }) + + rr := httptest.NewRecorder() + ClearSession(rr) + + res := rr.Result() + defer res.Body.Close() + cookies := res.Cookies() + if len(cookies) != 1 { + t.Fatalf("expected one cookie, got %d", len(cookies)) + } + c := cookies[0] + if c.Name != CookieName { + t.Fatalf("unexpected cookie name %q", c.Name) + } + if c.Value != "" { + t.Fatalf("expected cleared cookie value") + } + if c.MaxAge != -1 { + t.Fatalf("expected MaxAge=-1, got %d", c.MaxAge) + } + if !c.Expires.IsZero() && c.Expires.Unix() != 0 { + t.Fatalf("expected unix epoch expiry, got %v", c.Expires) + } + if !c.Secure { + t.Fatalf("expected secure cookie") + } +} diff --git a/backend/internal/usermap_test.go b/backend/internal/usermap_test.go new file mode 100644 index 0000000..972bb78 --- /dev/null +++ b/backend/internal/usermap_test.go @@ -0,0 +1,92 @@ +package internal + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestStringOrListUnmarshalScalarAndList(t *testing.T) { + var scalar struct { + Map map[string]StringOrList `yaml:"map"` + } + if err := yaml.Unmarshal([]byte("map:\n alice: photos\n"), &scalar); err != nil { + t.Fatalf("scalar unmarshal failed: %v", err) + } + if got, want := []string(scalar.Map["alice"]), []string{"photos"}; !reflect.DeepEqual(got, want) { + t.Fatalf("scalar mismatch got=%v want=%v", got, want) + } + + var list struct { + Map map[string]StringOrList `yaml:"map"` + } + if err := yaml.Unmarshal([]byte("map:\n brad: [movies, shows]\n"), &list); err != nil { + t.Fatalf("list unmarshal failed: %v", err) + } + if got, want := []string(list.Map["brad"]), []string{"movies", "shows"}; !reflect.DeepEqual(got, want) { + t.Fatalf("list mismatch got=%v want=%v", got, want) + } +} + +func TestStringOrListUnmarshalInvalidKind(t *testing.T) { + var data struct { + Map map[string]StringOrList `yaml:"map"` + } + err := yaml.Unmarshal([]byte("map:\n nope:\n nested: bad\n"), &data) + if err == nil { + t.Fatalf("expected unmarshal error for mapping node") + } +} + +func TestLoadUserMapAndResolve(t *testing.T) { + tempDir := t.TempDir() + mapPath := filepath.Join(tempDir, "user-map.yaml") + content := "map:\n alice: media/photos\n brad: [movies, ' clips ', '', '/', '.']\n" + if err := os.WriteFile(mapPath, []byte(content), 0o644); err != nil { + t.Fatalf("write user map failed: %v", err) + } + + m, err := LoadUserMap(mapPath) + if err != nil { + t.Fatalf("LoadUserMap failed: %v", err) + } + + root, err := m.Resolve("alice") + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if root != filepath.Clean("media/photos") { + t.Fatalf("unexpected resolved root %q", root) + } + + roots, err := m.ResolveAll("brad") + if err != nil { + t.Fatalf("ResolveAll failed: %v", err) + } + if got, want := roots, []string{"movies", "clips"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ResolveAll mismatch got=%v want=%v", got, want) + } +} + +func TestLoadUserMapMissingFile(t *testing.T) { + _, err := LoadUserMap("/definitely/missing/user-map.yaml") + if err == nil { + t.Fatalf("expected error for missing file") + } +} + +func TestResolveAllMissingAndInvalidEntries(t *testing.T) { + m := &UserMap{Map: map[string]StringOrList{ + "empty": {"", " ", "/", "."}, + }} + + if _, err := m.ResolveAll("unknown"); err == nil { + t.Fatalf("expected missing-user error") + } + if _, err := m.ResolveAll("empty"); err == nil { + t.Fatalf("expected invalid-mapping error") + } +} diff --git a/backend/main_test.go b/backend/main_test.go new file mode 100644 index 0000000..06a357b --- /dev/null +++ b/backend/main_test.go @@ -0,0 +1,305 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + tusd "github.com/tus/tusd/pkg/handler" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func TestSanitizeSegment(t *testing.T) { + cases := map[string]string{ + "": "", + " Family Videos ": "Family_Videos", + "photos/2026/trip": "photos", + "bad#$name": "bad__name", + "many spaces": "many___spaces", + "../../etc/passwd": "..", + } + + for in, want := range cases { + if got := sanitizeSegment(in); got != want { + t.Fatalf("sanitizeSegment(%q)=%q want=%q", in, got, want) + } + } +} + +func TestContains(t *testing.T) { + vals := []string{"a", "b", "c"} + if !contains(vals, "b") { + t.Fatalf("expected contains to return true") + } + if contains(vals, "z") { + t.Fatalf("expected contains to return false") + } +} + +func TestSanitizeDescriptorAndStemOf(t *testing.T) { + if got := sanitizeDescriptor(" cool.. file$$$ "); got != "cool_file_" { + t.Fatalf("unexpected sanitized descriptor %q", got) + } + if got := sanitizeDescriptor(""); got != "upload" { + t.Fatalf("expected upload fallback, got %q", got) + } + if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" { + t.Fatalf("unexpected stem %q", got) + } +} + +func TestComposeFinalName(t *testing.T) { + got := composeFinalName("2026-04-09", "My Desc", "Cool.Movie.MP4") + want := "2026.04.09.My_Desc.Cool_Movie.mp4" + if got != want { + t.Fatalf("composeFinalName got=%q want=%q", got, want) + } + + fallback := composeFinalName("not-a-date", "", "file") + if !regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.upload\.file\.bin$`).MatchString(fallback) { + t.Fatalf("unexpected fallback final name %q", fallback) + } +} + +func TestCorsForTusHandlesPreflight(t *testing.T) { + h := corsForTus(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("next handler should not run for preflight") + })) + + req := httptest.NewRequest(http.MethodOptions, "/tus/abc", nil) + req.Header.Set("Origin", "https://pegasus.bstein.dev") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204 for preflight, got %d", rr.Code) + } + if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://pegasus.bstein.dev" { + t.Fatalf("unexpected allow-origin %q", got) + } +} + +func TestCorsForTusPassesThrough(t *testing.T) { + called := false + h := corsForTus(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + + req := httptest.NewRequest(http.MethodGet, "/tus/abc", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if !called { + t.Fatalf("expected wrapped handler to be called") + } + if rr.Code != http.StatusTeapot { + t.Fatalf("expected wrapped response code, got %d", rr.Code) + } +} + +func TestSessionRequired(t *testing.T) { + protected := sessionRequired(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + t.Run("unauthorized without cookie", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/tus/", nil) + rr := httptest.NewRecorder() + protected.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } + }) + + t.Run("authorized with session cookie", func(t *testing.T) { + cookieRR := httptest.NewRecorder() + if err := internal.SetSession(cookieRR, "brad", "jf-token"); err != nil { + t.Fatalf("SetSession failed: %v", err) + } + cookies := cookieRR.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + + req := httptest.NewRequest(http.MethodGet, "/tus/", nil) + req.AddCookie(cookies[0]) + rr := httptest.NewRecorder() + protected.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + }) +} + +func TestClaimsFromHook(t *testing.T) { + cookieRR := httptest.NewRecorder() + if err := internal.SetSession(cookieRR, "alice", "jf-token"); err != nil { + t.Fatalf("SetSession failed: %v", err) + } + cookies := cookieRR.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + + ev := tusd.HookEvent{ + HTTPRequest: tusd.HTTPRequest{Header: http.Header{ + "Cookie": []string{cookies[0].Name + "=" + cookies[0].Value}, + }}, + } + claims, err := claimsFromHook(ev) + if err != nil { + t.Fatalf("claimsFromHook failed: %v", err) + } + if claims.Username != "alice" || claims.JFToken != "jf-token" { + t.Fatalf("unexpected claims %#v", claims) + } + + _, err = claimsFromHook(tusd.HookEvent{}) + if err == nil { + t.Fatalf("expected missing-header error") + } +} + +func TestMoveFromTusMovesFileAndCleansSidecars(t *testing.T) { + origTusDir := tusDir + tusDir = t.TempDir() + t.Cleanup(func() { + tusDir = origTusDir + }) + + origDryRun := internal.DryRun + internal.DryRun = false + t.Cleanup(func() { + internal.DryRun = origDryRun + }) + + id := "upload-id-123" + src := filepath.Join(tusDir, id+".bin") + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + _ = os.WriteFile(filepath.Join(tusDir, id+".info"), []byte("meta"), 0o644) + _ = os.WriteFile(filepath.Join(tusDir, id+".json"), []byte("meta"), 0o644) + + dst := filepath.Join(t.TempDir(), "final", "file.bin") + ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}} + + if err := moveFromTus(ev, dst); err != nil { + t.Fatalf("moveFromTus failed: %v", err) + } + + b, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("expected moved file at destination: %v", err) + } + if string(b) != "payload" { + t.Fatalf("unexpected destination payload %q", string(b)) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("expected source file to be removed, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(tusDir, id+".info")); !os.IsNotExist(err) { + t.Fatalf("expected .info sidecar cleanup, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(tusDir, id+".json")); !os.IsNotExist(err) { + t.Fatalf("expected .json sidecar cleanup, stat err=%v", err) + } +} + +func TestMoveFromTusDryRun(t *testing.T) { + origTusDir := tusDir + tusDir = t.TempDir() + t.Cleanup(func() { + tusDir = origTusDir + }) + + origDryRun := internal.DryRun + internal.DryRun = true + t.Cleanup(func() { + internal.DryRun = origDryRun + }) + + id := "upload-id-dry" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + dst := filepath.Join(t.TempDir(), "dst.bin") + ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}} + + if err := moveFromTus(ev, dst); err != nil { + t.Fatalf("moveFromTus dry-run failed: %v", err) + } + if _, err := os.Stat(src); err != nil { + t.Fatalf("expected source file to remain in dry-run mode: %v", err) + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Fatalf("expected destination to be absent in dry-run mode") + } +} + +func TestMoveFromTusMissingDataFile(t *testing.T) { + origTusDir := tusDir + tusDir = t.TempDir() + t.Cleanup(func() { + tusDir = origTusDir + }) + + ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: "missing"}} + err := moveFromTus(ev, filepath.Join(t.TempDir(), "dst.bin")) + if err == nil { + t.Fatalf("expected not-found error") + } +} + +func TestWriteJSON(t *testing.T) { + rr := httptest.NewRecorder() + writeJSON(rr, map[string]any{"ok": true}) + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("unexpected content-type %q", ct) + } + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status %d", rr.Code) + } + body, _ := io.ReadAll(rr.Result().Body) + if !regexp.MustCompile(`"ok"\s*:\s*true`).Match(body) { + t.Fatalf("unexpected JSON body %q", string(body)) + } +} + +func TestEnvHelper(t *testing.T) { + t.Setenv("PEGASUS_TEST_ENV", "value") + if got := env("PEGASUS_TEST_ENV", "fallback"); got != "value" { + t.Fatalf("unexpected env result %q", got) + } + if got := env("PEGASUS_TEST_ENV_MISSING", "fallback"); got != "fallback" { + t.Fatalf("unexpected fallback env result %q", got) + } +} + +func TestLoggingRWWriteHeader(t *testing.T) { + rr := httptest.NewRecorder() + lw := &loggingRW{ResponseWriter: rr, status: http.StatusOK} + lw.WriteHeader(http.StatusCreated) + if lw.status != http.StatusCreated { + t.Fatalf("expected tracked status %d, got %d", http.StatusCreated, lw.status) + } + if rr.Code != http.StatusCreated { + t.Fatalf("expected recorder status %d, got %d", http.StatusCreated, rr.Code) + } +} + +func TestComposeFinalNameDateFallbackNotEmpty(t *testing.T) { + got := composeFinalName("", "clip", "v.mkv") + today := time.Now().Format("2006.01.02") + if got[:10] != today { + t.Fatalf("expected date prefix %s got %s", today, got[:10]) + } +} diff --git a/backend/web/dist/index.html b/backend/web/dist/index.html new file mode 100644 index 0000000..96f660d --- /dev/null +++ b/backend/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Pegasus + + +
+

Pegasus frontend assets are not bundled in this build.

+
+ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a075e6..1592983 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,13 +14,101 @@ "tus-js-client": "^4.3.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/react": "^18.3.24", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.0.2", + "@vitest/coverage-v8": "^3.2.4", + "jsdom": "^27.0.0", "typescript": "^5.9.2", - "vite": "^7.1.5" + "vite": "^7.1.5", + "vitest": "^3.2.4" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -255,6 +343,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -303,6 +401,156 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -745,6 +993,52 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -785,9 +1079,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -801,6 +1095,17 @@ "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", @@ -1102,6 +1407,90 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1147,6 +1536,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1203,6 +1610,261 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.25.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", @@ -1242,6 +1904,16 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001741", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", @@ -1263,6 +1935,53 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combine-errors": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", @@ -1279,6 +1998,68 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1292,6 +2073,30 @@ "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", "license": "ISC" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -1310,6 +2115,48 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.214", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", @@ -1317,6 +2164,33 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -1369,6 +2243,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1387,6 +2281,36 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1412,12 +2336,152 @@ "node": ">=6.9.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1430,6 +2494,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -1442,6 +2583,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1542,6 +2723,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1552,6 +2740,111 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1585,6 +2878,77 @@ "dev": true, "license": "MIT" }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1634,6 +2998,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1645,6 +3025,16 @@ "signal-exit": "^3.0.2" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -1676,6 +3066,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1686,6 +3084,30 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -1742,6 +3164,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1761,6 +3196,36 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1777,6 +3242,199 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1794,6 +3452,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tus-js-client": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", @@ -1942,6 +3676,322 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 97e5ddb..e516302 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview --port 5173" + "preview": "vite preview --port 5173", + "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=../build/junit-frontend.xml --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=../build/frontend-coverage" }, "dependencies": { "@picocss/pico": "^2.1.1", @@ -15,10 +17,15 @@ "tus-js-client": "^4.3.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/react": "^18.3.24", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.0.2", + "@vitest/coverage-v8": "^3.2.4", + "jsdom": "^27.0.0", "typescript": "^5.9.2", - "vite": "^7.1.5" + "vite": "^7.1.5", + "vitest": "^3.2.4" } } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..5167c48 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import App from './App' +import { api } from './api' + +vi.mock('./api', () => ({ + api: vi.fn(), +})) + +vi.mock('./Uploader', () => ({ + default: function MockUploader() { + return
uploader
+ }, +})) + +vi.mock('./Login', () => ({ + default: function MockLogin({ onLogin }: { onLogin: () => void }) { + return ( + + ) + }, +})) + +describe('App', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('location', { reload: vi.fn() } as any) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders uploader when whoami is successful', async () => { + const apiMock = vi.mocked(api) + apiMock.mockResolvedValueOnce({ username: 'brad' } as never) + + render() + + expect(await screen.findByTestId('uploader')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument() + expect(apiMock).toHaveBeenCalledWith('/api/whoami') + }) + + it('renders login when whoami fails', async () => { + const apiMock = vi.mocked(api) + apiMock.mockRejectedValueOnce(new Error('unauthorized')) + + render() + + expect(await screen.findByRole('button', { name: 'mock-login' })).toBeInTheDocument() + }) + + it('calls logout endpoint and reloads page', async () => { + const apiMock = vi.mocked(api) + apiMock.mockResolvedValueOnce({ username: 'brad' } as never) + apiMock.mockResolvedValueOnce({ ok: true } as never) + + render() + const logoutBtn = await screen.findByRole('button', { name: 'Logout' }) + fireEvent.click(logoutBtn) + + await waitFor(() => { + expect(apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' }) + }) + expect((globalThis.location as any).reload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/Login.test.tsx b/frontend/src/Login.test.tsx new file mode 100644 index 0000000..834cf95 --- /dev/null +++ b/frontend/src/Login.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import Login from './Login' +import { api } from './api' + +vi.mock('./api', () => ({ + api: vi.fn(), +})) + +describe('Login', () => { + it('submits credentials and calls onLogin', async () => { + const apiMock = vi.mocked(api) + apiMock.mockResolvedValue({ ok: true }) + const onLogin = vi.fn() + + render() + + fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'brad' } }) + fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'pass' } }) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + await waitFor(() => { + expect(onLogin).toHaveBeenCalledTimes(1) + }) + + expect(apiMock).toHaveBeenCalledWith('/api/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'brad', password: 'pass' }), + }) + }) + + it('shows server error when login fails', async () => { + const apiMock = vi.mocked(api) + apiMock.mockRejectedValue(new Error('invalid credentials')) + + render( {}} />) + + fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'brad' } }) + fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'bad' } }) + fireEvent.click(screen.getByRole('button', { name: 'Login' })) + + expect(await screen.findByText('invalid credentials')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/Uploader.helpers.test.ts b/frontend/src/Uploader.helpers.test.ts new file mode 100644 index 0000000..262eeda --- /dev/null +++ b/frontend/src/Uploader.helpers.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { + clampOneLevel, + composeName, + extOf, + isDetailedError, + isImageFile, + isLikelyMobileUA, + isVideoFile, + normalizeRows, + sanitizeDesc, + sanitizeFolderName, + stemOf, +} from './Uploader' + +describe('Uploader helpers', () => { + let logSpy: ReturnType + + beforeAll(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterAll(() => { + logSpy.mockRestore() + }) + + it('sanitizes description and folder names', () => { + expect(sanitizeDesc(' Hello world! ')).toBe('Hello_world_') + expect(sanitizeDesc('')).toBe('upload') + expect(sanitizeFolderName(' /my folder/with/depth ')).toBe('my_folder') + expect(sanitizeFolderName('bad#$name')).toBe('bad_name') + }) + + it('computes extension, stem, and composed final name', () => { + expect(extOf('clip.MP4')).toBe('mp4') + expect(extOf('noext')).toBe('') + expect(stemOf('My.Video.File.mp4')).toBe('My_Video_File') + expect(composeName('2026-04-09', 'Trip Clip', 'My.Video.File.mp4')).toBe( + '2026.04.09.Trip_Clip.My_Video_File.mp4', + ) + }) + + it('clamps one-level paths and normalizes row payload shapes', () => { + expect(clampOneLevel('/photos/2026/trip/')).toBe('photos') + expect(clampOneLevel('')).toBe('') + + const rows = normalizeRows([ + { Name: 'a', Path: 'a', IsDir: true, Size: 12, Mtime: 55 }, + { name: 'b', path: 'b', is_dir: false, size: 3, mtime: 99 }, + null, + ] as any) + + expect(rows[0]).toEqual({ name: 'a', path: 'a', is_dir: true, size: 12, mtime: 55 }) + expect(rows[1]).toEqual({ name: 'b', path: 'b', is_dir: false, size: 3, mtime: 99 }) + expect(rows[2]).toEqual({ name: '', path: '', is_dir: false, size: 0, mtime: 0 }) + }) + + it('detects video/image files by type and extension', () => { + const mkFile = (name: string, type: string) => new File(['x'], name, { type }) + expect(isVideoFile(mkFile('x.mp4', 'video/mp4'))).toBe(true) + expect(isVideoFile(mkFile('x.mkv', ''))).toBe(true) + expect(isVideoFile(mkFile('x.txt', 'text/plain'))).toBe(false) + + expect(isImageFile(mkFile('x.jpg', 'image/jpeg'))).toBe(true) + expect(isImageFile(mkFile('x.heic', ''))).toBe(true) + expect(isImageFile(mkFile('x.mp4', 'video/mp4'))).toBe(false) + }) + + it('identifies detailed tus errors and mobile UA heuristics', () => { + expect(isDetailedError({ originalRequest: {} })).toBe(true) + expect(isDetailedError({ originalResponse: {} })).toBe(true) + expect(isDetailedError({})).toBe(false) + + expect(typeof isLikelyMobileUA()).toBe('boolean') + }) +}) diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index 3e61fd7..be3d6ea 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -6,31 +6,29 @@ import * as tus from 'tus-js-client' type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } -console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) - // ---------- helpers ---------- -function sanitizeDesc(s: string) { +export function sanitizeDesc(s: string) { s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_') if (!s) s = 'upload' return s.slice(0, 64) } -function sanitizeFolderName(s: string) { +export function sanitizeFolderName(s: string) { // 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-] s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '') s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_') return s.slice(0, 64) } -function extOf(n: string) { +export function extOf(n: string) { const i = n.lastIndexOf('.') return i > -1 ? n.slice(i + 1).toLowerCase() : '' } -function stemOf(n: string) { +export function stemOf(n: string) { const i = n.lastIndexOf('.') const stem = i > -1 ? n.slice(0, i) : n // keep safe charset and avoid extra dots within segments return sanitizeDesc(stem.replace(/\./g, '_')) || 'file' } -function composeName(date: string, desc: string, orig: string) { +export function composeName(date: string, desc: string, orig: string) { const d = date || new Date().toISOString().slice(0, 10) const [Y, M, D] = d.split('-') const sDesc = sanitizeDesc(desc) @@ -39,11 +37,11 @@ function composeName(date: string, desc: string, orig: string) { // New pattern: YYYY.MM.DD.Description.OriginalStem.ext return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}` } -function clampOneLevel(p: string) { +export function clampOneLevel(p: string) { if (!p) return '' return p.replace(/^\/+|\/+$/g, '').split('/')[0] || '' } -function normalizeRows(listRaw: any[]): FileRow[] { +export function normalizeRows(listRaw: any[]): FileRow[] { return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({ name: r?.name ?? r?.Name ?? '', path: r?.path ?? r?.Path ?? '', @@ -55,14 +53,14 @@ function normalizeRows(listRaw: any[]): FileRow[] { const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts']) const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff']) const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') -const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name)) -const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name)) +export const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name)) +export const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name)) -function isDetailedError(e: unknown): e is tus.DetailedError { +export function isDetailedError(e: unknown): e is tus.DetailedError { return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) } -function isLikelyMobileUA(): boolean { +export function isLikelyMobileUA(): boolean { if (typeof window === 'undefined') return false const ua = navigator.userAgent || '' const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts new file mode 100644 index 0000000..86de6f0 --- /dev/null +++ b/frontend/src/api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { api } from './api' + +describe('api helper', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns parsed json when content-type is json', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + + const res = await api<{ ok: boolean }>('/api/healthz') + expect(res.ok).toBe(true) + expect(fetchMock).toHaveBeenCalledWith('/api/healthz', { credentials: 'include' }) + }) + + it('parses json from text payload when response header is not json', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"value":42}', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ) + + const res = await api<{ value: number }>('/api/text-json') + expect(res.value).toBe(42) + }) + + it('returns raw text when text is not json', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('hello', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ) + + const res = await api('/api/text') + expect(res).toBe('hello') + }) + + it('throws server message when response is not ok', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('invalid credentials', { + status: 401, + headers: { 'content-type': 'text/plain' }, + }), + ) + + await expect(api('/api/login')).rejects.toThrow('invalid credentials') + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3fe7777..87177af 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,5 @@ // frontend/vite.config.ts -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ @@ -8,4 +8,11 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + css: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, }) diff --git a/scripts/__pycache__/publish_test_metrics.cpython-314.pyc b/scripts/__pycache__/publish_test_metrics.cpython-314.pyc new file mode 100644 index 0000000..82e1c6f Binary files /dev/null and b/scripts/__pycache__/publish_test_metrics.cpython-314.pyc differ diff --git a/scripts/publish_test_metrics.py b/scripts/publish_test_metrics.py new file mode 100755 index 0000000..a8aeeb6 --- /dev/null +++ b/scripts/publish_test_metrics.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Publish Pegasus test-suite results to Prometheus via Pushgateway. + +Inputs: +- Backend JUnit XML and frontend JUnit XML +- Backend/frontend coverage summaries + +Outputs pushed: +- platform_quality_gate_runs_total{suite="pegasus",status="ok|failed"} +- pegasus_quality_gate_tests_total{suite="pegasus",result=*} +- pegasus_quality_gate_coverage_percent{suite="pegasus"} +""" + +from __future__ import annotations + +import json +import os +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _escape_label(value: str) -> str: + return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"') + + +def _label_str(labels: dict[str, str]) -> str: + parts = [f'{key}="{_escape_label(val)}"' for key, val in labels.items() if val] + return "{" + ",".join(parts) + "}" if parts else "" + + +def _read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + +def _as_int(node: ET.Element, name: str) -> int: + raw = node.attrib.get(name) or "0" + try: + return int(float(raw)) + except ValueError: + return 0 + + +def _load_junit(path: Path) -> dict[str, int]: + if not path.exists(): + return {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + + tree = ET.parse(path) + root = tree.getroot() + suites: list[ET.Element] + if root.tag == "testsuite": + suites = [root] + elif root.tag == "testsuites": + suites = list(root.findall("testsuite")) + else: + suites = [] + + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + for suite in suites: + totals["tests"] += _as_int(suite, "tests") + totals["failures"] += _as_int(suite, "failures") + totals["errors"] += _as_int(suite, "errors") + totals["skipped"] += _as_int(suite, "skipped") + return totals + + +def _load_backend_coverage_percent(path: Path) -> float: + if not path.exists(): + return 0.0 + try: + return float(path.read_text(encoding="utf-8").strip()) + except ValueError: + return 0.0 + + +def _load_frontend_coverage_percent(path: Path) -> float: + if not path.exists(): + return 0.0 + payload = json.loads(path.read_text(encoding="utf-8")) + total = payload.get("total") or {} + lines = total.get("lines") or {} + pct = lines.get("pct") + if isinstance(pct, (int, float)): + return float(pct) + return 0.0 + + +def _read_test_exit_code(path: Path) -> int: + if not path.exists(): + return 1 + raw = path.read_text(encoding="utf-8").strip() + try: + return int(raw) + except ValueError: + return 1 + + +def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float: + text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics") + if not text: + return 0.0 + + for line in text.splitlines(): + if not line.startswith(metric + "{"): + continue + if any(f'{k}="{v}"' not in line for k, v in labels.items()): + continue + parts = line.split() + if len(parts) < 2: + continue + try: + return float(parts[1]) + except ValueError: + return 0.0 + return 0.0 + + +def _read_http(url: str) -> str: + try: + with urllib.request.urlopen(url, timeout=10) as resp: + return resp.read().decode("utf-8", errors="replace") + except Exception: + return "" + + +def _post_text(url: str, payload: str) -> None: + req = urllib.request.Request( + url, + data=payload.encode("utf-8"), + method="POST", + headers={"Content-Type": "text/plain"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + raise RuntimeError(f"push failed status={resp.status}") + + +def main() -> int: + suite = os.getenv("SUITE_NAME", "pegasus") + pushgateway_url = os.getenv( + "PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091" + ) + + backend_junit = Path(os.getenv("BACKEND_JUNIT_XML", "build/junit-backend.xml")) + frontend_junit = Path(os.getenv("FRONTEND_JUNIT_XML", "build/junit-frontend.xml")) + backend_cov = Path(os.getenv("BACKEND_COVERAGE_PERCENT_FILE", "build/coverage-backend-percent.txt")) + frontend_cov = Path( + os.getenv("FRONTEND_COVERAGE_JSON", "build/frontend-coverage/coverage-summary.json") + ) + backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc")) + frontend_rc_file = Path(os.getenv("FRONTEND_TEST_RC_FILE", "build/frontend-test.rc")) + + b = _load_junit(backend_junit) + f = _load_junit(frontend_junit) + totals = { + "tests": b["tests"] + f["tests"], + "failures": b["failures"] + f["failures"], + "errors": b["errors"] + f["errors"], + "skipped": b["skipped"] + f["skipped"], + } + passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) + + backend_pct = _load_backend_coverage_percent(backend_cov) + frontend_pct = _load_frontend_coverage_percent(frontend_cov) + coverage_pct = (backend_pct + frontend_pct) / 2 if (backend_pct or frontend_pct) else 0.0 + + backend_rc = _read_test_exit_code(backend_rc_file) + frontend_rc = _read_test_exit_code(frontend_rc_file) + outcome = ( + "ok" + if backend_rc == 0 + and frontend_rc == 0 + and totals["tests"] > 0 + and totals["failures"] == 0 + and totals["errors"] == 0 + else "failed" + ) + + job_name = "platform-quality-ci" + ok_count = _fetch_existing_counter( + pushgateway_url, + "platform_quality_gate_runs_total", + {"job": job_name, "suite": suite, "status": "ok"}, + ) + failed_count = _fetch_existing_counter( + pushgateway_url, + "platform_quality_gate_runs_total", + {"job": job_name, "suite": suite, "status": "failed"}, + ) + + if outcome == "ok": + ok_count += 1 + else: + failed_count += 1 + + branch = os.getenv("BRANCH_NAME", "") + build_number = os.getenv("BUILD_NUMBER", "") + commit = os.getenv("GIT_COMMIT", "") + + labels = { + "suite": suite, + "branch": branch, + "build_number": build_number, + "commit": commit, + } + + payload_lines = [ + "# TYPE platform_quality_gate_runs_total counter", + f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}', + f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}', + "# TYPE pegasus_quality_gate_tests_total gauge", + f'pegasus_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}', + f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}', + f'pegasus_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}', + f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', + "# TYPE pegasus_quality_gate_coverage_percent gauge", + f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}', + "# TYPE pegasus_quality_gate_build_info gauge", + f"pegasus_quality_gate_build_info{_label_str(labels)} 1", + ] + payload = "\n".join(payload_lines) + "\n" + + push_url = f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}" + _post_text(push_url, payload) + + summary = { + "suite": suite, + "tests_total": totals["tests"], + "tests_passed": passed, + "tests_failed": totals["failures"], + "tests_errors": totals["errors"], + "tests_skipped": totals["skipped"], + "coverage_percent": round(coverage_pct, 3), + "outcome": outcome, + "backend_rc": backend_rc, + "frontend_rc": frontend_rc, + "ok_counter": ok_count, + "failed_counter": failed_count, + } + Path("build/metrics-summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"metrics push failed: {exc}") + raise