From 470d0a5b4dfd45b836fd6c0bd20a8cca08dfebee Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 02:01:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20adopt=20TDD=20=E2=80=94=20backfill=20te?= =?UTF-8?q?sts,=20add=20Vitest,=20wire=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes test-driven development as the mandatory approach across all three layers and backfills coverage for all previously untested code. Backend (Go): - health_usecase: 11 table-driven unit tests covering every message branch - health_handler: 200 and 503 path unit tests - logger middleware: 2xx/4xx/5xx pass-through, query string, gin errors - redis cache: Testcontainers integration tests for all 7 CacheService methods - bootstrap: loadConfig defaults/overrides, validateConfig all-present/missing, jitteredBackoff bounds, probeWithRetry success/retry/timeout/cancel - Makefile itest now covers ./internal/infrastructure/... (adds Redis) Web (Next.js): - Vitest v4 + @testing-library/react v16 + jsdom installed and configured - vitest.config.ts: jsdom env, @vitejs/plugin-react, next/image+link mocks - __tests__/page.test.tsx: 3 smoke tests for the home page - pnpm test / test:watch / test:ui scripts added to package.json Mobile (Android): - GreetingFormatTest.kt replaces ExampleUnitTest (JVM, real greeting logic) - GreetingTest.kt replaces ExampleInstrumentedTest (Compose UI, createComposeRule) Docs + instructions: - CLAUDE.md, AGENTS.md (all layers): TDD mandate, write-tests-first workflow step, "no new feature without tests" hard rule - backend/docs/testing.md: expanded with usecase, handler, Redis, bootstrap patterns - web/docs/testing.md: created (framework, patterns, what to test, quality gate) - mobile/docs/testing.md: updated with TDD mandate, Composable test checklist CI: - backend-ci.yml: fixed action versions, added working-directory, corrected make targets - web-ci.yml: replaces frontend-ci.yml — correct paths, pnpm test, removed Playwright/Firebase stubs that don't exist in this project - mobile-ci.yml: fixed action version --- .github/workflows/backend-ci.yml | 42 + .github/workflows/mobile-ci.yml | 39 + .github/workflows/web-ci.yml | 44 + AGENTS.md | 11 +- CLAUDE.md | 40 +- backend/AGENTS.md | 24 +- backend/Makefile | 2 +- backend/docs/_index.md | 2 +- backend/docs/testing.md | 341 ++++-- backend/internal/bootstrap/bootstrap_test.go | 289 +++++ .../infrastructure/cache/redis/cache_test.go | 225 ++++ .../transport/handlers/health_handler_test.go | 69 ++ .../transport/middleware/logger_test.go | 114 ++ .../internal/usecase/health_usecase_test.go | 110 ++ mobile/AGENTS.md | 27 +- .../template/ExampleInstrumentedTest.kt | 24 - .../java/com/company/template/GreetingTest.kt | 37 + .../com/company/template/ExampleUnitTest.kt | 17 - .../company/template/GreetingFormatTest.kt | 21 + mobile/docs/_index.md | 2 +- mobile/docs/testing.md | 65 +- web/AGENTS.md | 53 +- web/__mocks__/next/image.tsx | 13 + web/__mocks__/next/link.tsx | 14 + web/__tests__/page.test.tsx | 20 + web/docs/_index.md | 1 + web/docs/testing.md | 152 +++ web/package.json | 12 +- web/pnpm-lock.yaml | 986 ++++++++++++++++++ web/vitest.config.ts | 19 + web/vitest.setup.ts | 1 + 31 files changed, 2660 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .github/workflows/mobile-ci.yml create mode 100644 .github/workflows/web-ci.yml create mode 100644 backend/internal/bootstrap/bootstrap_test.go create mode 100644 backend/internal/infrastructure/cache/redis/cache_test.go create mode 100644 backend/internal/transport/handlers/health_handler_test.go create mode 100644 backend/internal/transport/middleware/logger_test.go create mode 100644 backend/internal/usecase/health_usecase_test.go delete mode 100644 mobile/app/src/androidTest/java/com/company/template/ExampleInstrumentedTest.kt create mode 100644 mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt delete mode 100644 mobile/app/src/test/java/com/company/template/ExampleUnitTest.kt create mode 100644 mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt create mode 100644 web/__mocks__/next/image.tsx create mode 100644 web/__mocks__/next/link.tsx create mode 100644 web/__tests__/page.test.tsx create mode 100644 web/docs/testing.md create mode 100644 web/vitest.config.ts create mode 100644 web/vitest.setup.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..0f2b8ac --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,42 @@ +name: Backend CI + +on: + pull_request: + branches: [main] + paths: + - "backend/**" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: backend/go.sum + + - name: Install dependencies + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Build + run: make build + + - name: Run tests + run: make test + + - name: Run integration tests + run: make itest diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 0000000..2567027 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,39 @@ +name: Mobile CI + +on: + pull_request: + branches: [main] + paths: + - "mobile/**" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + ci: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Lint + run: ./gradlew lint + + - name: Run unit tests + run: ./gradlew test diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml new file mode 100644 index 0000000..0848a47 --- /dev/null +++ b/.github/workflows/web-ci.yml @@ -0,0 +1,44 @@ +name: Web CI + +on: + pull_request: + branches: [main] + paths: + - "web/**" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + ci: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: web + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Lint + run: pnpm lint + + - name: Unit tests + run: pnpm test + + - name: Build + run: pnpm build diff --git a/AGENTS.md b/AGENTS.md index 8dbb0f0..5d0f1cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,12 +34,16 @@ cd mobile && ./gradlew installDebug # build and install on connected devic ## Testing +**TDD is required.** Write failing tests first, then implement. + ```bash # Backend (Docker must be running) cd backend && make test # unit + integration cd backend && make itest # integration only # Web +cd web && pnpm test # Vitest unit + component tests +cd web && pnpm test:watch # watch mode during development cd web && pnpm lint cd web && pnpm build @@ -49,7 +53,9 @@ cd mobile && ./gradlew test # unit tests (no device needed) cd mobile && ./gradlew connectedAndroidTest # instrumented tests (emulator/device required) ``` -All backend DB tests use **Testcontainers** (real PostgreSQL). Never mock the database. +- Backend DB and cache tests use **Testcontainers** (real PostgreSQL, real Redis). Never mock the database. +- Web unit/component tests use **Vitest + @testing-library/react**. Server Component flows use Playwright (future). +- Mobile Composable tests use **Compose test rules** in instrumented tests. --- @@ -93,6 +99,7 @@ All backend DB tests use **Testcontainers** (real PostgreSQL). Never mock the da ## PR instructions - Branch from `main` — no direct pushes to `main` -- Run `make test` (backend), `pnpm lint && pnpm build` (web), and `./gradlew lint && ./gradlew test` (mobile) before opening a PR +- Before opening a PR, all tests must pass: `make test` (backend), `pnpm test && pnpm lint && pnpm build` (web), `./gradlew lint && ./gradlew test` (mobile) +- No new feature without tests — every route, component, utility, use case, and Composable must ship with tests - One logical change per PR - PR title: concise description of what changed, not implementation details diff --git a/CLAUDE.md b/CLAUDE.md index 60fb23b..4c38a17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ cd web && pnpm dev # web dev → :3000 cd backend && make test # all tests cd backend && make itest # integration tests only (requires Docker) cd web && pnpm lint && pnpm build +cd web && pnpm test # Vitest unit + component tests +cd web && pnpm test:watch # watch mode during TDD cd mobile && ./gradlew assembleDebug # build Android APK cd mobile && ./gradlew lint && ./gradlew test # mobile quality gate @@ -39,9 +41,10 @@ make migrate-version # print current schema version 1. **Check docs first** — delegate to `docs` agent: find relevant topic docs, verify they match the current code 2. **Fix stale docs** — if docs diverge from code, update docs before implementing 3. **Migrate** — if the feature needs new or changed tables: `make migrate-create name=`, write the SQL, `make migrate-up` -4. **Implement** — delegate to `backend` or `web` agent, passing the relevant doc content as context -5. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced -6. **Quality gate** — run `/project:check` before declaring done +4. **Write tests first (TDD)** — write failing tests that describe the intended behaviour before writing any implementation code +5. **Implement** — delegate to `backend` or `web` agent, passing the relevant doc content as context; implementation is complete when all tests pass +6. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced +7. **Quality gate** — run `/project:check` before declaring done Use `/project:implement` to run this workflow end-to-end. @@ -162,11 +165,31 @@ cd backend && make swagger # regenerate docs/swagger/ after annotation changes - Accept `modifier: Modifier = Modifier` as the last defaulted parameter in all public Composables. ## Testing — non-negotiable -- **Never mock the database.** Always use Testcontainers. -- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `internal/infrastructure/database/postgres/health_repository_test.go`. -- DB integration tests live in `internal/infrastructure/database/postgres/` (`package postgres`). -- Handler unit tests live in `internal/transport/handlers/` and may use mock use cases — that is not mocking the database. -- Docker must be running for integration tests. + +**Test-Driven Development is the required approach.** Write failing tests first; make them pass; then refactor. + +### Backend (Go) +- **Never mock the database.** Always use Testcontainers (real PostgreSQL, real Redis). +- Follow the `TestMain` + `mustStartPostgresContainer()` pattern from `internal/infrastructure/database/postgres/health_repository_test.go`. +- DB/cache integration tests live in their respective `infrastructure/` package. +- Handler unit tests live in `internal/transport/handlers/` and may use mock use case interfaces — that is not mocking the database. +- Usecase unit tests live in `internal/usecase/` and mock the repository interface (not the database). +- Docker must be running for any integration test. +- See `backend/docs/testing.md` for full patterns. + +### Web (Next.js) +- **Unit/component tests:** Vitest + `@testing-library/react` with `jsdom` environment. Run with `pnpm test`. +- **Client Components:** test with `render()` + `screen` assertions. +- **Server Components and full-page flows:** test end-to-end with Playwright (future work; not yet set up). +- New utility functions in `lib/` **must** have Vitest unit tests. +- New Client Components **must** have rendering tests. +- See `web/docs/testing.md` for full setup and patterns. + +### Mobile (Android) +- **Unit tests** (`src/test/`): JUnit 4, JVM only, no Android framework. For pure Kotlin logic and ViewModels. +- **Instrumented tests** (`src/androidTest/`): JUnit 4 + Compose test rules (`createComposeRule()`). For Composables and Activity-level tests. +- Every new public `@Composable` function must have a corresponding instrumented test. +- See `mobile/docs/testing.md` for full patterns. ## Hard rules (hooks enforce some of these) - No secrets in committed files. @@ -175,3 +198,4 @@ cd backend && make swagger # regenerate docs/swagger/ after annotation changes - No `"use client"` without a concrete browser requirement. - No database mocks in tests. - No `any` in TypeScript. +- **No new feature without tests.** Every new route, component, utility function, use case, and Composable must ship with tests. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index c40d732..5330634 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -50,16 +50,28 @@ Read the relevant doc before implementing. These are kept in sync with the code ## Testing instructions -All database tests run against a **real PostgreSQL instance** via Testcontainers. Docker must be running. +**TDD is required.** Write failing tests first, then implement. + +All database and cache tests run against **real instances** via Testcontainers. Docker must be running. ```bash -make test # unit + integration -make itest # integration only -go test ./internal/database -v -run TestHealth # single test +make test # unit + integration (requires Docker) +make itest # integration only +go test ./internal/usecase/... -v # usecase unit tests (no Docker needed) +go test ./internal/transport/handlers/... -v # handler unit tests (no Docker needed) +go test ./internal/infrastructure/... -v # DB + cache integration tests (requires Docker) ``` -Adding a new test: follow the `TestMain` + `mustStartPostgresContainer()` pattern in `internal/database/database_test.go`. -Use table-driven tests for multiple input cases. +### Test placement +| Layer | Package | What is mocked | +|---|---|---| +| `usecase/` | `package usecase` | Repository interfaces (not the DB) | +| `transport/handlers/` | `package handlers` | Use case interfaces | +| `infrastructure/database/postgres/` | `package postgres` | Nothing — real DB via Testcontainers | +| `infrastructure/cache/redis/` | `package redis` | Nothing — real Redis via Testcontainers | +| `bootstrap/` | `package bootstrap` | Pinger interface; env vars via `t.Setenv` | + +See `backend/docs/testing.md` for full patterns including `TestMain`, table-driven tests, and mock examples. --- diff --git a/backend/Makefile b/backend/Makefile index ac865e8..d3dd3dd 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -27,7 +27,7 @@ test: # Integration tests (requires Docker) itest: @echo "Running integration tests..." - @go test ./internal/infrastructure/database/postgres/... -v + @go test ./internal/infrastructure/... -v # Clean the binary clean: diff --git a/backend/docs/_index.md b/backend/docs/_index.md index 067ca94..8d476a2 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -9,6 +9,6 @@ The `docs` agent reads this index first to locate the right file before diving i | Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` | | Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `internal/infrastructure/database/migrations/`, `Makefile` | | HTTP routing & handler patterns | [routing.md](routing.md) | `internal/handler/handler.go`, `internal/handler/routes.go`, `internal/handler/hello_handler.go`, `internal/handler/health_handler.go`, `internal/server/server.go` | -| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go` | +| Testing patterns (unit, handler, Redis, bootstrap) | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go`, `internal/transport/handlers/health_handler_test.go`, `internal/transport/middleware/logger_test.go`, `internal/usecase/health_usecase_test.go`, `internal/infrastructure/cache/redis/cache_test.go`, `internal/bootstrap/bootstrap_test.go` | | Error handling conventions | [error-handling.md](error-handling.md) | `internal/repository/postgres/health_repository.go`, `internal/handler/health_handler.go`, `cmd/api/main.go` | | Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/repository/postgres/db.go` | diff --git a/backend/docs/testing.md b/backend/docs/testing.md index fb205dd..ccbac21 100644 --- a/backend/docs/testing.md +++ b/backend/docs/testing.md @@ -4,15 +4,282 @@ last_verified: 2026-06-15 sources: - internal/infrastructure/database/postgres/health_repository_test.go - internal/transport/handlers/hello_handler_test.go + - internal/transport/handlers/health_handler_test.go + - internal/transport/middleware/logger_test.go + - internal/usecase/health_usecase_test.go + - internal/infrastructure/cache/redis/cache_test.go + - internal/bootstrap/bootstrap_test.go --- # Testing ## Philosophy -**No mocks for the database.** All DB tests run against a real PostgreSQL instance spun up by Testcontainers. This catches schema mismatches, query errors, and type coercion issues that mocks hide. -## Testcontainers setup -`mustStartPostgresContainer()` starts a `postgres:latest` container, calls `NewPostgresDB(cfg)` with the container's mapped host/port, and assigns the result to the package-level `var testDB *sql.DB`. +No mocks for the database or cache. All infrastructure tests run against real services spun up by Testcontainers. This catches schema mismatches, query errors, and type coercion issues that mocks hide. + +Tests MUST be independent. No test may rely on execution order or on state left by another test. Each test is responsible for creating its own fixtures. + +## Test placement + +| Layer | Package | Mock strategy | +|---|---|---| +| `usecase/` | `package usecase` | Local mock of repository interface | +| `transport/handlers/` | `package handlers` | Local mock of use case interface | +| `transport/middleware/` | `package middleware` | None | +| `infrastructure/database/postgres/` | `package postgres` | None — Testcontainers | +| `infrastructure/cache/redis/` | `package redis` | None — Testcontainers | +| `bootstrap/` | `package bootstrap` | Mock Pinger, `t.Setenv` | + +--- + +## Usecase unit tests + +Location: `internal/usecase/` +Package: `package usecase` +No Docker required. + +Define a local struct implementing the repository interface in the same `_test.go` file. Use table-driven tests. + +```go +// mockHealthReader is a local test double implementing HealthReader. +type mockHealthReader struct { + stats domain.HealthStats + err error +} + +func (m *mockHealthReader) Health(_ context.Context) (domain.HealthStats, error) { + return m.stats, m.err +} + +func TestGetHealth_Messages(t *testing.T) { + tests := []struct { + name string + input domain.HealthStats + wantMsg string + }{ + {"default healthy message", domain.HealthStats{OpenConnections: 5}, "It's healthy"}, + {"high open connections", domain.HealthStats{OpenConnections: 41}, "The database is experiencing heavy load."}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + uc := NewHealthUseCase(&mockHealthReader{stats: tc.input}) + got, err := uc.GetHealth(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Message != tc.wantMsg { + t.Errorf("message mismatch\n got: %q\n want: %q", got.Message, tc.wantMsg) + } + }) + } +} + +func TestGetHealth_RepoErrorPropagates(t *testing.T) { + sentinel := errors.New("db unavailable") + uc := NewHealthUseCase(&mockHealthReader{err: sentinel}) + _, err := uc.GetHealth(context.Background()) + if !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got: %v", err) + } +} +``` + +--- + +## Handler unit tests + +Location: `internal/transport/handlers/` +Package: `package handlers` +No Docker required. + +Set `gin.SetMode(gin.TestMode)` once in an `init()` function so all tests in the package share it. Define a local struct implementing the use case interface. + +```go +func init() { + gin.SetMode(gin.TestMode) +} + +// mockHealthUC is a local test double implementing usecase.HealthUseCase. +type mockHealthUC struct { + stats domain.HealthStats + err error +} + +func (m *mockHealthUC) GetHealth(_ context.Context) (domain.HealthStats, error) { + return m.stats, m.err +} + +func TestHealthHandler_Success(t *testing.T) { + want := domain.HealthStats{Status: "up", Message: "It's healthy"} + h := NewHandler(&mockHealthUC{stats: want}) + + r := gin.New() + r.GET("/health", h.HealthHandler) + + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } +} + +func TestHealthHandler_ServiceUnavailable(t *testing.T) { + h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}) + + r := gin.New() + r.GET("/health", h.HealthHandler) + + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("expected status 503, got %d", rr.Code) + } +} +``` + +--- + +## Redis cache integration tests + +Location: `internal/infrastructure/cache/redis/` +Package: `package redis` +Requires Docker. + +Uses `testcontainers.GenericContainer` with `redis:7-alpine`. The same `TestMain` + `mustStart*` pattern as PostgreSQL tests applies. + +```go +var testCache usecase.CacheService + +func mustStartRedisContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { + ctx := context.Background() + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "redis:7-alpine", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + }, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start redis container: %w", err) + } + + host, _ := container.Host(ctx) + port, _ := container.MappedPort(ctx, "6379/tcp") + + cache, err := New(fmt.Sprintf("redis://%s:%s", host, port.Port())) + if err != nil { + return container.Terminate, fmt.Errorf("new redis cache: %w", err) + } + testCache = cache + + return container.Terminate, nil +} + +func TestMain(m *testing.M) { + teardown, err := mustStartRedisContainer() + if err != nil { + log.Fatalf("could not start redis container: %v", err) + } + m.Run() + if teardown != nil { + if err := teardown(context.Background()); err != nil { + log.Fatalf("could not teardown redis container: %v", err) + } + } +} +``` + +Each test operates on distinct keys so tests remain independent of each other: + +```go +func TestSetAndGet(t *testing.T) { + ctx := context.Background() + if err := testCache.Set(ctx, "test:set-get", "hello", time.Minute); err != nil { + t.Fatalf("Set() returned error: %v", err) + } + val, found, err := testCache.Get(ctx, "test:set-get") + if err != nil { + t.Fatalf("Get() returned error: %v", err) + } + if !found || val != "hello" { + t.Errorf("expected found=true and val=%q, got found=%v val=%q", "hello", found, val) + } +} +``` + +--- + +## Bootstrap unit tests + +Location: `internal/bootstrap/` +Package: `package bootstrap` +No Docker required. + +Tests access unexported functions (`loadConfig`, `validateConfig`, `jitteredBackoff`, `probeWithRetry`) directly because the test is in `package bootstrap`. Use `t.Setenv()` to set env vars — Go restores them automatically after each test. Suppress log output with `slog.NewTextHandler(io.Discard, nil)`. Mock the `Pinger` interface locally. + +```go +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestLoadConfig_DefaultPort(t *testing.T) { + t.Setenv("PORT", "") + cfg := loadConfig() + if cfg.Port != 8080 { + t.Errorf("expected default port 8080, got %d", cfg.Port) + } +} + +func TestValidateConfig_AllMissing(t *testing.T) { + err := validateConfig(Config{}, discardLogger()) + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T: %v", err, err) + } + if len(cfgErr.Issues) != 5 { + t.Errorf("expected 5 issues, got %d: %v", len(cfgErr.Issues), cfgErr.Issues) + } +} + +// mockPinger is a local test double implementing Pinger. +type mockPinger struct { + results []error + calls int +} + +func (m *mockPinger) PingContext(_ context.Context) error { + if m.calls >= len(m.results) { + return m.results[len(m.results)-1] + } + err := m.results[m.calls] + m.calls++ + return err +} + +func TestProbeWithRetry_SuccessAfterRetries(t *testing.T) { + sentinel := errors.New("not ready") + p := &mockPinger{results: []error{sentinel, sentinel, nil}} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := probeWithRetry(ctx, "test-service", p, discardLogger()); err != nil { + t.Errorf("expected nil after eventual success, got: %v", err) + } +} +``` + +--- + +## Postgres Testcontainers setup + +`mustStartPostgresContainer()` starts a `postgres:latest` container, calls `NewPostgresDB(cfg)` with the mapped host/port, and assigns the result to the package-level `var testDB *sql.DB`. ```go var testDB *sql.DB @@ -30,18 +297,13 @@ func mustStartPostgresContainer() (func(context.Context, ...testcontainers.Termi WithStartupTimeout(5*time.Second), ), ) - // resolves host and mapped port from container + // ...resolve host and mapped port from container... cfg := DBConfig{Host: dbHost, Port: dbPort.Port(), ...} db, err := NewPostgresDB(cfg) testDB = db return container.Terminate, nil } -``` -## TestMain pattern -All integration test files use `TestMain` to start/stop the container once per test run: - -```go func TestMain(m *testing.M) { teardown, err := mustStartPostgresContainer() if err != nil { @@ -54,66 +316,25 @@ func TestMain(m *testing.M) { } ``` -## Package placement -Tests live in the **same package** as the code under test. -- Repository tests: `package postgres` in `internal/infrastructure/database/postgres/` -- Handler tests: `package handlers` in `internal/transport/handlers/` - -## Handler unit tests -Handlers that have no DB dependency (e.g. `HelloWorldHandler`) use `httptest` without Testcontainers: - -```go -func TestHelloWorldHandler(t *testing.T) { - h := &Handler{} - r := gin.New() - r.GET("/", h.HelloWorldHandler) +## Adding a new integration test - req, _ := http.NewRequest("GET", "/", nil) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) +1. Add a `TestXxx(t *testing.T)` function in a `_test.go` file under `internal/infrastructure/database/postgres/` (same package). +2. Construct the repository under test using `testDB`: e.g. `repo := NewHealthRepository(testDB)`. +3. Call repository methods directly and assert on the results. +4. Use table-driven tests for multiple cases. +5. Each test must set up its own data and not assume anything from other tests. - if rr.Code != http.StatusOK { ... } -} -``` +--- ## Running tests + ```bash make test # unit + integration (requires Docker) make itest # integration only — runs ./internal/infrastructure/database/postgres/... go test ./internal/infrastructure/database/postgres/... -v -run TestHealth # single test ``` -## Adding a new integration test -1. Add a `TestXxx(t *testing.T)` function in a `_test.go` file under `internal/infrastructure/database/postgres/` (same package). -2. Construct the repository under test using `testDB`: e.g. `repo := NewHealthRepository(testDB)`. -3. Call repository methods directly and assert on the results. -4. Use table-driven tests for multiple cases: - -```go -func TestGetUser(t *testing.T) { - repo := NewUserRepository(testDB) - tests := []struct { - name string - id int64 - wantErr bool - }{ - {"valid user", 1, false}, - {"missing user", 999, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := repo.GetUser(context.Background(), tt.id) - if (err != nil) != tt.wantErr { - t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} -``` - -## Test ordering note -`internal/repository/postgres/health_repository_test.go` relies on test ordering: `TestNew` → `TestHealth` → `TestClose`. `TestClose` closes `testDB`; any test added after it that uses `testDB` will fail. - ## Requirements -- Docker must be running for any integration test. + +- Docker must be running for any integration test (Postgres and Redis containers). - `go test` runs all tests including integration. Use build tags if you need to separate them in the future. diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..043e8ae --- /dev/null +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,289 @@ +package bootstrap + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + "time" + + "backend/internal/infrastructure/database/postgres" +) + +// discardLogger returns a *slog.Logger that discards all output. +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// --------------------------------------------------------------------------- +// loadConfig +// --------------------------------------------------------------------------- + +func TestLoadConfig_DefaultPort(t *testing.T) { + t.Setenv("PORT", "") + cfg := loadConfig() + if cfg.Port != 8080 { + t.Errorf("expected default port 8080, got %d", cfg.Port) + } +} + +func TestLoadConfig_ExplicitPort(t *testing.T) { + t.Setenv("PORT", "9090") + cfg := loadConfig() + if cfg.Port != 9090 { + t.Errorf("expected port 9090, got %d", cfg.Port) + } +} + +func TestLoadConfig_DefaultSchema(t *testing.T) { + t.Setenv("BLUEPRINT_DB_SCHEMA", "") + cfg := loadConfig() + if cfg.DB.Schema != "public" { + t.Errorf("expected default schema %q, got %q", "public", cfg.DB.Schema) + } +} + +func TestLoadConfig_ExplicitSchema(t *testing.T) { + t.Setenv("BLUEPRINT_DB_SCHEMA", "myschema") + cfg := loadConfig() + if cfg.DB.Schema != "myschema" { + t.Errorf("expected schema %q, got %q", "myschema", cfg.DB.Schema) + } +} + +func TestLoadConfig_DefaultSSLMode(t *testing.T) { + t.Setenv("BLUEPRINT_DB_SSLMODE", "") + cfg := loadConfig() + if cfg.DB.SSLMode != "disable" { + t.Errorf("expected default sslmode %q, got %q", "disable", cfg.DB.SSLMode) + } +} + +func TestLoadConfig_ExplicitSSLMode(t *testing.T) { + t.Setenv("BLUEPRINT_DB_SSLMODE", "require") + cfg := loadConfig() + if cfg.DB.SSLMode != "require" { + t.Errorf("expected sslmode %q, got %q", "require", cfg.DB.SSLMode) + } +} + +// --------------------------------------------------------------------------- +// validateConfig +// --------------------------------------------------------------------------- + +func TestValidateConfig_AllPresent(t *testing.T) { + cfg := Config{ + Port: 8080, + DB: dbConfigFull(), + } + if err := validateConfig(cfg, discardLogger()); err != nil { + t.Errorf("expected nil error, got: %v", err) + } +} + +func TestValidateConfig_AllMissing(t *testing.T) { + cfg := Config{} // all DB fields are zero-value (empty strings) + err := validateConfig(cfg, discardLogger()) + if err == nil { + t.Fatal("expected a ConfigError, got nil") + } + + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T: %v", err, err) + } + if len(cfgErr.Issues) != 5 { + t.Errorf("expected 5 issues (one per required field), got %d: %v", len(cfgErr.Issues), cfgErr.Issues) + } +} + +func TestValidateConfig_MissingIndividualFields(t *testing.T) { + base := dbConfigFull() + + tests := []struct { + name string + mutate func(cfg *Config) + wantIssues int + issueSubstr string + }{ + { + name: "missing host", + mutate: func(c *Config) { c.DB.Host = "" }, + wantIssues: 1, + issueSubstr: "BLUEPRINT_DB_HOST", + }, + { + name: "missing port", + mutate: func(c *Config) { c.DB.Port = "" }, + wantIssues: 1, + issueSubstr: "BLUEPRINT_DB_PORT", + }, + { + name: "missing database", + mutate: func(c *Config) { c.DB.Database = "" }, + wantIssues: 1, + issueSubstr: "BLUEPRINT_DB_DATABASE", + }, + { + name: "missing username", + mutate: func(c *Config) { c.DB.Username = "" }, + wantIssues: 1, + issueSubstr: "BLUEPRINT_DB_USERNAME", + }, + { + name: "missing password", + mutate: func(c *Config) { c.DB.Password = "" }, + wantIssues: 1, + issueSubstr: "BLUEPRINT_DB_PASSWORD", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := Config{Port: 8080, DB: base} + tc.mutate(&cfg) + + err := validateConfig(cfg, discardLogger()) + if err == nil { + t.Fatal("expected error, got nil") + } + + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T", err) + } + if len(cfgErr.Issues) != tc.wantIssues { + t.Errorf("expected %d issue(s), got %d: %v", tc.wantIssues, len(cfgErr.Issues), cfgErr.Issues) + } + + found := false + for _, issue := range cfgErr.Issues { + if contains(issue, tc.issueSubstr) { + found = true + break + } + } + if !found { + t.Errorf("expected an issue mentioning %q, got: %v", tc.issueSubstr, cfgErr.Issues) + } + }) + } +} + +// --------------------------------------------------------------------------- +// jitteredBackoff +// --------------------------------------------------------------------------- + +func TestJitteredBackoff_NonNegative(t *testing.T) { + for attempt := 1; attempt <= 10; attempt++ { + d := jitteredBackoff(attempt) + if d < 0 { + t.Errorf("attempt %d: jitteredBackoff returned negative duration: %v", attempt, d) + } + } +} + +func TestJitteredBackoff_NeverExceedsMaxDelay(t *testing.T) { + for attempt := 1; attempt <= 20; attempt++ { + d := jitteredBackoff(attempt) + if d > maxDelay { + t.Errorf("attempt %d: jitteredBackoff returned %v which exceeds maxDelay %v", attempt, d, maxDelay) + } + } +} + +// --------------------------------------------------------------------------- +// probeWithRetry +// --------------------------------------------------------------------------- + +// mockPinger is a local test double implementing Pinger. +type mockPinger struct { + results []error // results[i] is returned on the (i+1)-th call; last value is repeated + calls int +} + +func (m *mockPinger) PingContext(_ context.Context) error { + if m.calls >= len(m.results) { + return m.results[len(m.results)-1] + } + err := m.results[m.calls] + m.calls++ + return err +} + +func TestProbeWithRetry_SuccessOnFirstAttempt(t *testing.T) { + p := &mockPinger{results: []error{nil}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := probeWithRetry(ctx, "test-service", p, discardLogger()) + if err != nil { + t.Errorf("expected nil error, got: %v", err) + } +} + +func TestProbeWithRetry_SuccessAfterRetries(t *testing.T) { + sentinel := errors.New("not ready") + p := &mockPinger{results: []error{sentinel, sentinel, nil}} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := probeWithRetry(ctx, "test-service", p, discardLogger()) + if err != nil { + t.Errorf("expected nil after eventual success, got: %v", err) + } +} + +func TestProbeWithRetry_FailsAfterAllAttempts(t *testing.T) { + sentinel := errors.New("always failing") + // Always return an error so all maxAttempts are exhausted. + p := &mockPinger{results: []error{sentinel}} + + // Use a deadline that is short enough that the jittered sleeps between + // retries get cancelled, preventing the test from taking O(minutes). + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := probeWithRetry(ctx, "test-service", p, discardLogger()) + if err == nil { + t.Fatal("expected error after all attempts failed, got nil") + } +} + +func TestProbeWithRetry_ContextCancelledMidRetry(t *testing.T) { + sentinel := errors.New("unavailable") + p := &mockPinger{results: []error{sentinel}} + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel immediately so the first inter-retry sleep is interrupted. + cancel() + + err := probeWithRetry(ctx, "test-service", p, discardLogger()) + if err == nil { + t.Fatal("expected error when context is cancelled, got nil") + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// dbConfigFull returns a postgres.DBConfig with all required fields populated. +func dbConfigFull() postgres.DBConfig { + return postgres.DBConfig{ + Host: "localhost", + Port: "5432", + Database: "testdb", + Username: "user", + Password: "secret", + Schema: "public", + SSLMode: "disable", + } +} + +// contains reports whether s contains substr. +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/backend/internal/infrastructure/cache/redis/cache_test.go b/backend/internal/infrastructure/cache/redis/cache_test.go new file mode 100644 index 0000000..29e4261 --- /dev/null +++ b/backend/internal/infrastructure/cache/redis/cache_test.go @@ -0,0 +1,225 @@ +package redis + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "backend/internal/usecase" +) + +var testCache usecase.CacheService + +func mustStartRedisContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { + ctx := context.Background() + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "redis:7-alpine", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + }, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start redis container: %w", err) + } + + host, err := container.Host(ctx) + if err != nil { + return container.Terminate, fmt.Errorf("container host: %w", err) + } + + port, err := container.MappedPort(ctx, "6379/tcp") + if err != nil { + return container.Terminate, fmt.Errorf("mapped port: %w", err) + } + + redisURL := fmt.Sprintf("redis://%s:%s", host, port.Port()) + + cache, err := New(redisURL) + if err != nil { + return container.Terminate, fmt.Errorf("new redis cache: %w", err) + } + testCache = cache + + return container.Terminate, nil +} + +func TestMain(m *testing.M) { + teardown, err := mustStartRedisContainer() + if err != nil { + log.Fatalf("could not start redis container: %v", err) + } + + m.Run() + + if teardown != nil { + if err := teardown(context.Background()); err != nil { + log.Fatalf("could not teardown redis container: %v", err) + } + } +} + +func TestPingContext(t *testing.T) { + err := testCache.PingContext(context.Background()) + if err != nil { + t.Errorf("PingContext() returned unexpected error: %v", err) + } +} + +func TestGet_MissingKey(t *testing.T) { + ctx := context.Background() + val, found, err := testCache.Get(ctx, "does-not-exist") + if err != nil { + t.Fatalf("Get() on missing key returned error: %v", err) + } + if found { + t.Error("Get() on missing key: expected found=false, got true") + } + if val != "" { + t.Errorf("Get() on missing key: expected empty string, got %q", val) + } +} + +func TestSetAndGet(t *testing.T) { + ctx := context.Background() + key := "test:set-get" + + if err := testCache.Set(ctx, key, "hello", time.Minute); err != nil { + t.Fatalf("Set() returned error: %v", err) + } + + val, found, err := testCache.Get(ctx, key) + if err != nil { + t.Fatalf("Get() returned error: %v", err) + } + if !found { + t.Fatal("Get() after Set(): expected found=true, got false") + } + if val != "hello" { + t.Errorf("Get() after Set(): expected %q, got %q", "hello", val) + } +} + +func TestDelete(t *testing.T) { + ctx := context.Background() + key := "test:delete" + + if err := testCache.Set(ctx, key, "to-delete", time.Minute); err != nil { + t.Fatalf("Set() returned error: %v", err) + } + + if err := testCache.Delete(ctx, key); err != nil { + t.Fatalf("Delete() returned error: %v", err) + } + + _, found, err := testCache.Get(ctx, key) + if err != nil { + t.Fatalf("Get() after Delete() returned error: %v", err) + } + if found { + t.Error("Get() after Delete(): expected found=false, got true") + } +} + +func TestExists(t *testing.T) { + ctx := context.Background() + key := "test:exists" + + exists, err := testCache.Exists(ctx, key) + if err != nil { + t.Fatalf("Exists() on missing key returned error: %v", err) + } + if exists { + t.Error("Exists() on missing key: expected false, got true") + } + + if err := testCache.Set(ctx, key, "present", time.Minute); err != nil { + t.Fatalf("Set() returned error: %v", err) + } + + exists, err = testCache.Exists(ctx, key) + if err != nil { + t.Fatalf("Exists() after Set() returned error: %v", err) + } + if !exists { + t.Error("Exists() after Set(): expected true, got false") + } +} + +func TestSetNX(t *testing.T) { + ctx := context.Background() + key := "test:setnx" + + // First call on a missing key must succeed. + ok, err := testCache.SetNX(ctx, key, "first", time.Minute) + if err != nil { + t.Fatalf("SetNX() first call returned error: %v", err) + } + if !ok { + t.Error("SetNX() first call: expected true (key was absent), got false") + } + + // Second call on the same key must fail (key already exists). + ok, err = testCache.SetNX(ctx, key, "second", time.Minute) + if err != nil { + t.Fatalf("SetNX() second call returned error: %v", err) + } + if ok { + t.Error("SetNX() second call: expected false (key already present), got true") + } + + // The stored value must still be the one from the first call. + val, found, err := testCache.Get(ctx, key) + if err != nil { + t.Fatalf("Get() after SetNX() returned error: %v", err) + } + if !found { + t.Fatal("Get() after SetNX(): expected found=true") + } + if val != "first" { + t.Errorf("Get() after SetNX(): expected %q, got %q", "first", val) + } +} + +func TestClose(t *testing.T) { + // Create a separate cache instance so closing it doesn't affect other tests. + // The container is still running; we just close the client connection. + ctx := context.Background() + + // Obtain the host/port from the existing test container by pinging — if + // testCache is healthy its address is reachable. We create a fresh client. + // Rather than re-introspect the container we rely on the exported New() + // constructor with the same URL. Since testCache is still open and the + // container is up, we can derive the address by parsing the internal client. + // The simpler approach: call Close on a freshly-constructed instance. + // + // We need the URL. Because New() accepts a URL string we capture it indirectly: + // ping testCache to confirm it's still healthy, then create a second client + // that we can safely close. + if err := testCache.PingContext(ctx); err != nil { + t.Fatalf("testCache is not healthy before Close test: %v", err) + } + + // Reconstruct the URL from the container — we do this by casting to the + // concrete type to reach the underlying redis.Client options. + cs, ok := testCache.(*cacheService) + if !ok { + t.Fatal("testCache is not *cacheService") + } + addr := cs.client.Options().Addr + secondCache, err := New("redis://" + addr) + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + + if err := secondCache.Close(); err != nil { + t.Errorf("Close() returned unexpected error: %v", err) + } +} diff --git a/backend/internal/transport/handlers/health_handler_test.go b/backend/internal/transport/handlers/health_handler_test.go new file mode 100644 index 0000000..2669911 --- /dev/null +++ b/backend/internal/transport/handlers/health_handler_test.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "backend/internal/domain" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// mockHealthUC is a local test double implementing usecase.HealthUseCase. +type mockHealthUC struct { + stats domain.HealthStats + err error +} + +func (m *mockHealthUC) GetHealth(_ context.Context) (domain.HealthStats, error) { + return m.stats, m.err +} + +func TestHealthHandler_Success(t *testing.T) { + want := domain.HealthStats{ + Status: "up", + Message: "It's healthy", + } + h := NewHandler(&mockHealthUC{stats: want}) + + r := gin.New() + r.GET("/health", h.HealthHandler) + + req, err := http.NewRequest(http.MethodGet, "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } +} + +func TestHealthHandler_ServiceUnavailable(t *testing.T) { + h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}) + + r := gin.New() + r.GET("/health", h.HealthHandler) + + req, err := http.NewRequest(http.MethodGet, "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("expected status 503, got %d", rr.Code) + } +} diff --git a/backend/internal/transport/middleware/logger_test.go b/backend/internal/transport/middleware/logger_test.go new file mode 100644 index 0000000..1269d01 --- /dev/null +++ b/backend/internal/transport/middleware/logger_test.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) + // Discard all slog output so test logs stay clean. + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +// newRouter returns a gin.Engine with the Logger middleware attached and a +// single GET route at path that calls the provided handler. +func newRouter(path string, handler gin.HandlerFunc) *gin.Engine { + r := gin.New() + r.Use(Logger()) + r.GET(path, handler) + return r +} + +func TestLogger_PassesThroughResponse(t *testing.T) { + tests := []struct { + name string + handlerStatus int + wantStatusCode int + }{ + {"2xx passes through", http.StatusOK, http.StatusOK}, + {"4xx passes through", http.StatusNotFound, http.StatusNotFound}, + {"5xx passes through", http.StatusInternalServerError, http.StatusInternalServerError}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := newRouter("/test", func(c *gin.Context) { + c.Status(tc.handlerStatus) + }) + + req, err := http.NewRequest(http.MethodGet, "/test", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != tc.wantStatusCode { + t.Errorf("expected status %d, got %d", tc.wantStatusCode, rr.Code) + } + }) + } +} + +func TestLogger_WithQueryString(t *testing.T) { + r := newRouter("/search", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req, err := http.NewRequest(http.MethodGet, "/search?q=hello&limit=10", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + + // Must not panic. + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } +} + +func TestLogger_WithGinErrors(t *testing.T) { + r := newRouter("/err", func(c *gin.Context) { + // Attach a gin error so the middleware's error-logging branch is exercised. + _ = c.Error(http.ErrBodyNotAllowed) + c.Status(http.StatusBadRequest) + }) + + req, err := http.NewRequest(http.MethodGet, "/err", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + + // Must not panic even with c.Errors populated. + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + } +} + +func TestLogger_DoesNotModifyBody(t *testing.T) { + r := newRouter("/body", func(c *gin.Context) { + c.String(http.StatusOK, "hello") + }) + + req, err := http.NewRequest(http.MethodGet, "/body", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if body := rr.Body.String(); body != "hello" { + t.Errorf("expected body %q, got %q", "hello", body) + } +} diff --git a/backend/internal/usecase/health_usecase_test.go b/backend/internal/usecase/health_usecase_test.go new file mode 100644 index 0000000..808b2a4 --- /dev/null +++ b/backend/internal/usecase/health_usecase_test.go @@ -0,0 +1,110 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "backend/internal/domain" +) + +// mockHealthReader is a local test double implementing HealthReader. +type mockHealthReader struct { + stats domain.HealthStats + err error +} + +func (m *mockHealthReader) Health(_ context.Context) (domain.HealthStats, error) { + return m.stats, m.err +} + +func TestGetHealth_Messages(t *testing.T) { + tests := []struct { + name string + input domain.HealthStats + wantMsg string + }{ + { + name: "default healthy message", + input: domain.HealthStats{OpenConnections: 5}, + wantMsg: "It's healthy", + }, + { + name: "high open connections", + input: domain.HealthStats{OpenConnections: 41}, + wantMsg: "The database is experiencing heavy load.", + }, + { + name: "exactly 40 connections is not heavy load", + input: domain.HealthStats{OpenConnections: 40}, + wantMsg: "It's healthy", + }, + { + name: "high wait count", + input: domain.HealthStats{OpenConnections: 5, WaitCount: 1001}, + wantMsg: "The database has a high number of wait events, indicating potential bottlenecks.", + }, + { + name: "exactly 1000 wait count is not a bottleneck", + input: domain.HealthStats{OpenConnections: 5, WaitCount: 1000}, + wantMsg: "It's healthy", + }, + { + name: "max idle closed exceeds half of open connections", + // OpenConnections=10, int64(10)/2 = 5; MaxIdleClosed=6 > 5 → triggers + input: domain.HealthStats{OpenConnections: 10, MaxIdleClosed: 6}, + wantMsg: "Many idle connections are being closed, consider revising the connection pool settings.", + }, + { + name: "max idle closed does not exceed half of open connections", + // OpenConnections=10, int64(10)/2 = 5; MaxIdleClosed=5 is not > 5 + input: domain.HealthStats{OpenConnections: 10, MaxIdleClosed: 5}, + wantMsg: "It's healthy", + }, + { + name: "max lifetime closed exceeds half of open connections", + // OpenConnections=10, int64(10)/2 = 5; MaxLifetimeClosed=6 > 5 → triggers + input: domain.HealthStats{OpenConnections: 10, MaxLifetimeClosed: 6}, + wantMsg: "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern.", + }, + { + name: "max lifetime closed does not exceed half of open connections", + // OpenConnections=10, int64(10)/2 = 5; MaxLifetimeClosed=5 is not > 5 + input: domain.HealthStats{OpenConnections: 10, MaxLifetimeClosed: 5}, + wantMsg: "It's healthy", + }, + { + // The conditions are evaluated in sequence and the last matching one wins. + // With OpenConnections=0, int64(0)/2=0 so MaxLifetimeClosed=1 > 0 is true. + name: "max lifetime closed branch wins over max idle closed when both trigger", + input: domain.HealthStats{OpenConnections: 0, MaxIdleClosed: 1, MaxLifetimeClosed: 1}, + wantMsg: "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + uc := NewHealthUseCase(&mockHealthReader{stats: tc.input}) + got, err := uc.GetHealth(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Message != tc.wantMsg { + t.Errorf("message mismatch\n got: %q\n want: %q", got.Message, tc.wantMsg) + } + }) + } +} + +func TestGetHealth_RepoErrorPropagates(t *testing.T) { + sentinel := errors.New("db unavailable") + uc := NewHealthUseCase(&mockHealthReader{err: sentinel}) + + _, err := uc.GetHealth(context.Background()) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got: %v", err) + } +} diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index e216c7a..994d5c0 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -93,9 +93,30 @@ mobile/ ## Testing -- Unit tests (`src/test/`): JUnit 4, no Android framework. Run with `./gradlew test`. -- Instrumented tests (`src/androidTest/`): JUnit 4 + Espresso + Compose test rules. Run with `./gradlew connectedAndroidTest` — requires a running emulator or connected device. -- No mocking of the Android framework — use `InstrumentationRegistry` for context in instrumented tests. +**TDD is required.** Write failing tests first, then implement. + +- **Unit tests** (`src/test/`): JUnit 4, JVM only. Use for pure Kotlin logic and ViewModels. Run with `./gradlew test`. +- **Instrumented tests** (`src/androidTest/`): JUnit 4 + Compose test rules. Use for Composables and Activity-level tests. Run with `./gradlew connectedAndroidTest` — requires a running emulator or connected device. +- No mocking of the Android framework — use `InstrumentationRegistry` for context. +- Every new public `@Composable` function must have a corresponding instrumented test. + +```kotlin +// Composable instrumented test pattern +@RunWith(AndroidJUnit4::class) +class GreetingTest { + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun greeting_displaysName() { + composeTestRule.setContent { + TemplateTheme { Greeting(name = "World") } + } + composeTestRule.onNodeWithText("Hello World!").assertIsDisplayed() + } +} +``` + +See [`docs/testing.md`](docs/testing.md) for the full pattern and conventions. --- diff --git a/mobile/app/src/androidTest/java/com/company/template/ExampleInstrumentedTest.kt b/mobile/app/src/androidTest/java/com/company/template/ExampleInstrumentedTest.kt deleted file mode 100644 index fc6ea13..0000000 --- a/mobile/app/src/androidTest/java/com/company/template/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.company.template - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.company.template", appContext.packageName) - } -} \ No newline at end of file diff --git a/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt b/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt new file mode 100644 index 0000000..4111b61 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt @@ -0,0 +1,37 @@ +package com.company.template + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GreetingTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun greeting_displaysName() { + composeTestRule.setContent { + TemplateTheme { + Greeting(name = "World") + } + } + composeTestRule.onNodeWithText("Hello World!").assertIsDisplayed() + } + + @Test + fun greeting_displaysAndroid() { + composeTestRule.setContent { + TemplateTheme { + Greeting(name = "Android") + } + } + composeTestRule.onNodeWithText("Hello Android!").assertIsDisplayed() + } +} diff --git a/mobile/app/src/test/java/com/company/template/ExampleUnitTest.kt b/mobile/app/src/test/java/com/company/template/ExampleUnitTest.kt deleted file mode 100644 index 43e14ad..0000000 --- a/mobile/app/src/test/java/com/company/template/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.company.template - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt b/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt new file mode 100644 index 0000000..a9c0d39 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt @@ -0,0 +1,21 @@ +package com.company.template + +import org.junit.Test +import org.junit.Assert.* + +class GreetingFormatTest { + + @Test + fun greeting_text_contains_name() { + val name = "World" + val expected = "Hello $name!" + assertEquals("Hello World!", expected) + } + + @Test + fun greeting_text_with_empty_name() { + val name = "" + val expected = "Hello $name!" + assertEquals("Hello !", expected) + } +} diff --git a/mobile/docs/_index.md b/mobile/docs/_index.md index 9adda9e..5a6f23d 100644 --- a/mobile/docs/_index.md +++ b/mobile/docs/_index.md @@ -11,4 +11,4 @@ Topic-based documentation for the Android app. Each file is kept in sync with th |---|---|---| | Jetpack Compose UI conventions | `compose-conventions.md` | `app/src/main/java/com/company/template/MainActivity.kt`, `ui/theme/` | | Activity and Compose architecture | `architecture.md` | `app/src/main/java/com/company/template/MainActivity.kt`, `app/build.gradle.kts` | -| Testing patterns | `testing.md` | `app/src/test/`, `app/src/androidTest/` | +| Testing patterns | `testing.md` | `app/src/test/java/com/company/template/GreetingFormatTest.kt`, `app/src/androidTest/java/com/company/template/GreetingTest.kt`, `app/build.gradle.kts` | diff --git a/mobile/docs/testing.md b/mobile/docs/testing.md index 90acd7f..b9ef491 100644 --- a/mobile/docs/testing.md +++ b/mobile/docs/testing.md @@ -1,15 +1,19 @@ --- topic: Testing patterns -last_verified: 2026-06-14 +last_verified: 2026-06-15 sources: - - app/src/test/java/com/company/template/ExampleUnitTest.kt - - app/src/androidTest/java/com/company/template/ExampleInstrumentedTest.kt + - app/src/test/java/com/company/template/GreetingFormatTest.kt + - app/src/androidTest/java/com/company/template/GreetingTest.kt - app/build.gradle.kts - gradle/libs.versions.toml --- # Testing patterns +## TDD mandate + +Write a failing test before writing the implementation. Every new public `@Composable` must have a corresponding instrumented test in `src/androidTest/`. + ## Two test source sets | Source set | Path | Runs on | Framework | @@ -21,11 +25,23 @@ sources: Run with `./gradlew test`. No Android framework available — test pure Kotlin logic here. +`GreetingFormatTest.kt` is the canonical example (replaces the scaffold `ExampleUnitTest.kt`): + ```kotlin -class ExampleUnitTest { +class GreetingFormatTest { + + @Test + fun greeting_text_contains_name() { + val name = "World" + val expected = "Hello $name!" + assertEquals("Hello World!", expected) + } + @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) + fun greeting_text_with_empty_name() { + val name = "" + val expected = "Hello $name!" + assertEquals("Hello !", expected) } } ``` @@ -36,26 +52,12 @@ Place unit tests in the same package as the code under test. No mocking of Andro Run with `./gradlew connectedAndroidTest`. Requires a running emulator or physically connected device. -```kotlin -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.company.template", appContext.packageName) - } -} -``` - -Use `InstrumentationRegistry.getInstrumentation().targetContext` for the app's `Context`. - -## Compose UI tests - -For UI tests, use the Compose testing library (`androidx.compose.ui:ui-test-junit4`), already included in `build.gradle.kts`. Add a `ComposeTestRule` and test composables in isolation: +`GreetingTest.kt` is the canonical Compose UI test (replaces the scaffold `ExampleInstrumentedTest.kt`): ```kotlin @RunWith(AndroidJUnit4::class) class GreetingTest { + @get:Rule val composeTestRule = createComposeRule() @@ -68,11 +70,30 @@ class GreetingTest { } composeTestRule.onNodeWithText("Hello World!").assertIsDisplayed() } + + @Test + fun greeting_displaysAndroid() { + composeTestRule.setContent { + TemplateTheme { + Greeting(name = "Android") + } + } + composeTestRule.onNodeWithText("Hello Android!").assertIsDisplayed() + } } ``` Use `createComposeRule()` (no Activity) for component tests. Use `createAndroidComposeRule()` for end-to-end tests that require the full Activity. +## New Composable checklist + +When adding a new public `@Composable`: + +1. Create the composable in the appropriate file under `app/src/main/`. +2. Add an instrumented test class in `src/androidTest/` using `createComposeRule()`. +3. Test at minimum: the composable renders its primary content given representative inputs. +4. Run `./gradlew connectedAndroidTest` to confirm the test passes on a device/emulator. + ## Dependencies Test dependencies in `build.gradle.kts`: diff --git a/web/AGENTS.md b/web/AGENTS.md index 59acf47..47c2275 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -9,11 +9,14 @@ This version has breaking changes — APIs, conventions, and file structure may ## Commands ```bash -pnpm install # install dependencies -pnpm dev # dev server → http://localhost:3000 -pnpm build # production build (also runs TypeScript check) -pnpm start # serve production build -pnpm lint # ESLint +pnpm install # install dependencies +pnpm dev # dev server → http://localhost:3000 +pnpm build # production build (also runs TypeScript check) +pnpm start # serve production build +pnpm lint # ESLint +pnpm test # Vitest — unit + component tests +pnpm test:watch # Vitest watch mode (use during TDD) +pnpm test:ui # Vitest browser UI ``` --- @@ -43,19 +46,51 @@ Read the relevant doc before implementing. These are kept in sync with the code | Server Components, data fetching, Server Actions | [`docs/data-fetching.md`](docs/data-fetching.md) | | Tailwind CSS v4, theme tokens, dark mode | [`docs/styling.md`](docs/styling.md) | | Component conventions, TypeScript rules | [`docs/components.md`](docs/components.md) | +| Testing setup, patterns, what to test | [`docs/testing.md`](docs/testing.md) | --- ## Testing instructions -No component test suite yet. Quality gate is lint + build: +**TDD is required.** Write failing tests first, then implement. +### Framework +- **Vitest** + **@testing-library/react** + **jsdom** for unit and component tests. +- `next/image` and other Next.js built-ins are aliased to lightweight mocks in `vitest.config.ts`. +- Server Component rendering requires Playwright E2E (not yet set up) — test logic separately. + +### What to test +- All functions in `lib/` must have Vitest unit tests. +- All Client Components (`"use client"`) must have render tests. +- Server Components: extract and test any logic; rendering is covered by E2E. + +### Patterns +```tsx +// Component test +import { render, screen } from '@testing-library/react' +import MyButton from '@/components/MyButton' + +it('renders label', () => { + render() + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() +}) + +// Utility test +import { formatDate } from '@/lib/format' + +it('formats ISO date', () => { + expect(formatDate('2026-01-15')).toBe('Jan 15, 2026') +}) +``` + +### Quality gate — all must pass before committing ```bash -pnpm lint # must pass — no ESLint errors -pnpm build # must pass — no TypeScript errors, no build failures +pnpm test # no failing tests +pnpm lint # no ESLint errors +pnpm build # no TypeScript errors, no build failures ``` -Both must pass before committing or opening a PR. +See [`docs/testing.md`](docs/testing.md) for full setup details and conventions. --- diff --git a/web/__mocks__/next/image.tsx b/web/__mocks__/next/image.tsx new file mode 100644 index 0000000..5ccbf89 --- /dev/null +++ b/web/__mocks__/next/image.tsx @@ -0,0 +1,13 @@ +import type { ImageProps } from 'next/image' + +export default function MockImage({ src, alt, width, height, ...props }: ImageProps) { + return ( + {alt} + ) +} diff --git a/web/__mocks__/next/link.tsx b/web/__mocks__/next/link.tsx new file mode 100644 index 0000000..e51780d --- /dev/null +++ b/web/__mocks__/next/link.tsx @@ -0,0 +1,14 @@ +import type { LinkProps } from 'next/link' +import type { ReactNode } from 'react' + +export default function MockLink({ + href, + children, + ...props +}: LinkProps & { children?: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/web/__tests__/page.test.tsx b/web/__tests__/page.test.tsx new file mode 100644 index 0000000..a61e361 --- /dev/null +++ b/web/__tests__/page.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import Home from '../app/page' + +describe('Home page', () => { + it('renders the getting-started heading', () => { + render() + expect(screen.getByText(/get started/i)).toBeInTheDocument() + }) + + it('renders the Deploy Now link', () => { + render() + expect(screen.getByText('Deploy Now')).toBeInTheDocument() + }) + + it('renders the Documentation link', () => { + render() + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) +}) diff --git a/web/docs/_index.md b/web/docs/_index.md index be94f12..3f38c07 100644 --- a/web/docs/_index.md +++ b/web/docs/_index.md @@ -9,3 +9,4 @@ The `docs` agent reads this index first to locate the right file. | Data fetching patterns | [data-fetching.md](data-fetching.md) | `app/page.tsx`, `app/layout.tsx` | | Styling with Tailwind CSS v4 | [styling.md](styling.md) | `app/globals.css`, `postcss.config.mjs` | | Component conventions | [components.md](components.md) | `app/` (all component files) | +| Testing patterns | [testing.md](testing.md) | `vitest.config.ts`, `vitest.setup.ts`, `__tests__/page.test.tsx` | diff --git a/web/docs/testing.md b/web/docs/testing.md new file mode 100644 index 0000000..005b1a0 --- /dev/null +++ b/web/docs/testing.md @@ -0,0 +1,152 @@ +--- +topic: testing +last_verified: 2026-06-15 +sources: + - vitest.config.ts + - vitest.setup.ts + - __tests__/page.test.tsx +--- + +# Testing + +## Framework + +Vitest v4 + @testing-library/react v16 + jsdom v29. + +| Package | Version | Role | +|---|---|---| +| `vitest` | ^4.1.8 | Test runner and assertion library | +| `@testing-library/react` | ^16.3.2 | Component rendering utilities | +| `@testing-library/jest-dom` | ^6.9.1 | DOM matchers (`toBeInTheDocument`, etc.) | +| `jsdom` | ^29.1.1 | Browser environment for Node | +| `@vitejs/plugin-react` | ^6.0.2 | JSX transform for Vitest | + +## Commands + +```bash +pnpm test # run all tests once (CI) +pnpm test:watch # watch mode (TDD inner loop) +pnpm test:ui # browser UI +``` + +These map to the `scripts` in `package.json`: +- `test` → `vitest run` +- `test:watch` → `vitest` +- `test:ui` → `vitest --ui` + +## Directory structure + +``` +__tests__/ # route-level and page-level tests + page.test.tsx +__mocks__/next/ # Next.js built-in mocks (image.tsx, link.tsx) — aliased in vitest.config.ts +vitest.config.ts # test config: jsdom env, @vitejs/plugin-react, next/* aliases +vitest.setup.ts # imports @testing-library/jest-dom matchers +``` + +Component and utility tests co-locate with their source file: +``` +components/ + MyButton.tsx + MyButton.test.tsx # co-located component test +lib/ + format.ts + format.test.ts # co-located utility test +``` + +## Configuration + +`vitest.config.ts`: +```ts +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + 'next/image': path.resolve(__dirname, './__mocks__/next/image.tsx'), + 'next/link': path.resolve(__dirname, './__mocks__/next/link.tsx'), + }, + }, +}) +``` + +`vitest.setup.ts`: +```ts +import '@testing-library/jest-dom' +``` + +## Next.js mock aliases + +`vitest.config.ts` maps `next/image` and `next/link` to files under `__mocks__/next/`. These mocks render real `` and `` elements so jsdom can match against them. Create these files when a component imports either module. + +## What to test and where + +| Subject | Location | Pattern | +|---|---|---| +| `lib/` functions | Co-located `lib/foo.test.ts` | Pure unit — no DOM, no `render` | +| Client Components (`"use client"`) | Co-located `ComponentName.test.tsx` | `render` + `screen` queries | +| Route-level pages | `__tests__/.test.tsx` | Renders the component in jsdom | +| Server Components | Extract logic to `lib/`; test that | Full rendering via Playwright (not yet set up) | + +## Patterns + +### Page / route test + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import Home from '../app/page' + +describe('Home page', () => { + it('renders the getting-started heading', () => { + render() + expect(screen.getByText(/get started/i)).toBeInTheDocument() + }) + + it('renders the Deploy Now link', () => { + render() + expect(screen.getByText('Deploy Now')).toBeInTheDocument() + }) +}) +``` + +### Client Component test + +```tsx +import { render, screen } from '@testing-library/react' +import MyButton from '@/components/MyButton' + +it('renders label', () => { + render() + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() +}) +``` + +### Utility function test + +```ts +import { formatDate } from '@/lib/format' + +it('formats ISO date', () => { + expect(formatDate('2026-01-15')).toBe('Jan 15, 2026') +}) +``` + +## TDD mandate + +Write a failing test before writing the implementation. `pnpm test:watch` is the inner loop — keep it running while developing. + +## Quality gate + +All three must pass before committing: + +```bash +pnpm test # no failing tests +pnpm lint # no ESLint errors +pnpm build # no TypeScript errors, no build failures +``` diff --git a/web/package.json b/web/package.json index e14f1dd..7a00251 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" }, "dependencies": { "next": "16.2.9", @@ -15,12 +18,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9", "eslint-config-next": "16.2.9", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.8" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cd7c30d..8abfa27 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^20 version: 20.19.43 @@ -30,25 +36,52 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)) eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.2.9 version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 tailwindcss: specifier: ^4 version: 4.3.1 typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -104,6 +137,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -116,6 +153,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -166,6 +243,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + 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 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -415,9 +501,107 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -509,9 +693,41 @@ packages: '@tailwindcss/postcss@4.3.1': resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -701,6 +917,48 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -714,13 +972,24 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -757,6 +1026,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -788,6 +1061,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -823,6 +1099,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -847,12 +1127,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -882,6 +1173,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -893,6 +1187,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -901,6 +1199,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -915,6 +1219,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -931,6 +1239,9 @@ packages: resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1071,10 +1382,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1123,6 +1441,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1216,6 +1539,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1232,6 +1559,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1307,6 +1638,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1364,6 +1698,15 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1486,9 +1829,17 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1496,6 +1847,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1504,6 +1858,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1591,6 +1949,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1611,6 +1973,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1622,6 +1987,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1649,6 +2017,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1667,10 +2039,17 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1679,6 +2058,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1695,6 +2078,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1710,6 +2098,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1762,6 +2154,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1769,6 +2164,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1800,6 +2201,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1825,6 +2230,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} @@ -1832,14 +2240,40 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1891,6 +2325,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -1903,6 +2341,106 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1924,10 +2462,22 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1946,8 +2496,30 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2025,6 +2597,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2048,6 +2622,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2115,6 +2717,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2298,8 +2902,63 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2373,11 +3032,50 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -2557,6 +3255,52 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -2570,12 +3314,20 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -2645,6 +3397,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2663,6 +3417,10 @@ snapshots: baseline-browser-mapping@2.10.37: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -2705,6 +3463,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2728,10 +3488,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -2758,6 +3532,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2772,12 +3548,18 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2793,6 +3575,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -2873,6 +3657,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3101,8 +3887,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3149,6 +3941,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3246,6 +4041,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3257,6 +4058,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3340,6 +4143,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3400,6 +4205,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3495,16 +4326,22 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3512,6 +4349,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3605,6 +4444,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3632,12 +4473,18 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3660,6 +4507,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3677,8 +4530,15 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.4: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -3699,6 +4559,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3714,6 +4576,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3737,6 +4620,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -3831,10 +4718,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3893,6 +4786,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): @@ -3908,19 +4805,41 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.1: {} tapable@2.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3993,6 +4912,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.27.2: {} + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -4030,6 +4951,62 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4075,8 +5052,17 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..a8993ab --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + 'next/image': path.resolve(__dirname, './__mocks__/next/image.tsx'), + 'next/link': path.resolve(__dirname, './__mocks__/next/link.tsx'), + }, + }, +}) diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/web/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'