From 4fe9135ef30349538d9f640ddcf9a0c9762b5c48 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 7 Jun 2026 23:43:09 -0700 Subject: [PATCH 01/16] fix: comprehensive audit remediation across app, SDKs, CI, and config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - BC-1: Replace btree FTS index with GIN for full-text search performance - BC-2: Skip session DB lookup on /v1/ ingest paths - BC-3: Fix dead 5xx error sanitization ternary in error-handler - BC-4: Isolate per-listener errors in event bus emit loops - BC-5: Delete dead migrate.ts (no callers) - BC-6: Mark session.ts as test-only with clear JSDoc - BC-7: Replace count+select+delete cleanup with CTE-based direct delete - BC-8/9: Drop redundant duplicate indexes - BC-10: Add explicit DB pool config (max/idle_timeout/connect_timeout) - BC-14/15: Graceful SIGTERM/SIGINT shutdown + cleanup interval overlap guard - BC-16: Make log.timestamp NOT NULL - BC-17: Auth secret from validated env, no hardcoded fallback - BU-1: Add depth cap (32) to OTLP recursive parser to prevent stack overflow - BU-2: Add <> to tsquery special-char strip to prevent injection 500s - BU-4: Store API keys as SHA-256 hashes; backfill migration included - BU-5/6: Fix CSV \r escaping and formula-injection whitespace bypass - BU-8/9: Remove dead OTLP exports (logLevelToSeverityNumber, dateToUnixNanoString) - BU-11: Case-insensitive content-type comparison - BU-12: Backfill now recomputes incident aggregates for existing incidents - BU-13: HEX_ID_REGEX requires at least one a-f char (pure numerics → {num}) Routes/API: - RT-1: Validate timeseries 'from' param to prevent NaN SQL error - RT-2/5: Add backpressure guard to incidents/stream; fix logs/stream teardown - RT-3: Add in-memory token-bucket rate limiting on /v1/* and login - RT-6: Stream export via ReadableStream with 500-row cursor batches - RT-7: Standardize error response shape via apiError() helper - RT-8: Skip COUNT(*) on paginated cursor requests - RT-9: Lower MIN_LIMIT from 100 to 1 - RT-11: Add CSRF check to SSE stream POST handlers - RT-14: Don't leak raw DB error in health endpoint response Frontend: - FE-1: Make SSE hook state reactive () so ConnectionStatus updates - FE-2: Use {#key projectId} to reset stream on project navigation - FE-3: Fix incident detail fetch race with request token - FE-4: Track selection by log id instead of array index (survives sorting) - FE-5: Scope scroll query to visible layout - FE-6: AbortController for timeseries refetch race - FE-8: Track and clear highlight timers on cleanup - FE-10/11: Add aria-label/title to chart segments for accessibility - FE-12-15: Remove dead exports (createLogStreamStore, formatBucketLabel, toast helpers, updateInitialFocus) - FE-16/17/19: Remove needless , void LOG_LEVELS smell, add keys to {#each} TypeScript SDK: - TS-1: Chunked flush bounded by batchSize (server limit 100) - TS-2: Add AbortSignal.timeout to prevent hung fetch stalling all flushes - TS-3: Add keepalive:true for browser page-unload survival - TS-4/5: Fix shutdown race; shutdown() returns IngestResponse|null - TS-6: Re-queue respects maxQueueSize with overflow notification - TS-7: Add config upper bound validation (batchSize<=100, etc.) - TS-9: Remove as-cast laundering in config/client - TS-10: Restrict to http/https; strip trailing slash from endpoint - TS-11: Child logger skips unused transport allocation - TS-12: Sync npm and JSR versions - TS-13: Honor Retry-After header on 429 Python SDK: - PY-1: Move on_error callbacks outside lock to prevent deadlock - PY-2: Isolate on_flush exceptions from send try/except (prevent dup delivery) - PY-3: Double-checked locking for event loop creation - PY-4: __version__ from importlib.metadata (single source of truth) - PY-5: Replace inspect.stack() with sys._getframe() (no file I/O per frame) - PY-6: Add __aenter__/__aexit__ and atexit flush hook - PY-7/8: Guard response.json(); make timeout configurable - PY-9: Normalize timestamps to Z suffix - PY-10: Child skips unused transport allocation - PY-11-14: URL/scheme hardening, deque queue, mypy 3.9, Retry-After Go SDK: - GO-1/2: Wire MaxRetries and HTTPClient into transport; add default 30s timeout - GO-3: Dispatch batch-threshold flush asynchronously - GO-4: Log() respects CaptureSourceLocation setting - GO-5: Fast-path mergeMetadata when no per-call metadata - GO-6: prepend() enforces MaxQueueSize; Shutdown documents undelivered-log risk - GO-7: errors.As instead of type assertions - GO-8: url.JoinPath + scheme validation - GO-9/10/11/12: Doc fixes, example defer Shutdown, timer generation counter CI/Config: - CI-1: Add test-migrations job running actual drizzle/*.sql files - CI-2: entrypoint.sh fails fast on migration error - CI-3/4: Run component tests and coverage in CI - CI-5: Pin bun-version to 1.2.15 across workflows - CI-6: Run Firefox in CI E2E alongside Chromium - CI-7: Add Dependabot for all SDK ecosystems - CI-9: release cancel-in-progress: false - CI-10: Scope packages:write to docker jobs only - SEC-1: Add author association gate to opencode workflow - SEC-3: Require DB_PASSWORD in compose.prod.yaml - DOCK-3: Increase healthcheck start-period to 60s - CFG-1: Delete stale vitest.workspace.ts - CFG-2: Add package-lock.json to .gitignore; delete it - CFG-3: Enable noUncheckedIndexedAccess; fix all ~238 type errors - CFG-4: Playwright stderr capture - CFG-6: Add engines field to package.json - TEST-1: Fix empty afterEach; add cleanup() call - TEST-2: New integration tests for regenerate, incident detail, csrf --- .github/dependabot.yml | 60 + .github/workflows/ci.yml | 63 +- .github/workflows/opencode.yml | 12 +- .github/workflows/release.yml | 14 +- .gitignore | 1 + Dockerfile | 3 +- compose.prod.yaml | 7 +- drizzle/0008_audit_improvements.sql | 23 + drizzle/meta/_journal.json | 7 + entrypoint.sh | 13 +- package-lock.json | 7836 ----------------- package.json | 3 + playwright.config.ts | 1 + scripts/backfill-incidents.ts | 6 + scripts/seed-admin.ts | 50 +- sdks/go/examples/basic/main.go | 6 +- sdks/go/logwell/client.go | 69 +- sdks/go/logwell/config.go | 2 +- sdks/go/logwell/queue.go | 34 +- sdks/go/logwell/transport.go | 25 +- sdks/go/logwell/types.go | 2 +- sdks/python/pyproject.toml | 2 +- sdks/python/src/logwell/__init__.py | 7 +- sdks/python/src/logwell/client.py | 33 +- sdks/python/src/logwell/config.py | 28 +- sdks/python/src/logwell/queue.py | 103 +- sdks/python/src/logwell/source_location.py | 28 +- sdks/python/src/logwell/transport.py | 36 +- sdks/python/src/logwell/types.py | 1 + sdks/typescript/jsr.json | 2 +- sdks/typescript/src/client.ts | 55 +- sdks/typescript/src/config.ts | 70 +- sdks/typescript/src/errors.ts | 1 + sdks/typescript/src/queue.ts | 71 +- sdks/typescript/src/transport.ts | 56 +- sdks/typescript/src/types.ts | 2 + src/hooks.server.ts | 23 +- .../dashboard-skeleton.component.test.ts | 8 +- .../log-detail-modal.component.test.ts | 2 +- .../__tests__/log-row.component.test.ts | 2 +- .../__tests__/log-table.component.test.ts | 52 +- .../stats-skeleton.component.test.ts | 4 +- src/lib/components/export-button.svelte | 2 +- .../components/incident-timeline-panel.svelte | 6 + src/lib/components/level-chart.svelte | 12 +- src/lib/components/log-table.svelte | 6 +- ...st.ts => use-log-stream.component.test.ts} | 4 +- src/lib/hooks/use-incident-stream.svelte.ts | 6 +- src/lib/hooks/use-log-stream.svelte.ts | 6 +- src/lib/server/auth.ts | 3 +- src/lib/server/config/env.ts | 69 +- src/lib/server/db/index.ts | 6 +- src/lib/server/db/migrate.ts | 22 - src/lib/server/db/schema.ts | 11 +- src/lib/server/db/test-db.ts | 6 +- src/lib/server/error-handler.ts | 2 +- src/lib/server/events.ts | 12 +- src/lib/server/jobs/cleanup-scheduler.ts | 18 +- src/lib/server/jobs/log-cleanup.ts | 53 +- src/lib/server/session.ts | 11 +- src/lib/server/utils/api-error.ts | 7 + src/lib/server/utils/api-key.ts | 101 +- src/lib/server/utils/content-type.ts | 2 +- src/lib/server/utils/csrf.ts | 5 + src/lib/server/utils/csv-serializer.ts | 7 +- src/lib/server/utils/cursor.ts | 8 +- src/lib/server/utils/incident-backfill.ts | 46 +- src/lib/server/utils/incident-fingerprint.ts | 3 +- src/lib/server/utils/incidents.ts | 1 + src/lib/server/utils/incidents.unit.test.ts | 6 +- src/lib/server/utils/otlp.ts | 31 +- src/lib/server/utils/otlp.unit.test.ts | 26 +- src/lib/server/utils/rate-limit.ts | 34 + src/lib/server/utils/search.ts | 25 +- .../server/utils/simple-ingest.unit.test.ts | 104 +- src/lib/shared/schemas/incident.ts | 5 +- src/lib/stores/__tests__/logs.unit.test.ts | 298 +- src/lib/stores/logs.svelte.ts | 116 - src/lib/utils/focus-trap.ts | 16 +- src/lib/utils/timeseries.ts | 30 - src/lib/utils/timeseries.unit.test.ts | 49 +- src/lib/utils/toast.ts | 23 - src/lib/utils/toast.unit.test.ts | 44 +- .../(app)/projects/[id]/+page.server.ts | 23 +- src/routes/(app)/projects/[id]/+page.svelte | 60 +- .../projects/[id]/incidents/+page.server.ts | 10 +- .../projects/[id]/incidents/+page.svelte | 63 +- .../(app)/projects/[id]/stats/+page.server.ts | 5 +- .../(app)/projects/[id]/stats/+page.svelte | 20 +- src/routes/api/health/+server.ts | 5 +- src/routes/api/projects/+server.ts | 17 +- .../api/projects/[id]/incidents/+server.ts | 24 +- .../[id]/incidents/[incidentId]/+server.ts | 3 +- .../[incidentId]/timeline/+server.ts | 3 +- .../projects/[id]/incidents/stream/+server.ts | 11 + src/routes/api/projects/[id]/logs/+server.ts | 28 +- .../api/projects/[id]/logs/export/+server.ts | 234 +- .../api/projects/[id]/logs/stream/+server.ts | 35 +- .../api/projects/[id]/regenerate/+server.ts | 5 +- .../projects/[id]/stats/timeseries/+server.ts | 7 +- .../login-navigation.component.test.ts | 4 +- src/routes/v1/ingest/+server.ts | 22 +- src/routes/v1/logs/+server.ts | 28 +- tests/fixtures/db.ts | 9 +- .../authorization.integration.test.ts | 6 +- .../incident-detail.integration.test.ts | 227 + .../incidents/incidents.integration.test.ts | 52 +- .../logs/logs-query.integration.test.ts | 26 +- .../project-detail.integration.test.ts | 4 +- .../project-rename.integration.test.ts | 8 +- .../projects/regenerate.integration.test.ts | 180 + .../projects/retention.integration.test.ts | 14 +- .../api/projects/server.integration.test.ts | 12 +- .../integration/auth/auth.integration.test.ts | 32 +- tests/integration/db/log.integration.test.ts | 26 +- .../db/project-retention.integration.test.ts | 8 +- .../db/project.integration.test.ts | 29 +- .../hooks/error-handler.integration.test.ts | 6 +- .../log-cleanup-ordering.integration.test.ts | 40 +- tests/integration/logs-pagination.test.ts | 4 +- .../integration/otlp/logs.integration.test.ts | 48 +- .../simple-ingest/logs.integration.test.ts | 44 +- .../utils/api-key.integration.test.ts | 22 +- tests/integration/utils/csrf.unit.test.ts | 131 + .../utils/incidents.integration.test.ts | 6 +- .../incidents.upsert.integration.test.ts | 2 +- .../utils/search.integration.test.ts | 16 +- tests/setup.ts | 3 +- tsconfig.json | 3 +- vitest.workspace.ts | 35 - 130 files changed, 2341 insertions(+), 9324 deletions(-) create mode 100644 drizzle/0008_audit_improvements.sql delete mode 100644 package-lock.json rename src/lib/hooks/__tests__/{use-log-stream.unit.test.ts => use-log-stream.component.test.ts} (99%) delete mode 100644 src/lib/server/db/migrate.ts create mode 100644 src/lib/server/utils/api-error.ts create mode 100644 src/lib/server/utils/rate-limit.ts create mode 100644 tests/integration/api/projects/incidents/incident-detail.integration.test.ts create mode 100644 tests/integration/api/projects/regenerate.integration.test.ts create mode 100644 tests/integration/utils/csrf.unit.test.ts delete mode 100644 vitest.workspace.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 61963bd..ee8de7f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -77,3 +77,63 @@ updates: labels: - dependencies - docker + + # TypeScript SDK + - package-ecosystem: npm + directory: /sdks/typescript + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "America/Phoenix" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps(sdk-ts)" + include: scope + labels: + - dependencies + - sdk + groups: + sdk-typescript: + patterns: + - "*" + + # Python SDK + - package-ecosystem: pip + directory: /sdks/python + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "America/Phoenix" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps(sdk-py)" + include: scope + labels: + - dependencies + - sdk + groups: + sdk-python: + patterns: + - "*" + + # Go SDK + - package-ecosystem: gomod + directory: /sdks/go + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "America/Phoenix" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps(sdk-go)" + include: scope + labels: + - dependencies + - sdk + groups: + sdk-go: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c3a5ae..433083e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ on: permissions: contents: read - packages: write # Cancel in-progress runs on same branch/PR concurrency: @@ -41,7 +40,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -79,7 +78,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -99,6 +98,12 @@ jobs: - name: Run unit tests run: bun run test:unit + - name: Run component tests + run: bun run test:component + + - name: Run tests with coverage + run: bun run test:coverage + # ============================================================================= # Integration Tests (uses PGlite in-memory PostgreSQL) # ============================================================================= @@ -114,7 +119,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -164,7 +169,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -213,7 +218,7 @@ jobs: ADMIN_PASSWORD: adminpass - name: Run E2E tests - run: bunx playwright test --project=chromium + run: bun run test:e2e --project=chromium --project=firefox - name: Upload Playwright artifacts if: failure() @@ -240,7 +245,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -269,6 +274,43 @@ jobs: # ============================================================================= # Docker Build Verification + # ============================================================================= + # Test that committed migration SQL files work against a real Postgres + # ============================================================================= + test-migrations: + name: Test Production Migrations + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: logwell_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2.15" + + - name: Install deps + run: bun install --frozen-lockfile + + - name: Test production migrations + env: + DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/logwell_test + run: bunx drizzle-kit migrate + # ============================================================================= docker-build: name: Docker Build @@ -302,6 +344,8 @@ jobs: timeout-minutes: 20 needs: [lint, test-unit, test-integration, test-e2e, build, docker-build] if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + packages: write strategy: fail-fast: false matrix: @@ -370,6 +414,8 @@ jobs: timeout-minutes: 10 needs: [docker-publish] if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + packages: write outputs: image-tags: ${{ steps.meta.outputs.tags }} image-digest: ${{ steps.digest.outputs.digest }} @@ -446,7 +492,7 @@ jobs: name: CI Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [lint, test-unit, test-integration, test-e2e, build, docker-build, docker-publish, docker-merge] + needs: [lint, test-unit, test-integration, test-e2e, test-migrations, build, docker-build, docker-publish, docker-merge] if: always() steps: @@ -457,6 +503,7 @@ jobs: [[ "${{ needs.test-unit.result }}" != "success" ]] || \ [[ "${{ needs.test-integration.result }}" != "success" ]] || \ [[ "${{ needs.test-e2e.result }}" != "success" ]] || \ + [[ "${{ needs.test-migrations.result }}" != "success" ]] || \ [[ "${{ needs.build.result }}" != "success" ]] || \ [[ "${{ needs.docker-build.result }}" != "success" ]]; then echo "One or more CI jobs failed" diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index a3ef2b1..f728191 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,10 +9,13 @@ on: jobs: opencode: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + ( + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + ) && + contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest permissions: id-token: write @@ -26,6 +29,7 @@ jobs: persist-credentials: false - name: Run opencode + # TODO: pin to SHA for supply-chain safety (e.g. anomalyco/opencode/github@<40-char-sha>) uses: anomalyco/opencode/github@latest env: FIREPASS_API_KEY: ${{ secrets.FIREPASS_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2daba16..fd25e17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,10 @@ permissions: contents: write packages: write -# Cancel in-progress releases for same tag +# Do not cancel in-progress releases — partial releases can leave inconsistent state concurrency: group: release-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false env: # PostgreSQL connection for integration tests @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -81,7 +81,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -116,7 +116,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -171,7 +171,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir @@ -247,7 +247,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Get bun cache directory id: bun-cache-dir diff --git a/.gitignore b/.gitignore index f0be618..ce64a50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +package-lock.json # Output .output diff --git a/Dockerfile b/Dockerfile index 186c96d..c9c26b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ # ----------------------------------------------------------------------------- # Stage 1: Base image with Bun runtime # ----------------------------------------------------------------------------- +# SECURITY: pin to digest for reproducible builds (e.g. oven/bun:1.2.15-alpine) FROM oven/bun:1-alpine AS base WORKDIR /app @@ -128,7 +129,7 @@ ENV PORT=3000 # Checks /api/health endpoint every 30 seconds # Allows 30 seconds for startup (migrations + seed), 10 second timeout per check # Marks unhealthy after 3 consecutive failures -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:3000/api/health || exit 1 # Start the application via entrypoint (runs migrations + seed first) diff --git a/compose.prod.yaml b/compose.prod.yaml index a631023..30a5581 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -9,8 +9,7 @@ # ORIGIN - Production URL (e.g., https://logs.example.com) # # Optional Environment Variables: -# DB_PASSWORD - Database password (default: logwell_internal_db) -# Set this if exposing port 5432 for backups +# DB_PASSWORD - Database password (required, no default for security) # PORT - App port (default: 3000) # # Usage: @@ -32,7 +31,7 @@ services: - NODE_ENV=production - HOST=0.0.0.0 - PORT=3000 - - DATABASE_URL=postgresql://logwell:${DB_PASSWORD:-logwell_internal_db}@db:5432/logwell + - DATABASE_URL=postgresql://logwell:${DB_PASSWORD:?DB_PASSWORD must be set}@db:5432/logwell - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} - ORIGIN=${ORIGIN:?ORIGIN is required} @@ -56,7 +55,7 @@ services: restart: unless-stopped environment: POSTGRES_USER: logwell - POSTGRES_PASSWORD: ${DB_PASSWORD:-logwell_internal_db} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set} POSTGRES_DB: logwell volumes: - postgres-data:/var/lib/postgresql/data diff --git a/drizzle/0008_audit_improvements.sql b/drizzle/0008_audit_improvements.sql new file mode 100644 index 0000000..dabf98d --- /dev/null +++ b/drizzle/0008_audit_improvements.sql @@ -0,0 +1,23 @@ +-- Audit improvements migration + +-- BC-1: Replace btree FTS index with GIN +DROP INDEX IF EXISTS "idx_log_search"; +CREATE INDEX "idx_log_search" ON "log" USING gin ("search"); + +-- BC-8: Drop redundant project_id index (covered by compound indexes) +DROP INDEX IF EXISTS "idx_log_project_id"; + +-- BC-9: Drop duplicate api_key index (unique constraint index already exists) +DROP INDEX IF EXISTS "idx_project_api_key"; + +-- BC-16: Make timestamp not-null (safe; defaultNow so no nulls in practice) +ALTER TABLE "log" ALTER COLUMN "timestamp" SET NOT NULL; + +-- BU-4: Add api_key_hash column for hashed API key storage +ALTER TABLE "project" ADD COLUMN IF NOT EXISTS "api_key_hash" text NOT NULL DEFAULT ''; + +-- BU-4: Backfill api_key_hash from existing api_key values using sha256 +UPDATE "project" SET "api_key_hash" = encode(sha256(api_key::bytea), 'hex') WHERE "api_key_hash" = ''; + +-- BU-4: Add unique constraint on api_key_hash +ALTER TABLE "project" ADD CONSTRAINT "project_api_key_hash_unique" UNIQUE ("api_key_hash"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 938307a..46c4f51 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1778493600000, "tag": "0007_per_user_project_names", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1749340800000, + "tag": "0008_audit_improvements", + "breakpoints": true } ] } diff --git a/entrypoint.sh b/entrypoint.sh index 49a49d9..036ba62 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,20 +5,13 @@ echo "=== Logwell startup ===" # Run database migrations echo "Running database migrations..." -if bun run drizzle-kit migrate 2>&1; then - echo "✓ Migrations completed successfully" -else - echo "✗ Migration failed, but continuing (tables may already exist)" -fi +bun run drizzle-kit migrate || { echo "Migration failed! Aborting startup."; exit 1; } +echo "✓ Migrations completed successfully" # Seed admin user (idempotent - skips if exists) if [ -n "$ADMIN_PASSWORD" ]; then echo "Seeding admin user..." - if bun run scripts/seed-admin.ts 2>&1; then - echo "✓ Admin seeding completed" - else - echo "✗ Admin seeding failed (user may already exist)" - fi + bun run db:seed || echo "Seed step failed (admin may already exist, continuing)" else echo "⚠ ADMIN_PASSWORD not set, skipping admin seed" fi diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e60fb46..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7836 +0,0 @@ -{ - "name": "logwell", - "version": "1.0.12", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "logwell", - "version": "1.0.12", - "dependencies": { - "@lucide/svelte": "^1.16.0", - "better-auth": "^1.6.11", - "clsx": "^2.1.1", - "layerchart": "^1.0.13", - "nanoid": "^5.1.11", - "postgres": "^3.4.9", - "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2", - "tw-animate-css": "^1.4.0", - "zod": "^4.4.3" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.15", - "@electric-sql/pglite": "^0.4.5", - "@internationalized/date": "^3.12.1", - "@playwright/test": "^1.60.0", - "@sveltejs/kit": "^2.60.1", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tailwindcss/vite": "^4.3.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/svelte": "^5.3.1", - "@testing-library/user-event": "^14.6.1", - "@types/d3-scale": "^4.0.9", - "@types/node": "^25.9.1", - "@vitest/browser": "^4.1.6", - "@vitest/coverage-v8": "^4.1.6", - "@vitest/ui": "^4.1.6", - "bits-ui": "^2.18.1", - "drizzle-kit": "^0.31.10", - "drizzle-orm": "^0.45.2", - "jsdom": "^29.1.1", - "knip": "^6.14.1", - "mode-watcher": "^1.1.0", - "svelte": "^5.55.8", - "svelte-adapter-bun": "^1.0.1", - "svelte-check": "^4.4.8", - "svelte-sonner": "^1.1.1", - "tailwindcss": "^4.3.0", - "typescript": "^6.0.3", - "vite": "^8.0.13", - "vitest": "^4.1.6" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "devOptional": true, - "license": "MIT", - "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" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@better-auth/core": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.11.tgz", - "integrity": "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==", - "license": "MIT", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.39.0", - "@standard-schema/spec": "^1.1.0", - "zod": "^4.3.6" - }, - "peerDependencies": { - "@better-auth/utils": "0.4.0", - "@better-fetch/fetch": "1.1.21", - "@cloudflare/workers-types": ">=4", - "@opentelemetry/api": "^1.9.0", - "better-call": "1.3.5", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@better-auth/drizzle-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.11.tgz", - "integrity": "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "drizzle-orm": "^0.45.2" - }, - "peerDependenciesMeta": { - "drizzle-orm": { - "optional": true - } - } - }, - "node_modules/@better-auth/kysely-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.11.tgz", - "integrity": "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "kysely": "^0.28.17" - }, - "peerDependenciesMeta": { - "kysely": { - "optional": true - } - } - }, - "node_modules/@better-auth/memory-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.11.tgz", - "integrity": "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0" - } - }, - "node_modules/@better-auth/mongo-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.11.tgz", - "integrity": "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "mongodb": "^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "mongodb": { - "optional": true - } - } - }, - "node_modules/@better-auth/prisma-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.11.tgz", - "integrity": "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@prisma/client": { - "optional": true - }, - "prisma": { - "optional": true - } - } - }, - "node_modules/@better-auth/telemetry": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.11.tgz", - "integrity": "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "@better-fetch/fetch": "1.1.21" - } - }, - "node_modules/@better-auth/utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", - "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" - } - }, - "node_modules/@better-fetch/fetch": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" - }, - "node_modules/@biomejs/biome": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", - "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.15", - "@biomejs/cli-darwin-x64": "2.4.15", - "@biomejs/cli-linux-arm64": "2.4.15", - "@biomejs/cli-linux-arm64-musl": "2.4.15", - "@biomejs/cli-linux-x64": "2.4.15", - "@biomejs/cli-linux-x64-musl": "2.4.15", - "@biomejs/cli-win32-arm64": "2.4.15", - "@biomejs/cli-win32-x64": "2.4.15" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", - "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", - "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", - "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", - "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", - "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", - "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", - "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", - "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@blazediff/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", - "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@dagrejs/dagre": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", - "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", - "license": "MIT", - "dependencies": { - "@dagrejs/graphlib": "2.2.4" - } - }, - "node_modules/@dagrejs/graphlib": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", - "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", - "license": "MIT", - "engines": { - "node": ">17.0.0" - } - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@electric-sql/pglite": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.5.tgz", - "integrity": "sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "devOptional": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@internationalized/date": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", - "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@layerstack/svelte-actions": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1.tgz", - "integrity": "sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.6.13", - "@layerstack/utils": "1.0.1", - "d3-array": "^3.2.4", - "d3-scale": "^4.0.2", - "date-fns": "^4.1.0", - "lodash-es": "^4.17.21" - } - }, - "node_modules/@layerstack/svelte-stores": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-stores/-/svelte-stores-1.0.2.tgz", - "integrity": "sha512-IxK0UKD0PVxg1VsyaR+n7NyJ+NlvyqvYYAp+J10lkjDQxm0yx58CaF2LBV08T22C3aY1iTlqJaatn/VHV4SoQg==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "1.0.1", - "d3-array": "^3.2.4", - "date-fns": "^4.1.0", - "immer": "^10.1.1", - "lodash-es": "^4.17.21", - "zod": "^3.24.2" - } - }, - "node_modules/@layerstack/svelte-stores/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@layerstack/tailwind": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-1.0.1.tgz", - "integrity": "sha512-nlshEkUCfaV0zYzrFXVVYRnS8bnBjs4M7iui6l/tu6NeBBlxDivIyRraJkdYGCSL1lZHi6FqacLQ3eerHtz90A==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "^1.0.1", - "clsx": "^2.1.1", - "culori": "^4.0.1", - "d3-array": "^3.2.4", - "date-fns": "^4.1.0", - "lodash-es": "^4.17.21", - "tailwind-merge": "^2.5.4", - "tailwindcss": "^3.4.15" - } - }, - "node_modules/@layerstack/tailwind/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/@layerstack/tailwind/node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@layerstack/tailwind/node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/@layerstack/tailwind/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@layerstack/tailwind/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/@layerstack/tailwind/node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/@layerstack/tailwind/node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@layerstack/utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-1.0.1.tgz", - "integrity": "sha512-sWP9b+SFMkJYMZyYFI01aLxbg2ZUrix6Tv+BCDmeOrcLNxtWFsMYAomMhALzTMHbb+Vis/ua5vXhpdNXEw8a2Q==", - "license": "MIT", - "dependencies": { - "d3-array": "^3.2.4", - "date-fns": "^4.1.0", - "lodash-es": "^4.17.21" - } - }, - "node_modules/@lucide/svelte": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz", - "integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==", - "license": "ISC", - "peerDependencies": { - "svelte": "^5" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@noble/ciphers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", - "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", - "integrity": "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.130.0.tgz", - "integrity": "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.130.0.tgz", - "integrity": "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.130.0.tgz", - "integrity": "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.130.0.tgz", - "integrity": "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.130.0.tgz", - "integrity": "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.130.0.tgz", - "integrity": "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.130.0.tgz", - "integrity": "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.130.0.tgz", - "integrity": "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.130.0.tgz", - "integrity": "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.130.0.tgz", - "integrity": "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.130.0.tgz", - "integrity": "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.130.0.tgz", - "integrity": "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.130.0.tgz", - "integrity": "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.130.0.tgz", - "integrity": "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.130.0.tgz", - "integrity": "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.130.0.tgz", - "integrity": "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.130.0.tgz", - "integrity": "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.130.0.tgz", - "integrity": "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.130.0.tgz", - "integrity": "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", - "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", - "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", - "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", - "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", - "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", - "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", - "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", - "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", - "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", - "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", - "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", - "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", - "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", - "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", - "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", - "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", - "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", - "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", - "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", - "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.60.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz", - "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", - "cookie": "^0.6.0", - "devalue": "^5.8.1", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", - "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.2" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/svelte": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", - "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "9.x.x || 10.x.x", - "@testing-library/svelte-core": "1.0.0" - }, - "engines": { - "node": ">= 10" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", - "vite": "*", - "vitest": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/svelte-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", - "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, - "node_modules/@vitest/browser": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.6.tgz", - "integrity": "sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.6", - "@vitest/utils": "4.1.6", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.6" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.6", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.6", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", - "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.6", - "fflate": "^0.8.2", - "flatted": "^3.4.2", - "pathe": "^2.0.3", - "sirv": "^3.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.6" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.6", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/better-auth": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.11.tgz", - "integrity": "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.11", - "@better-auth/drizzle-adapter": "1.6.11", - "@better-auth/kysely-adapter": "1.6.11", - "@better-auth/memory-adapter": "1.6.11", - "@better-auth/mongo-adapter": "1.6.11", - "@better-auth/prisma-adapter": "1.6.11", - "@better-auth/telemetry": "1.6.11", - "@better-auth/utils": "0.4.0", - "@better-fetch/fetch": "1.1.21", - "@noble/ciphers": "^2.1.1", - "@noble/hashes": "^2.0.1", - "better-call": "1.3.5", - "defu": "^6.1.4", - "jose": "^6.1.3", - "kysely": "^0.28.17", - "nanostores": "^1.1.1", - "zod": "^4.3.6" - }, - "peerDependencies": { - "@lynx-js/react": "*", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", - "@tanstack/react-start": "^1.0.0", - "@tanstack/solid-start": "^1.0.0", - "better-sqlite3": "^12.0.0", - "drizzle-kit": ">=0.31.4", - "drizzle-orm": "^0.45.2", - "mongodb": "^6.0.0 || ^7.0.0", - "mysql2": "^3.0.0", - "next": "^14.0.0 || ^15.0.0 || ^16.0.0", - "pg": "^8.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "solid-js": "^1.0.0", - "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@lynx-js/react": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "@tanstack/react-start": { - "optional": true - }, - "@tanstack/solid-start": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "drizzle-kit": { - "optional": true - }, - "drizzle-orm": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "next": { - "optional": true - }, - "pg": { - "optional": true - }, - "prisma": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vitest": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/better-call": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", - "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", - "license": "MIT", - "dependencies": { - "@better-auth/utils": "^0.4.0", - "@better-fetch/fetch": "^1.1.21", - "rou3": "^0.7.12", - "set-cookie-parser": "^3.0.1" - }, - "peerDependencies": { - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bits-ui": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", - "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.1", - "@floating-ui/dom": "^1.7.1", - "esm-env": "^1.1.2", - "runed": "^0.35.1", - "svelte-toolbelt": "^0.10.6", - "tabbable": "^6.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/huntabyte" - }, - "peerDependencies": { - "@internationalized/date": "^3.8.1", - "svelte": "^5.33.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/culori": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.2.tgz", - "integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo-voronoi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", - "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-delaunay": "6", - "d3-geo": "3", - "d3-tricontour": "1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate-path": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", - "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tile": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d3-tile/-/d3-tile-1.0.0.tgz", - "integrity": "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tricontour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", - "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", - "license": "ISC", - "dependencies": { - "d3-delaunay": "6", - "d3-scale": "4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "tsx": "^4.21.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", - "devOptional": true, - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "devOptional": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", - "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-package-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", - "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "walk-up-path": "^4.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/formatly": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", - "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fd-package-json": "^2.0.0" - }, - "bin": { - "formatly": "bin/index.mjs" - }, - "engines": { - "node": ">=18.3.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "dev": true, - "license": "MIT" - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "devOptional": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "devOptional": true, - "license": "MIT", - "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.3", - "@exodus/bytes": "^1.15.0", - "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.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "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" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/knip": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.14.1.tgz", - "integrity": "sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/webpro" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/knip" - } - ], - "license": "ISC", - "dependencies": { - "fdir": "^6.5.0", - "formatly": "^0.3.0", - "get-tsconfig": "4.14.0", - "jiti": "^2.7.0", - "minimist": "^1.2.8", - "oxc-parser": "^0.130.0", - "oxc-resolver": "^11.19.1", - "picomatch": "^4.0.4", - "smol-toml": "^1.6.1", - "strip-json-comments": "5.0.3", - "tinyglobby": "^0.2.16", - "unbash": "^3.0.0", - "yaml": "^2.9.0", - "zod": "^4.1.11" - }, - "bin": { - "knip": "bin/knip.js", - "knip-bun": "bin/knip-bun.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/kysely": { - "version": "0.28.17", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", - "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/layercake": { - "version": "8.4.3", - "resolved": "https://registry.npmjs.org/layercake/-/layercake-8.4.3.tgz", - "integrity": "sha512-PZDduaPFxgHHkxlmsz5MVBECf6ZCT39DI3LgMVvuMwrmlrtlXwXUM/elJp46zHYzCE1j+cGyDuBDxnANv94tOQ==", - "license": "MIT", - "dependencies": { - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-scale": "^4.0.2", - "d3-shape": "^3.2.0" - }, - "peerDependencies": { - "svelte": "3 - 5 || >=5.0.0-next.120", - "typescript": "^5.0.2" - } - }, - "node_modules/layerchart": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-1.0.13.tgz", - "integrity": "sha512-bjcrfyTdHtfYZn7yj26dvA1qUjM+R6+akp2VeBJ4JWKmDGhb5WvT9nMCs52Rb+gSd/omFq5SjZLz49MqlVljZw==", - "license": "MIT", - "dependencies": { - "@dagrejs/dagre": "^1.1.4", - "@layerstack/svelte-actions": "^1.0.1", - "@layerstack/svelte-stores": "^1.0.2", - "@layerstack/tailwind": "^1.0.1", - "@layerstack/utils": "^1.0.1", - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-delaunay": "^6.0.4", - "d3-dsv": "^3.0.1", - "d3-force": "^3.0.0", - "d3-geo": "^3.1.1", - "d3-geo-voronoi": "^2.1.0", - "d3-hierarchy": "^3.1.2", - "d3-interpolate": "^3.0.1", - "d3-interpolate-path": "^2.3.0", - "d3-path": "^3.1.0", - "d3-quadtree": "^3.0.1", - "d3-random": "^3.0.1", - "d3-sankey": "^0.12.3", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.1.0", - "d3-shape": "^3.2.0", - "d3-tile": "^1.0.0", - "d3-time": "^3.1.0", - "date-fns": "^4.1.0", - "layercake": "8.4.3", - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "devOptional": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", - "devOptional": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "devOptional": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mode-watcher": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", - "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "runed": "^0.25.0", - "svelte-toolbelt": "^0.7.1" - }, - "peerDependencies": { - "svelte": "^5.27.0" - } - }, - "node_modules/mode-watcher/node_modules/runed": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", - "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/mode-watcher/node_modules/svelte-toolbelt": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", - "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte" - ], - "dependencies": { - "clsx": "^2.1.1", - "runed": "^0.23.2", - "style-to-object": "^1.0.8" - }, - "engines": { - "node": ">=18", - "pnpm": ">=8.7.0" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", - "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", - "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/nanostores": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", - "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "devOptional": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/oxc-parser": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", - "integrity": "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.130.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.130.0", - "@oxc-parser/binding-android-arm64": "0.130.0", - "@oxc-parser/binding-darwin-arm64": "0.130.0", - "@oxc-parser/binding-darwin-x64": "0.130.0", - "@oxc-parser/binding-freebsd-x64": "0.130.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", - "@oxc-parser/binding-linux-arm64-musl": "0.130.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", - "@oxc-parser/binding-linux-x64-gnu": "0.130.0", - "@oxc-parser/binding-linux-x64-musl": "0.130.0", - "@oxc-parser/binding-openharmony-arm64": "0.130.0", - "@oxc-parser/binding-wasm32-wasi": "0.130.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", - "@oxc-parser/binding-win32-x64-msvc": "0.130.0" - } - }, - "node_modules/oxc-resolver": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", - "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.19.1", - "@oxc-resolver/binding-android-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-x64": "11.19.1", - "@oxc-resolver/binding-freebsd-x64": "11.19.1", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", - "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-musl": "11.19.1", - "@oxc-resolver/binding-openharmony-arm64": "11.19.1", - "@oxc-resolver/binding-wasm32-wasi": "11.19.1", - "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", - "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", - "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/postgres": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", - "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", - "license": "Unlicense", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/porsager" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" - }, - "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.130.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" - } - }, - "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", - "license": "MIT" - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } - } - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "license": "MIT" - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "5.55.9", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", - "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.9", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-adapter-bun": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/svelte-adapter-bun/-/svelte-adapter-bun-1.0.1.tgz", - "integrity": "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rolldown": "^1.0.0-beta.38" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0", - "typescript": "^5" - } - }, - "node_modules/svelte-check": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", - "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/svelte-sonner": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", - "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "runed": "^0.28.0" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/svelte-sonner/node_modules/runed": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", - "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/svelte-toolbelt": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", - "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte" - ], - "dependencies": { - "clsx": "^2.1.1", - "runed": "^0.35.1", - "style-to-object": "^1.0.8" - }, - "engines": { - "node": ">=18", - "pnpm": ">=8.7.0" - }, - "peerDependencies": { - "svelte": "^5.30.2" - } - }, - "node_modules/svelte/node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwind-variants": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", - "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", - "license": "MIT", - "engines": { - "node": ">=16.x", - "pnpm": ">=7.x" - }, - "peerDependencies": { - "tailwind-merge": ">=3.0.0", - "tailwindcss": "*" - }, - "peerDependenciesMeta": { - "tailwind-merge": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.30" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "devOptional": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tw-animate-css": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", - "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Wombosvideo" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", - "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "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 - } - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "devOptional": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "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.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", - "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 - }, - "vite": { - "optional": false - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/walk-up-path": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", - "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "devOptional": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "devOptional": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index a6771f0..8c044a0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.12", "type": "module", + "engines": { + "bun": ">=1.2.0" + }, "scripts": { "dev": "bun --bun run vite dev", "build": "bun --bun run vite build", diff --git a/playwright.config.ts b/playwright.config.ts index 459b5db..993de19 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,7 @@ const config: PlaywrightTestConfig = { reuseExistingServer: !isCI, timeout: 180000, stdout: 'pipe', + stderr: 'pipe', }, }; diff --git a/scripts/backfill-incidents.ts b/scripts/backfill-incidents.ts index 85d3440..d25da93 100644 --- a/scripts/backfill-incidents.ts +++ b/scripts/backfill-incidents.ts @@ -1,4 +1,10 @@ #!/usr/bin/env bun +/** + * IMPORTANT: This script is non-transactional and not resumable. + * If it fails mid-run, some projects may be partially backfilled. + * Re-running is safe (it is idempotent per fingerprint) but may be slow. + * For large datasets, consider running per-project with the --project flag. + */ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from '../src/lib/server/db/schema'; diff --git a/scripts/seed-admin.ts b/scripts/seed-admin.ts index bd30af7..34ef8da 100755 --- a/scripts/seed-admin.ts +++ b/scripts/seed-admin.ts @@ -1,5 +1,4 @@ #!/usr/bin/env bun -import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import { createAuth } from '../src/lib/server/auth'; @@ -38,35 +37,34 @@ async function seedAdmin() { const db = drizzle(client, { schema }); try { - // Check if admin already exists by username - const existingAdmin = await db - .select() - .from(schema.user) - .where(eq(schema.user.username, ADMIN_USERNAME)); - - if (existingAdmin.length > 0) { - console.log('✓ Admin user already exists, skipping'); - return; - } - // Create admin user via better-auth with username + // Idempotent: catch unique constraint errors and treat as success const auth = createAuth(db); - const result = await auth.api.signUpEmail({ - body: { - email: generatedEmail, - password: ADMIN_PASSWORD, - name: 'Admin', - username: ADMIN_USERNAME, - }, - }); + try { + const result = await auth.api.signUpEmail({ + body: { + email: generatedEmail, + password: ADMIN_PASSWORD, + name: 'Admin', + username: ADMIN_USERNAME, + }, + }); - if (result.error) { - throw new Error(`Failed to create admin user: ${result.error.message}`); - } + if (result.error) { + throw new Error(`Failed to create admin user: ${result.error.message}`); + } - console.log('✓ Admin user created successfully'); - console.log(` Username: ${ADMIN_USERNAME}`); - console.log(' You can now sign in with the admin credentials'); + console.log('✓ Admin user created successfully'); + console.log(` Username: ${ADMIN_USERNAME}`); + console.log(' You can now sign in with the admin credentials'); + } catch (e) { + const msg = e instanceof Error ? e.message.toLowerCase() : ''; + if (msg.includes('unique') || msg.includes('already exists') || msg.includes('23505')) { + console.log('✓ Admin user already exists, skipping seed.'); + } else { + throw e; + } + } } catch (error) { console.error('✗ Failed to seed admin user:', error); throw error; diff --git a/sdks/go/examples/basic/main.go b/sdks/go/examples/basic/main.go index 9f409ca..30c5079 100644 --- a/sdks/go/examples/basic/main.go +++ b/sdks/go/examples/basic/main.go @@ -41,6 +41,7 @@ func main() { if err != nil { log.Fatalf("Failed to create Logwell client: %v", err) } + defer client.Shutdown(context.Background()) fmt.Println("Logwell Go SDK - Basic Example") fmt.Printf("Endpoint: %s\n", endpoint) @@ -71,11 +72,6 @@ func main() { // Give some time for the flush to complete time.Sleep(100 * time.Millisecond) - // Flush any remaining logs before exit - // Note: In POC, Flush is not yet implemented on Client, but will be in Phase 2 - // For now, just give time for any background operations - _ = context.Background() - fmt.Println() fmt.Println("Example completed. Check your Logwell server for the logs!") fmt.Println("If running locally, start the server with: bun run dev") diff --git a/sdks/go/logwell/client.go b/sdks/go/logwell/client.go index cebaee7..14e1c71 100644 --- a/sdks/go/logwell/client.go +++ b/sdks/go/logwell/client.go @@ -2,7 +2,9 @@ package logwell import ( "context" + "errors" "sync" + "time" ) // ErrClientShutdown is returned when attempting to log after shutdown. @@ -21,6 +23,9 @@ type Client struct { mu sync.Mutex shutdown bool + + // flushWG tracks in-flight async flush goroutines so Shutdown can wait for them. + flushWG sync.WaitGroup } // ChildOption configures a child logger created via Client.Child(). @@ -72,7 +77,7 @@ func New(endpoint, apiKey string, opts ...Option) (*Client, error) { return nil, err } - transport := newHTTPTransport(endpoint, apiKey) + transport := newHTTPTransportFromConfig(cfg) // Create client first so we can pass flush callback to queue c := &Client{ @@ -181,6 +186,14 @@ func (c *Client) Log(entry LogEntry) { } c.mu.Unlock() + // Capture source location if enabled and not already set + if c.config.CaptureSourceLocation && entry.SourceFile == "" { + if file, line := captureSource(2); file != "" { + entry.SourceFile = file + entry.LineNumber = line + } + } + // Set defaults if not provided if entry.Timestamp == "" { entry.Timestamp = now() @@ -197,7 +210,18 @@ func (c *Client) Log(entry LogEntry) { c.mu.Unlock() if shouldFlush { - c.flush() + root := c + if c.parent != nil { + root = c.parent + } + root.flushWG.Add(1) + go func() { + defer root.flushWG.Done() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Flush handles OnError callback internally; ignore the returned error here. + _ = c.Flush(ctx) + }() } } @@ -231,7 +255,18 @@ func (c *Client) log(level LogLevel, message string, metadata ...map[string]any) c.mu.Unlock() if shouldFlush { - c.flush() + root := c + if c.parent != nil { + root = c.parent + } + root.flushWG.Add(1) + go func() { + defer root.flushWG.Done() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Flush handles OnError callback internally; ignore the returned error here. + _ = c.Flush(ctx) + }() } } @@ -255,7 +290,8 @@ func (c *Client) flush() { // Re-queue failed entries at the front for retry c.queue.prepend(entries) if c.config.OnError != nil { - if logwellErr, ok := err.(*Error); ok { + var logwellErr *Error + if errors.As(err, &logwellErr) { c.config.OnError(logwellErr) } else { c.config.OnError(NewErrorWithCause(ErrNetworkError, "flush failed", err)) @@ -287,7 +323,8 @@ func (c *Client) Flush(ctx context.Context) error { // Re-queue failed entries at the front for retry c.queue.prepend(entries) if c.config.OnError != nil { - if logwellErr, ok := err.(*Error); ok { + var logwellErr *Error + if errors.As(err, &logwellErr) { c.config.OnError(logwellErr) } else { c.config.OnError(NewErrorWithCause(ErrNetworkError, "flush failed", err)) @@ -307,7 +344,8 @@ func (c *Client) Flush(ctx context.Context) error { // It stops accepting new logs, flushes any remaining queued logs, // and cleans up resources. // Respects context cancellation and timeout. -// Returns any error from flushing remaining logs. +// Returns any error from flushing remaining logs. A non-nil error +// means that some logs may not have been delivered to the server. // // For child loggers, Shutdown only marks the child as shut down; // it does NOT affect the parent or other children. The parent must @@ -330,6 +368,9 @@ func (c *Client) Shutdown(ctx context.Context) error { // Stop the queue timer to prevent further auto-flushes c.queue.stopTimer() + // Wait for any in-flight async flush goroutines to complete + c.flushWG.Wait() + // Flush remaining logs with context return c.Flush(ctx) } @@ -341,6 +382,22 @@ func mergeMetadata(maps ...map[string]any) map[string]any { return nil } + // Fast-path: single map, return directly (read-only, safe) + if len(maps) == 1 { + if len(maps[0]) == 0 { + return nil + } + return maps[0] + } + + // Fast-path: two maps where the second (extra) is empty + if len(maps) == 2 && len(maps[1]) == 0 { + if len(maps[0]) == 0 { + return nil + } + return maps[0] + } + result := make(map[string]any) for _, m := range maps { for k, v := range m { diff --git a/sdks/go/logwell/config.go b/sdks/go/logwell/config.go index 7288953..72e383d 100644 --- a/sdks/go/logwell/config.go +++ b/sdks/go/logwell/config.go @@ -45,7 +45,7 @@ type Config struct { Metadata map[string]any // BatchSize is the number of logs to batch before sending. - // Default: 10, Range: 1-500. + // Default: 50, Range: 1-500. BatchSize int // FlushInterval is the maximum time to wait before flushing. diff --git a/sdks/go/logwell/queue.go b/sdks/go/logwell/queue.go index 438371f..7b1a905 100644 --- a/sdks/go/logwell/queue.go +++ b/sdks/go/logwell/queue.go @@ -2,6 +2,7 @@ package logwell import ( "sync" + "sync/atomic" "time" ) @@ -16,6 +17,7 @@ type batchQueue struct { flushInterval time.Duration flushFn func() timer *time.Timer + generation int64 // incremented on each timer stop/restart to detect stale callbacks // Overflow protection maxQueueSize int @@ -61,8 +63,15 @@ func (q *batchQueue) add(entry LogEntry) { // Start or reset the flush timer if auto-flush is enabled if q.flushInterval > 0 && q.flushFn != nil { if q.timer == nil { - // Start new timer - q.timer = time.AfterFunc(q.flushInterval, q.flushFn) + // Start new timer with current generation + gen := atomic.LoadInt64(&q.generation) + flushFn := q.flushFn + q.timer = time.AfterFunc(q.flushInterval, func() { + if atomic.LoadInt64(&q.generation) != gen { + return // stale callback, ignore + } + flushFn() + }) } else { // Reset existing timer q.timer.Reset(q.flushInterval) @@ -74,6 +83,7 @@ func (q *batchQueue) add(entry LogEntry) { // prepend adds entries to the front of the queue. // Used to re-queue entries after a failed flush. +// Enforces maxQueueSize by truncating combined entries if needed. // Starts or resets the flush timer if auto-flush is enabled. func (q *batchQueue) prepend(entries []LogEntry) { q.mu.Lock() @@ -84,12 +94,23 @@ func (q *batchQueue) prepend(entries []LogEntry) { } // Prepend entries to the front: new slice = entries + existing - q.entries = append(entries, q.entries...) + combined := append(entries, q.entries...) + if q.maxQueueSize > 0 && len(combined) > q.maxQueueSize { + combined = combined[:q.maxQueueSize] // keep newest (prepended) entries + } + q.entries = combined // Start or reset the flush timer if auto-flush is enabled if q.flushInterval > 0 && q.flushFn != nil { if q.timer == nil { - q.timer = time.AfterFunc(q.flushInterval, q.flushFn) + gen := atomic.LoadInt64(&q.generation) + flushFn := q.flushFn + q.timer = time.AfterFunc(q.flushInterval, func() { + if atomic.LoadInt64(&q.generation) != gen { + return // stale callback, ignore + } + flushFn() + }) } else { q.timer.Reset(q.flushInterval) } @@ -102,8 +123,9 @@ func (q *batchQueue) flush() []LogEntry { q.mu.Lock() defer q.mu.Unlock() - // Stop the flush timer if running + // Stop the flush timer if running; bump generation to invalidate stale callbacks if q.timer != nil { + atomic.AddInt64(&q.generation, 1) q.timer.Stop() q.timer = nil } @@ -128,10 +150,12 @@ func (q *batchQueue) size() int { } // stopTimer stops the auto-flush timer if running. +// Bumps the generation counter so any in-flight timer callbacks become no-ops. // Used during shutdown to prevent timer fires after shutdown starts. func (q *batchQueue) stopTimer() { q.mu.Lock() defer q.mu.Unlock() + atomic.AddInt64(&q.generation, 1) if q.timer != nil { q.timer.Stop() q.timer = nil diff --git a/sdks/go/logwell/transport.go b/sdks/go/logwell/transport.go index 0461fcc..734a39d 100644 --- a/sdks/go/logwell/transport.go +++ b/sdks/go/logwell/transport.go @@ -8,6 +8,7 @@ import ( "io" "math/rand" "net/http" + "strings" "time" ) @@ -27,17 +28,35 @@ type httpTransport struct { maxRetries int } -// newHTTPTransport creates a new HTTP transport. +// newHTTPTransport creates a new HTTP transport with the given endpoint and API key. +// Uses default settings: no custom HTTP client and defaultMaxRetries. func newHTTPTransport(endpoint, apiKey string) *httpTransport { return &httpTransport{ endpoint: endpoint, apiKey: apiKey, - httpClient: &http.Client{}, - ingestURL: endpoint + "/v1/ingest", + httpClient: &http.Client{Timeout: 30 * time.Second}, + ingestURL: strings.TrimRight(endpoint, "/") + "/v1/ingest", maxRetries: defaultMaxRetries, } } +// newHTTPTransportFromConfig creates a new HTTP transport from the given config. +// Wires MaxRetries and HTTPClient from the config; applies a 30s default timeout +// when no custom HTTP client is provided. +func newHTTPTransportFromConfig(cfg *Config) *httpTransport { + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &httpTransport{ + endpoint: cfg.Endpoint, + apiKey: cfg.APIKey, + httpClient: httpClient, + ingestURL: strings.TrimRight(cfg.Endpoint, "/") + "/v1/ingest", + maxRetries: cfg.MaxRetries, + } +} + // sendWithRetry sends a batch with exponential backoff retry for transient errors. // Network errors, 5xx, and 429 are retried. 400, 401, 403 are not. func (t *httpTransport) sendWithRetry(ctx context.Context, logs []LogEntry) (*IngestResponse, error) { diff --git a/sdks/go/logwell/types.go b/sdks/go/logwell/types.go index 54d1a83..dc83b85 100644 --- a/sdks/go/logwell/types.go +++ b/sdks/go/logwell/types.go @@ -53,7 +53,7 @@ type IngestResponse struct { Errors []string `json:"errors,omitempty"` } -// Now returns the current time formatted as ISO8601. +// now returns the current time formatted as ISO8601. // Used internally for timestamp generation. func now() string { return time.Now().UTC().Format(time.RFC3339Nano) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 84050ec..867d681 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -47,7 +47,7 @@ Issues = "https://github.com/Divkix/Logwell/issues" packages = ["src/logwell"] [tool.mypy] -python_version = "3.10" +python_version = "3.9" strict = true warn_return_any = true warn_unused_configs = true diff --git a/sdks/python/src/logwell/__init__.py b/sdks/python/src/logwell/__init__.py index c158607..77df5f0 100644 --- a/sdks/python/src/logwell/__init__.py +++ b/sdks/python/src/logwell/__init__.py @@ -1,10 +1,15 @@ """Logwell Python SDK - Official logging client for Logwell platform.""" +from importlib.metadata import PackageNotFoundError, version + from logwell.client import Logwell from logwell.errors import LogwellError, LogwellErrorCode from logwell.types import IngestResponse, LogEntry, LogLevel, LogwellConfig -__version__ = "0.1.0" +try: + __version__ = version("logwell") +except PackageNotFoundError: + __version__ = "unknown" __all__ = [ "__version__", "IngestResponse", diff --git a/sdks/python/src/logwell/client.py b/sdks/python/src/logwell/client.py index dd5b6f0..e0be86d 100644 --- a/sdks/python/src/logwell/client.py +++ b/sdks/python/src/logwell/client.py @@ -6,6 +6,7 @@ from __future__ import annotations +import atexit from datetime import datetime, timezone from typing import TYPE_CHECKING, Any @@ -53,26 +54,44 @@ def __init__( self._parent_metadata = _parent_metadata self._stopped = False - # Create transport - self._transport = HttpTransport(self._config) - - # Use existing queue (for child loggers) or create new one + # Use existing queue (for child loggers) or create new one (PY-10) if _queue is not None: + self._transport: HttpTransport | None = None self._queue = _queue self._owns_queue = False else: + self._transport = HttpTransport(self._config) queue_config = QueueConfig.from_logwell_config(self._config) self._queue = BatchQueue( send_batch=self._transport.send, config=queue_config, ) self._owns_queue = True + self._register_atexit() @property def queue_size(self) -> int: """Current number of logs waiting in the queue.""" return self._queue.size + async def __aenter__(self) -> Logwell: + return self + + async def __aexit__(self, *args: object) -> None: + await self.shutdown() + + def _register_atexit(self) -> None: + """Register a best-effort flush on process exit (PY-6).""" + def _flush_on_exit() -> None: + import asyncio as _asyncio + try: + loop = _asyncio.new_event_loop() + loop.run_until_complete(_asyncio.wait_for(self.shutdown(), timeout=5.0)) + loop.close() + except Exception: + pass + atexit.register(_flush_on_exit) + def _add_log(self, entry: LogEntry, skip_frames: int) -> None: """Internal log method with source location capture. @@ -96,7 +115,7 @@ def _add_log(self, entry: LogEntry, skip_frames: int) -> None: full_entry: LogEntry = { "level": entry["level"], "message": entry["message"], - "timestamp": entry.get("timestamp") or datetime.now(timezone.utc).isoformat(), + "timestamp": entry.get("timestamp") or datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'), } # Add service from entry, config, or omit @@ -202,7 +221,8 @@ async def shutdown(self) -> None: self._stopped = True if self._owns_queue: await self._queue.shutdown() - await self._transport.close() + if self._transport is not None: + await self._transport.close() def child( self, @@ -233,6 +253,7 @@ def child( "flush_interval": self._config.get("flush_interval", 5.0), "max_queue_size": self._config.get("max_queue_size", 1000), "max_retries": self._config.get("max_retries", 3), + "timeout": self._config.get("timeout", 30.0), "capture_source_location": self._config.get("capture_source_location", False), } diff --git a/sdks/python/src/logwell/config.py b/sdks/python/src/logwell/config.py index 5c84982..211b2b7 100644 --- a/sdks/python/src/logwell/config.py +++ b/sdks/python/src/logwell/config.py @@ -21,6 +21,7 @@ "flush_interval": 5.0, # seconds "max_queue_size": 1000, "max_retries": 3, + "timeout": 30.0, # seconds "capture_source_location": False, } @@ -97,14 +98,29 @@ def validate_config(config: LogwellConfig) -> LogwellConfig: LogwellErrorCode.INVALID_CONFIG, ) - # Validate endpoint URL - if not _is_valid_url(config["endpoint"]): + # Validate endpoint URL and strip trailing slash (PY-11) + try: + parsed_endpoint = urlparse(config["endpoint"]) + valid_url = bool(parsed_endpoint.scheme and parsed_endpoint.netloc) + valid_scheme = parsed_endpoint.scheme in ("http", "https") + except (ValueError, AttributeError): + valid_url = False + valid_scheme = False + parsed_endpoint = None # type: ignore[assignment] + + if not valid_url: raise LogwellError( f"Invalid endpoint URL: '{config['endpoint']}'. " "Expected a valid URL with scheme (http:// or https://) and host. " "Example: 'https://logs.example.com' or 'http://localhost:3000'", LogwellErrorCode.INVALID_CONFIG, ) + if not valid_scheme: + raise LogwellError( + f"Invalid endpoint URL scheme: '{parsed_endpoint.scheme}'. " + "endpoint must use http or https scheme.", + LogwellErrorCode.INVALID_CONFIG, + ) # Validate numeric options if "batch_size" in config and config["batch_size"] <= 0: @@ -139,6 +155,13 @@ def validate_config(config: LogwellConfig) -> LogwellConfig: LogwellErrorCode.INVALID_CONFIG, ) + if "timeout" in config and config["timeout"] <= 0: + raise LogwellError( + f"Invalid timeout: {config['timeout']}. " + "timeout must be a positive number in seconds (e.g., 30.0).", + LogwellErrorCode.INVALID_CONFIG, + ) + # Return merged config with defaults merged: LogwellConfig = { "api_key": config["api_key"], @@ -147,6 +170,7 @@ def validate_config(config: LogwellConfig) -> LogwellConfig: "flush_interval": config.get("flush_interval", DEFAULT_CONFIG["flush_interval"]), "max_queue_size": config.get("max_queue_size", DEFAULT_CONFIG["max_queue_size"]), "max_retries": config.get("max_retries", DEFAULT_CONFIG["max_retries"]), + "timeout": config.get("timeout", DEFAULT_CONFIG["timeout"]), "capture_source_location": config.get( "capture_source_location", DEFAULT_CONFIG["capture_source_location"] ), diff --git a/sdks/python/src/logwell/queue.py b/sdks/python/src/logwell/queue.py index e18f994..dd80a08 100644 --- a/sdks/python/src/logwell/queue.py +++ b/sdks/python/src/logwell/queue.py @@ -8,6 +8,7 @@ import asyncio import threading +from collections import deque from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING @@ -89,8 +90,9 @@ def __init__( self._config = QueueConfig.from_logwell_config(config) self._send_batch = send_batch - self._queue: list[LogEntry] = [] + self._queue: deque[LogEntry] = deque() self._lock = threading.Lock() + self._loop_lock = threading.Lock() # Separate lock for event-loop creation (PY-3) self._timer_future: ConcurrentFuture[Any] | None = None self._flushing = False self._stopped = False @@ -112,26 +114,24 @@ def add(self, entry: LogEntry) -> None: Args: entry: Log entry to add """ + overflow_error: LogwellError | None = None with self._lock: if self._stopped: return # Handle queue overflow if len(self._queue) >= self._config.max_queue_size: - dropped = self._queue.pop(0) - if self._config.on_error: - msg = dropped.get("message", "")[:50] - self._config.on_error( - LogwellError( - f"Queue overflow: max_queue_size " - f"({self._config.max_queue_size}) exceeded. " - f"Dropped oldest log: '{msg}...'. " - "Logs are being generated faster than they can be sent. " - "Consider increasing max_queue_size, reducing log volume, " - "or calling flush() more frequently.", - LogwellErrorCode.QUEUE_OVERFLOW, - ) - ) + dropped = self._queue.popleft() + msg = dropped.get("message", "")[:50] + overflow_error = LogwellError( + f"Queue overflow: max_queue_size " + f"({self._config.max_queue_size}) exceeded. " + f"Dropped oldest log: '{msg}...'. " + "Logs are being generated faster than they can be sent. " + "Consider increasing max_queue_size, reducing log volume, " + "or calling flush() more frequently.", + LogwellErrorCode.QUEUE_OVERFLOW, + ) self._queue.append(entry) @@ -143,6 +143,10 @@ def add(self, entry: LogEntry) -> None: # Flush immediately if batch size reached should_flush = len(self._queue) >= self._config.batch_size + # Invoke callback outside the lock to prevent re-entrant deadlock (PY-1) + if overflow_error is not None and self._config.on_error: + self._config.on_error(overflow_error) + if should_flush: self._trigger_flush() @@ -183,36 +187,49 @@ async def _do_flush(self) -> IngestResponse | None: self._stop_timer() # Take current batch - batch = self._queue.copy() + batch = list(self._queue) self._queue.clear() count = len(batch) + send_error: Exception | None = None try: response = await self._send_batch(batch) - if self._config.on_flush: - self._config.on_flush(count) - - # Restart timer if more logs remain (added during flush) - with self._lock: - if len(self._queue) > 0 and not self._stopped: - self._start_timer() - - return response except Exception as error: - # Re-queue failed logs at the front + send_error = error + response = None + + if send_error is not None: + # Re-queue failed logs at the front (outside error callback to avoid deadlock) with self._lock: - self._queue = batch + self._queue - if self._config.on_error: - self._config.on_error(error) + self._queue.extendleft(reversed(batch)) # Restart timer to retry if not self._stopped: self._start_timer() - return None - finally: + # Invoke callback outside the lock (PY-1) + if self._config.on_error: + self._config.on_error(send_error) + with self._lock: self._flushing = False + return None + + # Success path — call on_flush in its own try/except (PY-2) + if self._config.on_flush: + try: + self._config.on_flush(count) + except Exception as flush_err: + if self._config.on_error: + self._config.on_error(flush_err) + + # Restart timer if more logs remain (added during flush) + with self._lock: + self._flushing = False + if len(self._queue) > 0 and not self._stopped: + self._start_timer() + + return response async def shutdown(self) -> None: """Flush remaining logs and stop the queue. @@ -235,12 +252,24 @@ async def shutdown(self) -> None: self._stop_loop() def _ensure_loop(self) -> asyncio.AbstractEventLoop: - """Ensure a background event loop is running.""" - if self._queue_loop is None or self._queue_loop.is_closed(): - self._queue_loop = asyncio.new_event_loop() - self._queue_thread = threading.Thread(target=self._run_loop, daemon=True) - self._queue_thread.start() - return self._queue_loop + """Ensure a background event loop is running (double-checked locking, PY-3). + + Uses a dedicated _loop_lock separate from _lock so this method can be + called safely from within code that already holds _lock. + """ + loop = self._queue_loop + if loop is not None and not loop.is_closed(): + return loop + with self._loop_lock: + loop = self._queue_loop + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + # Assign before starting the thread so _run_loop sees it + self._queue_loop = loop + thread = threading.Thread(target=self._run_loop, daemon=True) + thread.start() + self._queue_thread = thread + return self._queue_loop # type: ignore[return-value] def _run_loop(self) -> None: """Run the background event loop.""" diff --git a/sdks/python/src/logwell/source_location.py b/sdks/python/src/logwell/source_location.py index 85a1eba..62c5643 100644 --- a/sdks/python/src/logwell/source_location.py +++ b/sdks/python/src/logwell/source_location.py @@ -2,7 +2,7 @@ from __future__ import annotations -import inspect +import sys from dataclasses import dataclass @@ -22,8 +22,8 @@ class SourceLocation: def capture_source_location(skip_frames: int = 0) -> SourceLocation | None: """Capture the source location of the caller. - Uses Python's inspect module to get the call stack and extract - the file path and line number of the caller. + Uses direct frame walking (sys._getframe) instead of inspect.stack() + to avoid per-frame file I/O overhead. Args: skip_frames: Number of stack frames to skip (0 = immediate caller @@ -41,23 +41,11 @@ def log(message: str) -> None: # location.source_file = file where log() was called """ try: - # inspect.stack() returns list of FrameInfo objects - # Index 0 is this function (capture_source_location) - # Index 1 is the immediate caller - # So we need index 1 + skip_frames - stack = inspect.stack() - - # Target frame: skip capture_source_location frame + user-specified frames - target_index = 1 + skip_frames - - if target_index >= len(stack): - return None - - frame_info = stack[target_index] - + # skip_frames=0 → caller of this function (1 frame above us) + frame = sys._getframe(skip_frames + 1) return SourceLocation( - source_file=frame_info.filename, - line_number=frame_info.lineno, + source_file=frame.f_code.co_filename, + line_number=frame.f_lineno, ) - except (IndexError, AttributeError): + except (ValueError, AttributeError): return None diff --git a/sdks/python/src/logwell/transport.py b/sdks/python/src/logwell/transport.py index 5fec1f8..fb9910a 100644 --- a/sdks/python/src/logwell/transport.py +++ b/sdks/python/src/logwell/transport.py @@ -47,6 +47,7 @@ def from_logwell_config(cls, config: LogwellConfig) -> TransportConfig: endpoint=config["endpoint"], api_key=config["api_key"], max_retries=config.get("max_retries", 3), + timeout=config.get("timeout", 30.0), ) @@ -84,7 +85,8 @@ def __init__(self, config: LogwellConfig | TransportConfig) -> None: else: self._config = TransportConfig.from_logwell_config(config) - self._ingest_url = f"{self._config.endpoint}/v1/ingest" + # Strip trailing slash to avoid double-slash in URL (PY-11) + self._ingest_url = f"{self._config.endpoint.rstrip('/')}/v1/ingest" self._client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: @@ -131,7 +133,12 @@ async def send(self, logs: list[LogEntry]) -> IngestResponse: # Don't delay after the last attempt if attempt < self._config.max_retries: - await _delay(attempt) + # Honor Retry-After header for 429 responses (PY-14) + retry_after = getattr(error, "retry_after", None) + if retry_after is not None: + await asyncio.sleep(retry_after) + else: + await _delay(attempt) raise last_error @@ -178,10 +185,13 @@ async def _do_request(self, logs: list[LogEntry]) -> IngestResponse: # Handle error responses if not response.is_success: error_body = self._try_parse_error(response) - raise self._create_error(response.status_code, error_body) + raise self._create_error(response.status_code, error_body, response) - # Parse successful response - data: IngestResponse = response.json() + # Parse successful response, guarding against non-JSON bodies (PY-7) + try: + data: IngestResponse = response.json() + except Exception: + data = {"accepted": len(logs), "rejected": 0, "errors": []} return data def _try_parse_error(self, response: httpx.Response) -> str: @@ -199,12 +209,15 @@ def _try_parse_error(self, response: httpx.Response) -> str: except Exception: return f"HTTP {response.status_code}" - def _create_error(self, status: int, message: str) -> LogwellError: + def _create_error( + self, status: int, message: str, response: httpx.Response | None = None + ) -> LogwellError: """Create appropriate LogwellError based on status code. Args: status: HTTP status code message: Error message + response: Optional response for header inspection Returns: LogwellError with appropriate code and retryable flag @@ -228,7 +241,7 @@ def _create_error(self, status: int, message: str) -> LogwellError: False, ) elif status == 429: - return LogwellError( + err = LogwellError( f"Rate limit exceeded (429): {message}. " "Too many requests sent to the server. The SDK will automatically " "retry with exponential backoff.", @@ -236,6 +249,15 @@ def _create_error(self, status: int, message: str) -> LogwellError: status, True, ) + # Parse Retry-After header (PY-14) + if response is not None: + retry_after_header = response.headers.get("Retry-After") + if retry_after_header: + try: + err.retry_after = float(retry_after_header) # type: ignore[attr-defined] + except ValueError: + pass + return err elif status >= 500: return LogwellError( f"Server error ({status}): {message}. " diff --git a/sdks/python/src/logwell/types.py b/sdks/python/src/logwell/types.py index cd395bb..29709ed 100644 --- a/sdks/python/src/logwell/types.py +++ b/sdks/python/src/logwell/types.py @@ -62,6 +62,7 @@ class LogwellConfig(TypedDict, total=False): flush_interval: float max_queue_size: int max_retries: int + timeout: float capture_source_location: bool on_error: Callable[[Exception], None] on_flush: Callable[[int], None] diff --git a/sdks/typescript/jsr.json b/sdks/typescript/jsr.json index ee1b827..5aad22e 100644 --- a/sdks/typescript/jsr.json +++ b/sdks/typescript/jsr.json @@ -1,6 +1,6 @@ { "name": "@divkix/logwell", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "exports": "./src/index.ts" } diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 34ca75a..82fbcdc 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,4 +1,4 @@ -import { validateConfig } from './config'; +import { type ResolvedConfig, validateConfig } from './config'; import { BatchQueue, type QueueConfig } from './queue'; import { captureSourceLocation } from './source-location'; import { HttpTransport } from './transport'; @@ -12,29 +12,6 @@ export interface ChildLoggerOptions { metadata?: Record; } -/** - * Internal resolved config with all defaults applied - */ -interface ResolvedConfig { - apiKey: string; - endpoint: string; - service?: string; - batchSize: number; - flushInterval: number; - maxQueueSize: number; - maxRetries: number; - captureSourceLocation: boolean; - onError?: (error: Error) => void; - onFlush?: (count: number) => void; -} - -/** - * Asserts that config has all required fields after validation - */ -function asResolvedConfig(config: LogwellConfig): ResolvedConfig { - return config as ResolvedConfig; -} - /** * Main Logwell client class * @@ -56,7 +33,7 @@ function asResolvedConfig(config: LogwellConfig): ResolvedConfig { export class Logwell { private readonly config: ResolvedConfig; private readonly queue: BatchQueue; - private readonly transport: HttpTransport; + private readonly transport?: HttpTransport; private readonly parentMetadata?: Record; private stopped = false; @@ -67,21 +44,22 @@ export class Logwell { existingQueue?: BatchQueue, parentMetadata?: Record, ) { - // Validate and apply defaults - this.config = asResolvedConfig(validateConfig(config)); + // Validate and apply defaults (returns ResolvedConfig directly, no cast needed) + this.config = validateConfig(config); this.parentMetadata = parentMetadata; - // Create transport - this.transport = new HttpTransport({ - endpoint: this.config.endpoint, - apiKey: this.config.apiKey, - maxRetries: this.config.maxRetries, - }); - - // Use existing queue (for child loggers) or create new one + // Use existing queue (for child loggers) or create new one with its own transport if (existingQueue) { this.queue = existingQueue; + // transport is the parent's, accessed via queue's sendBatch — no allocation needed } else { + this.transport = new HttpTransport({ + endpoint: this.config.endpoint, + apiKey: this.config.apiKey, + maxRetries: this.config.maxRetries, + timeout: this.config.timeout, + }); + const queueConfig: QueueConfig = { batchSize: this.config.batchSize, flushInterval: this.config.flushInterval, @@ -89,7 +67,7 @@ export class Logwell { onError: this.config.onError, onFlush: this.config.onFlush, }; - this.queue = new BatchQueue((logs) => this.transport.send(logs), queueConfig); + this.queue = new BatchQueue((logs) => this.transport!.send(logs), queueConfig); } } @@ -186,10 +164,11 @@ export class Logwell { * Flush remaining logs and stop the client * * Call this before process exit to ensure all logs are sent. + * @returns Last response from the server, or null if queue was empty */ - async shutdown(): Promise { + async shutdown(): Promise { this.stopped = true; - await this.queue.shutdown(); + return this.queue.shutdown(); } /** diff --git a/sdks/typescript/src/config.ts b/sdks/typescript/src/config.ts index 457283c..a27d197 100644 --- a/sdks/typescript/src/config.ts +++ b/sdks/typescript/src/config.ts @@ -9,9 +9,27 @@ export const DEFAULT_CONFIG = { flushInterval: 5000, maxQueueSize: 1000, maxRetries: 3, + timeout: 30000, captureSourceLocation: false, } as const; +/** + * Resolved configuration with all defaults applied and required fields guaranteed + */ +export interface ResolvedConfig { + apiKey: string; + endpoint: string; + service?: string; + batchSize: number; + flushInterval: number; + maxQueueSize: number; + maxRetries: number; + timeout: number; + captureSourceLocation: boolean; + onError?: (error: Error) => void; + onFlush?: (count: number) => void; +} + /** * API key format regex: lw_[32 alphanumeric chars including - and _] */ @@ -31,17 +49,20 @@ export function validateApiKeyFormat(apiKey: string): boolean { } /** - * Validates a URL string + * Validates a URL string, requiring http or https protocol * * @param url - URL string to validate - * @returns true if valid URL, false otherwise + * @throws LogwellError if the URL is invalid or uses a non-http/https scheme */ -function isValidUrl(url: string): boolean { +function validateEndpointUrl(url: string): void { + let parsed: URL; try { - new URL(url); - return true; + parsed = new URL(url); } catch { - return false; + throw new LogwellError('Invalid endpoint URL', 'INVALID_CONFIG'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new LogwellError('endpoint must use http or https', 'INVALID_CONFIG'); } } @@ -49,10 +70,10 @@ function isValidUrl(url: string): boolean { * Validates configuration and returns merged config with defaults * * @param config - Partial configuration to validate - * @returns Complete configuration with defaults applied + * @returns Complete configuration with all defaults applied * @throws LogwellError if configuration is invalid */ -export function validateConfig(config: Partial): LogwellConfig { +export function validateConfig(config: Partial): ResolvedConfig { // Validate required fields if (!config.apiKey) { throw new LogwellError('apiKey is required', 'INVALID_CONFIG'); @@ -70,12 +91,10 @@ export function validateConfig(config: Partial): LogwellConfig { ); } - // Validate endpoint URL - if (!isValidUrl(config.endpoint)) { - throw new LogwellError('Invalid endpoint URL', 'INVALID_CONFIG'); - } + // Validate endpoint URL (also checks protocol) + validateEndpointUrl(config.endpoint); - // Validate numeric options + // Validate numeric options — lower bounds if (config.batchSize !== undefined && config.batchSize <= 0) { throw new LogwellError('batchSize must be positive', 'INVALID_CONFIG'); } @@ -92,15 +111,36 @@ export function validateConfig(config: Partial): LogwellConfig { throw new LogwellError('maxRetries must be non-negative', 'INVALID_CONFIG'); } - // Return merged config with defaults + // Validate numeric options — upper bounds (TS-7) + if (config.batchSize !== undefined && config.batchSize > 100) { + throw new LogwellError('batchSize cannot exceed 100 (server limit)', 'INVALID_CONFIG'); + } + + if (config.maxQueueSize !== undefined && config.maxQueueSize > 100000) { + throw new LogwellError('maxQueueSize cannot exceed 100000', 'INVALID_CONFIG'); + } + + if (config.flushInterval !== undefined && config.flushInterval < 100) { + throw new LogwellError('flushInterval must be at least 100ms', 'INVALID_CONFIG'); + } + + if (config.flushInterval !== undefined && config.flushInterval > 60000) { + throw new LogwellError('flushInterval cannot exceed 60000ms', 'INVALID_CONFIG'); + } + + // Normalize endpoint: strip trailing slash + const endpoint = config.endpoint.replace(/\/$/, ''); + + // Return merged config with all defaults — typed as ResolvedConfig (no cast needed) return { apiKey: config.apiKey, - endpoint: config.endpoint, + endpoint, service: config.service, batchSize: config.batchSize ?? DEFAULT_CONFIG.batchSize, flushInterval: config.flushInterval ?? DEFAULT_CONFIG.flushInterval, maxQueueSize: config.maxQueueSize ?? DEFAULT_CONFIG.maxQueueSize, maxRetries: config.maxRetries ?? DEFAULT_CONFIG.maxRetries, + timeout: config.timeout ?? DEFAULT_CONFIG.timeout, captureSourceLocation: config.captureSourceLocation ?? DEFAULT_CONFIG.captureSourceLocation, onError: config.onError, onFlush: config.onFlush, diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 65b50a1..4a77d75 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -32,6 +32,7 @@ export class LogwellError extends Error { public readonly code: LogwellErrorCode, public readonly statusCode?: number, public readonly retryable: boolean = false, + public readonly retryAfterMs?: number, ) { super(message); this.name = 'LogwellError'; diff --git a/sdks/typescript/src/queue.ts b/sdks/typescript/src/queue.ts index 019a15b..cd1b799 100644 --- a/sdks/typescript/src/queue.ts +++ b/sdks/typescript/src/queue.ts @@ -83,7 +83,8 @@ export class BatchQueue { /** * Flush all queued logs immediately * - * @returns Response from the server, or null if queue was empty + * Sends in chunks bounded by batchSize. + * @returns Last response from the server, or null if queue was empty */ async flush(): Promise { // Prevent concurrent flushes @@ -94,52 +95,64 @@ export class BatchQueue { this.flushing = true; this.stopTimer(); - // Take current batch - const batch = this.queue.splice(0); - const count = batch.length; - + // Snapshot current queue length so concurrent adds during flush are deferred + const snapshotLength = this.queue.length; + let sent = 0; + let lastResponse: IngestResponse | null = null; try { - const response = await this.sendBatch(batch); - this.config.onFlush?.(count); - - // Restart timer if more logs remain (added during flush) - if (this.queue.length > 0 && !this.stopped) { - this.startTimer(); - } - - return response; - } catch (error) { - // Re-queue failed logs at the front - this.queue.unshift(...batch); - this.config.onError?.(error as Error); - - // Restart timer to retry - if (!this.stopped) { - this.startTimer(); + // Send in chunks bounded by batchSize, up to the snapshot count + while (sent < snapshotLength) { + const remaining = snapshotLength - sent; + const chunkSize = Math.min(this.config.batchSize, remaining); + const batch = this.queue.splice(0, chunkSize); + sent += batch.length; + try { + const response = await this.sendBatch(batch); + this.config.onFlush?.(batch.length); + lastResponse = response; + } catch (error) { + // Re-queue failed batch at front, respect maxQueueSize + const requeued = [...batch, ...this.queue]; + this.queue.length = 0; + this.queue.push(...requeued.slice(0, this.config.maxQueueSize)); + if (requeued.length > this.config.maxQueueSize) { + this.config.onError?.( + new LogwellError( + `Queue overflow: dropped ${requeued.length - this.config.maxQueueSize} logs`, + 'QUEUE_OVERFLOW', + ), + ); + } + this.config.onError?.(error as Error); + break; // stop flushing on error + } } - - return null; } finally { this.flushing = false; + if (this.queue.length > 0 && !this.stopped) { + this.startTimer(); + } } + return lastResponse; } /** * Flush remaining logs and stop the queue + * + * @returns Last response from the server, or null if queue was empty */ - async shutdown(): Promise { + async shutdown(): Promise { if (this.stopped) { - return; + return null; } this.stopped = true; this.stopTimer(); - // Flush all remaining logs if (this.queue.length > 0) { - this.flushing = false; // Reset flushing flag - await this.flush(); + return this.flush(); } + return null; } private startTimer(): void { diff --git a/sdks/typescript/src/transport.ts b/sdks/typescript/src/transport.ts index 548d0ac..033e431 100644 --- a/sdks/typescript/src/transport.ts +++ b/sdks/typescript/src/transport.ts @@ -20,6 +20,13 @@ function delay(attempt: number, baseDelay = 100): Promise { return new Promise((resolve) => setTimeout(resolve, ms + jitter)); } +/** + * Sleep for a given number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** * HTTP transport for sending logs to Logwell server * @@ -32,7 +39,8 @@ export class HttpTransport { private readonly ingestUrl: string; constructor(private config: TransportConfig) { - this.ingestUrl = `${config.endpoint}/v1/ingest`; + const cleanEndpoint = config.endpoint.replace(/\/$/, ''); + this.ingestUrl = `${cleanEndpoint}/v1/ingest`; } /** @@ -54,7 +62,16 @@ export class HttpTransport { try { return await this.doRequest(logs); } catch (error) { - lastError = error as LogwellError; + if (error instanceof LogwellError) { + lastError = error; + } else { + lastError = new LogwellError( + `Unexpected error: ${(error as Error).message}`, + 'NETWORK_ERROR', + undefined, + true, + ); + } // Don't retry non-retryable errors if (!lastError.retryable) { @@ -63,7 +80,13 @@ export class HttpTransport { // Don't delay after the last attempt if (attempt < this.config.maxRetries) { - await delay(attempt); + if (lastError.retryAfterMs !== undefined) { + // Honor Retry-After but cap it to the exponential backoff ceiling + const backoffMs = Math.min(100 * 2 ** attempt, 10000); + await sleep(Math.min(lastError.retryAfterMs, backoffMs)); + } else { + await delay(attempt); + } } } } @@ -82,8 +105,17 @@ export class HttpTransport { 'Content-Type': 'application/json', }, body: JSON.stringify(logs), + signal: AbortSignal.timeout(this.config.timeout ?? 30000), + keepalive: true, }); } catch (error) { + // Timeout error (AbortError or TimeoutError) + if ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + throw new LogwellError('Request timed out', 'NETWORK_ERROR', undefined, true); + } // Network error (fetch failed) throw new LogwellError( `Network error: ${(error as Error).message}`, @@ -96,7 +128,7 @@ export class HttpTransport { // Handle error responses if (!response.ok) { const errorBody = await this.tryParseError(response); - throw this.createError(response.status, errorBody); + throw this.createErrorWithRetryAfter(response, errorBody); } // Parse successful response @@ -112,6 +144,22 @@ export class HttpTransport { } } + private createErrorWithRetryAfter(response: Response, message: string): LogwellError { + const { status } = response; + if (status === 429) { + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterMs = retryAfterHeader ? parseFloat(retryAfterHeader) * 1000 : undefined; + return new LogwellError( + `Rate limited: ${message}`, + 'RATE_LIMITED', + status, + true, + retryAfterMs, + ); + } + return this.createError(status, message); + } + private createError(status: number, message: string): LogwellError { switch (status) { case 401: diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 3f66e34..aa8c0ed 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -36,6 +36,8 @@ export interface LogwellConfig { maxQueueSize?: number; /** Max retry attempts (default: 3) */ maxRetries?: number; + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; /** Capture source file and line number (default: false). Has performance overhead when enabled. */ captureSourceLocation?: boolean; /** Called on send failures */ diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5fbaf14..f236fcb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,7 +4,7 @@ import { building } from '$app/environment'; import { auth, initAuth } from '$lib/server/auth'; import { db } from '$lib/server/db'; import { createErrorHandler } from '$lib/server/error-handler'; -import { startCleanupScheduler } from '$lib/server/jobs/cleanup-scheduler'; +import { startCleanupScheduler, stopCleanupScheduler } from '$lib/server/jobs/cleanup-scheduler'; // Initialize on server startup let initialized = false; @@ -25,6 +25,16 @@ async function ensureInitialized(): Promise { } } +// Graceful shutdown +function gracefulShutdown(signal: string) { + console.log(`[shutdown] ${signal} received`); + stopCleanupScheduler(); + // Give in-flight requests ~5s then exit + setTimeout(() => process.exit(0), 5000); +} +process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.once('SIGINT', () => gracefulShutdown('SIGINT')); + /** * Combined SvelteKit handle hook for better-auth * - Populates event.locals with session/user data @@ -38,6 +48,16 @@ export const handle: Handle = async ({ event, resolve }) => { await ensureInitialized(); + // Skip session lookup for paths that never need auth + const pathname = event.url.pathname; + if ( + pathname.startsWith('/v1/') || + pathname === '/api/health' || + pathname.startsWith('/static/') + ) { + return resolve(event); + } + // Fetch current session from Better Auth const session = await auth.api.getSession({ headers: event.request.headers, @@ -49,6 +69,7 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.user = session.user; } + // Test injection seam — tests override this with a PGlite client via locals.db event.locals.db = db; // Use better-auth's SvelteKit handler for proper routing diff --git a/src/lib/components/__tests__/dashboard-skeleton.component.test.ts b/src/lib/components/__tests__/dashboard-skeleton.component.test.ts index 72f24c4..9178a0a 100644 --- a/src/lib/components/__tests__/dashboard-skeleton.component.test.ts +++ b/src/lib/components/__tests__/dashboard-skeleton.component.test.ts @@ -46,21 +46,21 @@ describe('DashboardSkeleton', () => { render(DashboardSkeleton); const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]).getByTestId('skeleton-title')).toBeInTheDocument(); + expect(within(cards[0]!).getByTestId('skeleton-title')).toBeInTheDocument(); }); it('card skeleton has content placeholders', () => { render(DashboardSkeleton); const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]).getByTestId('skeleton-content')).toBeInTheDocument(); + expect(within(cards[0]!).getByTestId('skeleton-content')).toBeInTheDocument(); }); it('card skeleton has button placeholder', () => { render(DashboardSkeleton); const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]).getByTestId('skeleton-card-button')).toBeInTheDocument(); + expect(within(cards[0]!).getByTestId('skeleton-card-button')).toBeInTheDocument(); }); }); @@ -68,7 +68,7 @@ describe('DashboardSkeleton', () => { it('has pulse animation on skeleton elements', () => { render(DashboardSkeleton); - const skeletonTitle = screen.getAllByTestId('skeleton-title')[0]; + const skeletonTitle = screen.getAllByTestId('skeleton-title')[0]!; expect(skeletonTitle).toHaveClass('animate-pulse'); }); diff --git a/src/lib/components/__tests__/log-detail-modal.component.test.ts b/src/lib/components/__tests__/log-detail-modal.component.test.ts index 5c368a2..649be87 100644 --- a/src/lib/components/__tests__/log-detail-modal.component.test.ts +++ b/src/lib/components/__tests__/log-detail-modal.component.test.ts @@ -141,7 +141,7 @@ describe('LogDetailModal', () => { }); it('handles null timestamp gracefully', () => { - const logWithNullTimestamp = { ...baseLog, timestamp: null }; + const logWithNullTimestamp = { ...baseLog, timestamp: null as unknown as Date }; render(LogDetailModal, { props: { log: logWithNullTimestamp, open: true } }); expect(screen.getByText('N/A')).toBeInTheDocument(); diff --git a/src/lib/components/__tests__/log-row.component.test.ts b/src/lib/components/__tests__/log-row.component.test.ts index 3a95cc3..982957d 100644 --- a/src/lib/components/__tests__/log-row.component.test.ts +++ b/src/lib/components/__tests__/log-row.component.test.ts @@ -77,7 +77,7 @@ describe('LogRow', () => { }); it('handles null timestamp gracefully', () => { - const log = { ...baseLog, timestamp: null }; + const log = { ...baseLog, timestamp: null as unknown as Date }; render(LogRow, { props: { log } }); // Should show a placeholder or handle gracefully diff --git a/src/lib/components/__tests__/log-table.component.test.ts b/src/lib/components/__tests__/log-table.component.test.ts index 15aa785..5b6baa4 100644 --- a/src/lib/components/__tests__/log-table.component.test.ts +++ b/src/lib/components/__tests__/log-table.component.test.ts @@ -145,7 +145,7 @@ describe('LogTable', () => { render(LogTable, { props: { logs: sampleLogs, loading: false, onLogClick } }); const rows = screen.getAllByTestId('log-row'); - await rows[0].click(); + await rows[0]!.click(); expect(onLogClick).toHaveBeenCalledTimes(1); expect(onLogClick).toHaveBeenCalledWith(sampleLogs[0]); @@ -192,7 +192,7 @@ describe('LogTable', () => { it('skeleton rows have animated pulse effect', () => { render(LogTable, { props: { logs: [], loading: true } }); - const skeleton = screen.getAllByTestId('log-table-skeleton-row')[0]; + const skeleton = screen.getAllByTestId('log-table-skeleton-row')[0]!; // Check that skeleton children have animation class const skeletonElements = within(skeleton).getAllByRole('presentation', { hidden: true }); expect(skeletonElements.length).toBeGreaterThan(0); @@ -343,9 +343,9 @@ describe('LogTable', () => { // After ascending sort by time: Alpha (14:30) -> Charlie (14:31) -> Beta (14:32) const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[2]).getByText('Beta message')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('Charlie message')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('Beta message')).toBeInTheDocument(); }); it('sorts logs by timestamp descending on second click', async () => { @@ -357,9 +357,9 @@ describe('LogTable', () => { // After descending sort by time: Beta (14:32) -> Charlie (14:31) -> Alpha (14:30) const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[1]).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[2]).getByText('Alpha message')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('Beta message')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('Charlie message')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('Alpha message')).toBeInTheDocument(); }); it('resets sort on third click', async () => { @@ -372,9 +372,9 @@ describe('LogTable', () => { // Original order restored: Alpha -> Beta -> Charlie const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]).getByText('Charlie message')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('Charlie message')).toBeInTheDocument(); }); it('sorts logs by level severity ascending', async () => { @@ -386,9 +386,9 @@ describe('LogTable', () => { // Level priority: debug (1) < warn (3) < error (4) // Ascending: debug -> warn -> error const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('DEBUG')).toBeInTheDocument(); - expect(within(rows[1]).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]).getByText('ERROR')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('DEBUG')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('ERROR')).toBeInTheDocument(); }); it('sorts logs by level severity descending on second click', async () => { @@ -400,9 +400,9 @@ describe('LogTable', () => { // Descending: error -> warn -> debug const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('ERROR')).toBeInTheDocument(); - expect(within(rows[1]).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]).getByText('DEBUG')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('ERROR')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('DEBUG')).toBeInTheDocument(); }); it('sorts logs by message alphabetically ascending', async () => { @@ -413,9 +413,9 @@ describe('LogTable', () => { // Alphabetically: Alpha -> Beta -> Charlie const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]).getByText('Charlie message')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('Charlie message')).toBeInTheDocument(); }); it('sorts logs by message alphabetically descending on second click', async () => { @@ -427,9 +427,9 @@ describe('LogTable', () => { // Descending: Charlie -> Beta -> Alpha const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[1]).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]).getByText('Alpha message')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('Charlie message')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('Alpha message')).toBeInTheDocument(); }); it('switching sort columns resets to ascending', async () => { @@ -444,9 +444,9 @@ describe('LogTable', () => { // After switching to level, should be ascending: debug -> warn -> error const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]).getByText('DEBUG')).toBeInTheDocument(); - expect(within(rows[1]).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]).getByText('ERROR')).toBeInTheDocument(); + expect(within(rows[0]!).getByText('DEBUG')).toBeInTheDocument(); + expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); + expect(within(rows[2]!).getByText('ERROR')).toBeInTheDocument(); }); it('displays sort direction indicator on active column', async () => { diff --git a/src/lib/components/__tests__/stats-skeleton.component.test.ts b/src/lib/components/__tests__/stats-skeleton.component.test.ts index 4175391..5fc9b67 100644 --- a/src/lib/components/__tests__/stats-skeleton.component.test.ts +++ b/src/lib/components/__tests__/stats-skeleton.component.test.ts @@ -69,14 +69,14 @@ describe('StatsSkeleton', () => { render(StatsSkeleton); const legendItems = screen.getAllByTestId('skeleton-legend-item'); - expect(within(legendItems[0]).getByTestId('skeleton-legend-color')).toBeInTheDocument(); + expect(within(legendItems[0]!).getByTestId('skeleton-legend-color')).toBeInTheDocument(); }); it('legend item has text placeholder', () => { render(StatsSkeleton); const legendItems = screen.getAllByTestId('skeleton-legend-item'); - expect(within(legendItems[0]).getByTestId('skeleton-legend-text')).toBeInTheDocument(); + expect(within(legendItems[0]!).getByTestId('skeleton-legend-text')).toBeInTheDocument(); }); }); diff --git a/src/lib/components/export-button.svelte b/src/lib/components/export-button.svelte index 81c6909..091a017 100644 --- a/src/lib/components/export-button.svelte +++ b/src/lib/components/export-button.svelte @@ -87,7 +87,7 @@ async function handleExport(format: 'csv' | 'json') { let filename = `logs-export.${format}`; if (contentDisposition) { const match = contentDisposition.match(/filename="([^"]+)"/); - if (match) filename = match[1]; + if (match?.[1]) filename = match[1]; } const a = document.createElement('a'); diff --git a/src/lib/components/incident-timeline-panel.svelte b/src/lib/components/incident-timeline-panel.svelte index 03fbe78..b0620e6 100644 --- a/src/lib/components/incident-timeline-panel.svelte +++ b/src/lib/components/incident-timeline-panel.svelte @@ -64,6 +64,12 @@ const maxBucketCount = $derived(

Total events: {detail.totalEvents.toLocaleString()}

+ {#if timeline} + + Timeline: {timeline.buckets.reduce((sum, b) => sum + b.count, 0)} events{timeline.peakBucket ? `, peak at ${timeline.peakBucket.timestamp}` : ''}. + + {/if} +

Volume Over Time

{#if timeline} diff --git a/src/lib/components/level-chart.svelte b/src/lib/components/level-chart.svelte index e1cd74c..b8f8b73 100644 --- a/src/lib/components/level-chart.svelte +++ b/src/lib/components/level-chart.svelte @@ -147,8 +147,16 @@ function formatPercentage(value: number): string { > {#each segments as segment} - {#each segment.paths as path} - + {#each segment.paths as path, pi} + + {#if pi === 0} + {segment.level}: {data.levelCounts[segment.level]} ({formatPercentage(data.levelPercentages[segment.level] ?? 0)}%) + {/if} + {/each} {/each} diff --git a/src/lib/components/log-table.svelte b/src/lib/components/log-table.svelte index 6d3027e..33b7781 100644 --- a/src/lib/components/log-table.svelte +++ b/src/lib/components/log-table.svelte @@ -18,6 +18,7 @@ interface Props { project?: { apiKey: string }; appUrl?: string; selectedIndex?: number; + selectedId?: string | null; } const { @@ -30,6 +31,7 @@ const { project, appUrl, selectedIndex = -1, + selectedId = null, }: Props = $props(); // Show quick start empty state when no filters and project/appUrl provided @@ -120,7 +122,7 @@ const sortedLogs = $derived.by(() => { {/if} {:else} {#each sortedLogs as log, i (log.id)} - + {/each} {/if} @@ -219,7 +221,7 @@ const sortedLogs = $derived.by(() => { {/if} {:else} {#each sortedLogs as log, i (log.id)} - + {/each} {/if} diff --git a/src/lib/hooks/__tests__/use-log-stream.unit.test.ts b/src/lib/hooks/__tests__/use-log-stream.component.test.ts similarity index 99% rename from src/lib/hooks/__tests__/use-log-stream.unit.test.ts rename to src/lib/hooks/__tests__/use-log-stream.component.test.ts index 675d9cb..a5b6433 100644 --- a/src/lib/hooks/__tests__/use-log-stream.unit.test.ts +++ b/src/lib/hooks/__tests__/use-log-stream.component.test.ts @@ -13,7 +13,7 @@ function createMockSSEResponse(events: Array<{ event: string; data: string }>): const stream = new ReadableStream({ pull(controller) { if (eventIndex < events.length) { - const event = events[eventIndex]; + const event = events[eventIndex]!; const sseData = `event: ${event.event}\ndata: ${event.data}\n\n`; controller.enqueue(new TextEncoder().encode(sseData)); eventIndex++; @@ -44,7 +44,7 @@ function createDelayedMockSSEResponse( const stream = new ReadableStream({ async pull(controller) { if (eventIndex < events.length) { - const event = events[eventIndex]; + const event = events[eventIndex]!; if (event.delayMs) { await new Promise((resolve) => setTimeout(resolve, event.delayMs)); } diff --git a/src/lib/hooks/use-incident-stream.svelte.ts b/src/lib/hooks/use-incident-stream.svelte.ts index f89192b..d97d2c1 100644 --- a/src/lib/hooks/use-incident-stream.svelte.ts +++ b/src/lib/hooks/use-incident-stream.svelte.ts @@ -50,9 +50,9 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden reconnectBaseDelay = DEFAULT_RECONNECT_BASE_DELAY, } = options; - let _isConnected = false; - let _isConnecting = false; - let _error: Error | null = null; + let _isConnected = $state(false); + let _isConnecting = $state(false); + let _error = $state(null); let _abortController: AbortController | null = null; let _reconnectTimeoutId: ReturnType | null = null; let _reconnectAttempts = 0; diff --git a/src/lib/hooks/use-log-stream.svelte.ts b/src/lib/hooks/use-log-stream.svelte.ts index 42cece5..64a5517 100644 --- a/src/lib/hooks/use-log-stream.svelte.ts +++ b/src/lib/hooks/use-log-stream.svelte.ts @@ -66,9 +66,9 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { } = options; // Internal state - let _isConnected = false; - let _isConnecting = false; - let _error: Error | null = null; + let _isConnected = $state(false); + let _isConnecting = $state(false); + let _error = $state(null); let _abortController: AbortController | null = null; let _reconnectTimeoutId: ReturnType | null = null; let _reconnectAttempts = 0; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index b33a537..9461634 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { username } from 'better-auth/plugins'; +import { env } from '$lib/server/config/env'; import type { DatabaseClient } from './db/db'; /** @@ -21,7 +22,7 @@ export function createAuth(database: DatabaseClient) { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // Update session every 24 hours }, - secret: process.env.BETTER_AUTH_SECRET || 'default-secret-for-development-only', + secret: env.BETTER_AUTH_SECRET, // Self-hosted app - trust the configured ORIGIN for reverse proxies/tunnels trustedOrigins: [process.env.ORIGIN].filter(Boolean) as string[], }); diff --git a/src/lib/server/config/env.ts b/src/lib/server/config/env.ts index 7ccce07..2b4da46 100644 --- a/src/lib/server/config/env.ts +++ b/src/lib/server/config/env.ts @@ -28,25 +28,6 @@ export class EnvValidationError extends Error { } } -/** - * Validation result type - */ -export interface ValidationResult { - valid: boolean; - errors: Array<{ variable: string; message: string }>; -} - -/** - * Environment summary type (with masked sensitive values) - */ -export interface EnvSummary { - DATABASE_URL: string; - BETTER_AUTH_SECRET: string; - ADMIN_PASSWORD: string; - ORIGIN: string; - NODE_ENV: string; -} - // Get NODE_ENV first for conditional validation const nodeEnv = process.env.NODE_ENV || 'development'; const isProd = nodeEnv === 'production'; @@ -95,9 +76,10 @@ if (isProd) { // Throw aggregated error if validation failed if (validationErrors.length > 0) { const errorMessages = validationErrors.map((e) => `- ${e.variable}: ${e.message}`).join('\n'); + const firstError = validationErrors[0]; throw new EnvValidationError( `Environment validation failed:\n${errorMessages}`, - validationErrors[0].variable, + firstError?.variable ?? 'unknown', ); } @@ -136,8 +118,27 @@ export function isDevelopment(): boolean { } /** - * Validate environment configuration - * Returns validation result instead of throwing + * Validation result type + */ +export interface ValidationResult { + valid: boolean; + errors: Array<{ variable: string; message: string }>; +} + +/** + * Environment summary type (with masked sensitive values) + */ +export interface EnvSummary { + DATABASE_URL: string; + BETTER_AUTH_SECRET: string; + ADMIN_PASSWORD: string; + ORIGIN: string; + NODE_ENV: string; +} + +/** + * Validate environment configuration — returns result instead of throwing. + * Used in tests and diagnostics. */ export function validateEnv(): ValidationResult { const errors: Array<{ variable: string; message: string }> = []; @@ -168,40 +169,28 @@ export function validateEnv(): ValidationResult { } } - return { - valid: errors.length === 0, - errors, - }; + return { valid: errors.length === 0, errors }; } -/** - * Mask a sensitive value for logging - */ -function maskValue(value: string | undefined, maskChar = '*'): string { +function maskValue(value: string | undefined): string { if (!value) return '[not set]'; - return maskChar.repeat(Math.min(value.length, 16)); + return '*'.repeat(Math.min(value.length, 16)); } -/** - * Mask a database URL (hide password) - */ function maskDatabaseUrl(url: string | undefined): string { if (!url) return '[not set]'; try { const parsed = new URL(url); - if (parsed.password) { - parsed.password = '****'; - } + if (parsed.password) parsed.password = '****'; return parsed.toString(); } catch { - // If URL parsing fails, just mask the whole thing return maskValue(url); } } /** - * Get a summary of environment configuration with masked sensitive values - * Useful for logging/debugging without exposing secrets + * Get a summary of environment configuration with masked sensitive values. + * Used in tests and diagnostics. */ export function getEnvSummary(): EnvSummary { return { diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index d4f3fd9..4785f09 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -5,7 +5,11 @@ import * as schema from './schema'; if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); -const client = postgres(env.DATABASE_URL); +const client = postgres(env.DATABASE_URL, { + max: 20, + idle_timeout: 30, + connect_timeout: 10, +}); export const db = drizzle(client, { schema }); diff --git a/src/lib/server/db/migrate.ts b/src/lib/server/db/migrate.ts deleted file mode 100644 index e68e36f..0000000 --- a/src/lib/server/db/migrate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { migrate } from 'drizzle-orm/postgres-js/migrator'; -import { db } from './index'; - -let migrated = false; - -/** - * Run database migrations on startup. - * Idempotent - only runs once per process, and Drizzle tracks applied migrations. - */ -export async function runMigrations(): Promise { - if (migrated) return; - - try { - console.log('[db] Running migrations...'); - await migrate(db, { migrationsFolder: './drizzle' }); - console.log('[db] Migrations complete'); - migrated = true; - } catch (error) { - console.error('[db] Migration failed:', error); - throw error; - } -} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 47b5a8b..3a1f3bd 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -18,7 +18,8 @@ export const project = pgTable( { id: text('id').primaryKey(), name: text('name').notNull(), - apiKey: text('api_key').notNull().unique(), + apiKey: text('api_key').notNull().unique().$type(), + apiKeyHash: text('api_key_hash').notNull().default('').unique(), // Owner of the project - required for authorization ownerId: text('owner_id') .notNull() @@ -32,7 +33,6 @@ export const project = pgTable( updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (table) => [ - index('idx_project_api_key').on(table.apiKey), index('idx_project_owner_id').on(table.ownerId), uniqueIndex('uq_project_name_owner').on(table.name, table.ownerId), ], @@ -119,7 +119,7 @@ export const log = pgTable( requestId: text('request_id'), userId: text('user_id'), ipAddress: text('ip_address'), - timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow(), + timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(), search: tsvector('search').generatedAlwaysAs( (): SQL => sql`setweight(to_tsvector('english', ${log.message}), 'A') || @@ -130,7 +130,6 @@ export const log = pgTable( ), }, (table) => [ - index('idx_log_project_id').on(table.projectId), index('idx_log_project_incident_timestamp').on( table.projectId, table.incidentId, @@ -145,8 +144,8 @@ export const log = pgTable( index('idx_log_timestamp').on(table.timestamp), index('idx_log_level').on(table.level), index('idx_log_project_timestamp').on(table.projectId, table.timestamp), - // GIN index for full-text search (needs special handling in test-db.ts) - index('idx_log_search').on(table.search), + // GIN index for full-text search + index('idx_log_search').using('gin', table.search), ], ); diff --git a/src/lib/server/db/test-db.ts b/src/lib/server/db/test-db.ts index cd33a19..80aa9ff 100644 --- a/src/lib/server/db/test-db.ts +++ b/src/lib/server/db/test-db.ts @@ -119,7 +119,11 @@ function generateCreateTableSQL(table: PgTable): string { parts.push(`DEFAULT ${value}`); } } else if (column.default !== undefined) { - const defaultValue = (column.default as unknown as { value?: unknown })?.value; + const rawDefault = column.default; + const defaultValue = + typeof rawDefault === 'object' && rawDefault !== null && 'value' in rawDefault + ? (rawDefault as { value?: unknown }).value + : rawDefault; if (defaultValue && typeof defaultValue === 'object' && 'sql' in defaultValue) { // Handle SQL default expressions const sqlValue = (defaultValue as { sql?: string }).sql; diff --git a/src/lib/server/error-handler.ts b/src/lib/server/error-handler.ts index ca6db73..4a746a2 100644 --- a/src/lib/server/error-handler.ts +++ b/src/lib/server/error-handler.ts @@ -49,7 +49,7 @@ export function createErrorHandler(): (context: ErrorContext) => ErrorResponse { // For 5xx errors, sanitize the message to avoid leaking internal details // For 4xx errors, preserve the user-friendly message - const clientMessage = status >= 500 ? message : message; + const clientMessage = status >= 500 ? 'Internal server error' : message; return { id: errorId, diff --git a/src/lib/server/events.ts b/src/lib/server/events.ts index d3a22aa..8f05bd8 100644 --- a/src/lib/server/events.ts +++ b/src/lib/server/events.ts @@ -45,7 +45,11 @@ class LogEventBus { const projectListeners = this.listeners.get(log.projectId); if (projectListeners) { for (const listener of projectListeners) { - listener(log); + try { + listener(log); + } catch (e) { + console.error('[events] listener error:', e); + } } } } @@ -83,7 +87,11 @@ class LogEventBus { const projectListeners = this.incidentListeners.get(incident.projectId); if (projectListeners) { for (const listener of projectListeners) { - listener(incident); + try { + listener(incident); + } catch (e) { + console.error('[events] listener error:', e); + } } } } diff --git a/src/lib/server/jobs/cleanup-scheduler.ts b/src/lib/server/jobs/cleanup-scheduler.ts index 2a7be06..49453e8 100644 --- a/src/lib/server/jobs/cleanup-scheduler.ts +++ b/src/lib/server/jobs/cleanup-scheduler.ts @@ -3,6 +3,7 @@ import { cleanupOldLogs } from './log-cleanup'; let cleanupStarted = false; let cleanupIntervalId: ReturnType | null = null; +let isRunning = false; /** * Starts the log cleanup scheduler. @@ -22,10 +23,10 @@ export function startCleanupScheduler(): boolean { cleanupStarted = true; // Run immediately on startup - runCleanup(); + runCleanupWithGuard(); // Schedule periodic runs - cleanupIntervalId = setInterval(runCleanup, RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS); + cleanupIntervalId = setInterval(runCleanupWithGuard, RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS); console.log( `[cleanup-scheduler] Started with interval: ${RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS}ms, retention: ${RETENTION_CONFIG.LOG_RETENTION_DAYS} days`, @@ -53,6 +54,19 @@ export function isCleanupSchedulerRunning(): boolean { return cleanupStarted; } +/** + * Overlap guard: skips the cycle if a previous one is still running. + */ +async function runCleanupWithGuard(): Promise { + if (isRunning) return; + isRunning = true; + try { + await runCleanup(); + } finally { + isRunning = false; + } +} + /** * Runs a single cleanup cycle. * Handles errors gracefully to prevent scheduler crash. diff --git a/src/lib/server/jobs/log-cleanup.ts b/src/lib/server/jobs/log-cleanup.ts index cfd2edd..36c5de0 100644 --- a/src/lib/server/jobs/log-cleanup.ts +++ b/src/lib/server/jobs/log-cleanup.ts @@ -1,7 +1,8 @@ -import { and, asc, eq, inArray, lt, sql } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; import { RETENTION_CONFIG } from '$lib/server/config'; import type { DatabaseClient } from '$lib/server/db/db'; -import { log, project } from '$lib/server/db/schema'; +import { getQueryRows } from '$lib/server/db/db'; +import { project } from '$lib/server/db/schema'; export interface CleanupResult { projectsProcessed: number; @@ -59,39 +60,23 @@ export async function cleanupOldLogs(dbClient?: DatabaseClient): Promise`count(*)::int` }) - .from(log) - .where(and(eq(log.projectId, proj.id), lt(log.timestamp, cutoffDate))); - - const totalToDelete = logsToDeleteCount[0]?.count || 0; - - if (totalToDelete === 0) { - // No logs to delete for this project - continue; - } - - // Batch delete logs in chunks of BATCH_SIZE + // Batch delete logs using a CTE to avoid count + id-select round-trips let deletedInProject = 0; - while (deletedInProject < totalToDelete) { - // Get a batch of log IDs to delete - const logsToDelete = await db - .select({ id: log.id }) - .from(log) - .where(and(eq(log.projectId, proj.id), lt(log.timestamp, cutoffDate))) - .orderBy(asc(log.id)) - .limit(BATCH_SIZE); - - if (logsToDelete.length === 0) { - break; - } - - // Delete the batch - const idsToDelete = logsToDelete.map((l) => l.id); - await db.delete(log).where(inArray(log.id, idsToDelete)); - - deletedInProject += logsToDelete.length; + while (true) { + const raw = await db.execute(sql` + WITH batch AS ( + SELECT id FROM "log" + WHERE project_id = ${proj.id} + AND timestamp < ${cutoffDate} + ORDER BY timestamp ASC + LIMIT ${BATCH_SIZE} + ) + DELETE FROM "log" WHERE id IN (SELECT id FROM batch) + RETURNING id + `); + const rows = getQueryRows(raw as Parameters[0]); + if (rows.length === 0) break; + deletedInProject += rows.length; } if (deletedInProject > 0) { diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index b7b0f39..392ee6e 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -1,3 +1,10 @@ +/** + * TEST-ONLY helper — do NOT use in production routes. + * Production code uses auth.api.getSession() which properly validates HMAC signatures. + * This module does a raw DB lookup without signature verification and is only used in + * integration test setup. + */ + import { eq } from 'drizzle-orm'; import { session as sessionTable, user as userTable } from '$lib/server/db/schema'; import type { Session, User } from './auth'; @@ -49,7 +56,9 @@ export async function getSession( if (result.length === 0) return null; - const { session, user } = result[0]; + const resultRow = result[0]; + if (!resultRow) return null; + const { session, user } = resultRow; // Check if session is expired if (session.expiresAt < new Date()) { diff --git a/src/lib/server/utils/api-error.ts b/src/lib/server/utils/api-error.ts new file mode 100644 index 0000000..c02eb0a --- /dev/null +++ b/src/lib/server/utils/api-error.ts @@ -0,0 +1,7 @@ +// Standardized API error response helper (RT-7: consistent error shape) +export function apiError(status: number, error: string, message?: string): Response { + return new Response(JSON.stringify({ error, ...(message ? { message } : {}) }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/src/lib/server/utils/api-key.ts b/src/lib/server/utils/api-key.ts index c705d6d..9f3c24d 100644 --- a/src/lib/server/utils/api-key.ts +++ b/src/lib/server/utils/api-key.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DatabaseClient } from '$lib/server/db/db'; @@ -20,30 +21,60 @@ export class ApiKeyError extends Error { } /** - * API Key cache entry with project ID and expiration time + * API Key cache entry with project ID, key hash, and expiration time */ interface CacheEntry { projectId: string; + keyHash: string; + expiresAt: number; +} + +/** + * Negative cache entry for rejected keys + */ +interface NegativeCacheEntry { expiresAt: number; } /** * In-memory cache for validated API keys - * Maps API key to project ID with TTL + * Maps key hash to project ID with TTL */ const API_KEY_CACHE = new Map(); +/** + * Negative cache for invalid keys (30s TTL to avoid repeated DB hits) + */ +const NEGATIVE_CACHE = new Map(); + /** * Cache TTL in milliseconds (5 minutes) */ const CACHE_TTL_MS = 5 * 60 * 1000; +/** + * Negative cache TTL in milliseconds (30 seconds) + */ +const NEGATIVE_CACHE_TTL_MS = 30 * 1000; + +/** + * Maximum number of entries in the positive cache + */ +const MAX_CACHE_SIZE = 1000; + /** * Regex pattern for API key validation * Format: lw_[32 alphanumeric characters including - and _] */ const API_KEY_REGEX = /^lw_[A-Za-z0-9_-]{32}$/; +/** + * Hash an API key using SHA-256 + */ +export function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + /** * Generates a new API key with format: lw_[32 random alphanumeric characters] * Uses nanoid for cryptographically secure random generation @@ -68,17 +99,29 @@ export function validateApiKeyFormat(key: string): boolean { return API_KEY_REGEX.test(key); } +/** + * Evict the oldest expired entry from the positive cache, or evict oldest entry if at max size + */ +function evictCacheEntry(): void { + const now = Date.now(); + // Find an expired entry first + for (const [key, entry] of API_KEY_CACHE) { + if (entry.expiresAt <= now) { + API_KEY_CACHE.delete(key); + return; + } + } + // No expired entries — evict oldest (first inserted) + const firstKey = API_KEY_CACHE.keys().next().value; + if (firstKey !== undefined) { + API_KEY_CACHE.delete(firstKey); + } +} + /** * Validates API key from request Authorization header and returns project ID * Implements caching with 5-minute TTL for performance - * - * Flow: - * 1. Extract Bearer token from Authorization header - * 2. Validate format using regex - * 3. Check in-memory cache (return if valid and not expired) - * 4. Query database if not in cache - * 5. Update cache on successful validation - * 6. Return project ID + * Uses SHA-256 hash for cache lookup (never stores raw key in cache) * * @param request - Request object containing Authorization header * @param dbClient - Optional database client for testing (uses default if not provided) @@ -100,31 +143,50 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient throw new ApiKeyError(401, 'Invalid API key format'); } - // Check cache - const cached = API_KEY_CACHE.get(apiKey); - if (cached && cached.expiresAt > Date.now()) { + const keyHash = hashApiKey(apiKey); + + // Check negative cache + const negCached = NEGATIVE_CACHE.get(keyHash); + if (negCached && negCached.expiresAt > Date.now()) { + throw new ApiKeyError(401, 'Invalid API key'); + } + + // Check positive cache — validate stored hash matches + const cached = API_KEY_CACHE.get(keyHash); + if (cached && cached.expiresAt > Date.now() && cached.keyHash === keyHash) { return cached.projectId; } // Lazy load default db only when needed (avoids issues in unit tests) const db = dbClient ?? (await import('$lib/server/db')).db; - // Query database + // Query database by key hash const [result] = await db .select({ id: project.id }) .from(project) - .where(eq(project.apiKey, apiKey)); + .where(eq(project.apiKeyHash, keyHash)); if (!result) { + // Store in negative cache + NEGATIVE_CACHE.set(keyHash, { expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS }); throw new ApiKeyError(401, 'Invalid API key'); } - // Update cache - API_KEY_CACHE.set(apiKey, { + // Evict if at capacity before inserting + if (API_KEY_CACHE.size >= MAX_CACHE_SIZE) { + evictCacheEntry(); + } + + // Update positive cache (keyed by hash, never the raw key) + API_KEY_CACHE.set(keyHash, { projectId: result.id, + keyHash, expiresAt: Date.now() + CACHE_TTL_MS, }); + // Remove from negative cache if present + NEGATIVE_CACHE.delete(keyHash); + return result.id; } @@ -138,7 +200,9 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient * @param apiKey - API key to remove from cache */ export function invalidateApiKeyCache(apiKey: string): void { - API_KEY_CACHE.delete(apiKey); + const keyHash = hashApiKey(apiKey); + API_KEY_CACHE.delete(keyHash); + NEGATIVE_CACHE.delete(keyHash); } /** @@ -147,4 +211,5 @@ export function invalidateApiKeyCache(apiKey: string): void { */ export function clearApiKeyCache(): void { API_KEY_CACHE.clear(); + NEGATIVE_CACHE.clear(); } diff --git a/src/lib/server/utils/content-type.ts b/src/lib/server/utils/content-type.ts index acd9be2..8f4b74b 100644 --- a/src/lib/server/utils/content-type.ts +++ b/src/lib/server/utils/content-type.ts @@ -6,7 +6,7 @@ import { json } from '@sveltejs/kit'; */ export function requireJsonContentType(request: Request): Response | null { const contentType = request.headers.get('content-type') ?? ''; - if (!contentType.startsWith('application/json')) { + if (!contentType.toLowerCase().startsWith('application/json')) { return json( { error: 'unsupported_media_type', message: 'Content-Type must be application/json' }, { status: 415 }, diff --git a/src/lib/server/utils/csrf.ts b/src/lib/server/utils/csrf.ts index 31d7949..e445ae1 100644 --- a/src/lib/server/utils/csrf.ts +++ b/src/lib/server/utils/csrf.ts @@ -11,6 +11,11 @@ import type { RequestEvent } from '@sveltejs/kit'; * * This protects against cross-origin POST/PATCH/DELETE while avoiding false * positives for legitimate same-origin requests that don't send Origin. + * + * Note: requests with neither Origin nor Referer are allowed. This is intentional for + * API clients (e.g. SDKs, curl) that don't send these headers. Cross-origin browser + * requests always include Origin per spec. For additional protection on browser-only + * routes, consider requiring Origin when a session cookie is present. */ export function checkCsrfOrigin(event: RequestEvent): Response | null { const method = event.request.method; diff --git a/src/lib/server/utils/csv-serializer.ts b/src/lib/server/utils/csv-serializer.ts index f693298..f03cfce 100644 --- a/src/lib/server/utils/csv-serializer.ts +++ b/src/lib/server/utils/csv-serializer.ts @@ -28,12 +28,13 @@ export function escapeCSVField(field: unknown): string { let value = String(field); // Prefix formula-starting characters to prevent CSV injection (OWASP) - if (/^[=+\-@]/.test(value)) { + // Strip leading whitespace before testing to prevent whitespace bypass + if (/^[=+\-@]/.test(value.trimStart())) { value = `'${value}`; } - // Check if field needs quoting (contains comma, quote, or newline) - if (value.includes(',') || value.includes('"') || value.includes('\n')) { + // Check if field needs quoting (contains comma, quote, newline, or carriage return) + if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) { // Escape double quotes by doubling them const escaped = value.replace(/"/g, '""'); return `"${escaped}"`; diff --git a/src/lib/server/utils/cursor.ts b/src/lib/server/utils/cursor.ts index d1f93a3..cf15ef9 100644 --- a/src/lib/server/utils/cursor.ts +++ b/src/lib/server/utils/cursor.ts @@ -9,11 +9,15 @@ * Encodes a cursor from timestamp and ID * Format: base64url(timestamp_id) * - * @param timestamp - The log timestamp + * @param timestamp - The log timestamp (must not be null) * @param id - The log ID * @returns Base64url-encoded cursor string + * @throws Error if timestamp is null or undefined */ -export function encodeCursor(timestamp: Date, id: string): string { +export function encodeCursor(timestamp: Date | null | undefined, id: string): string { + if (!timestamp) { + throw new Error('Cannot encode cursor for log without timestamp'); + } return Buffer.from(`${timestamp.toISOString()}_${id}`).toString('base64url'); } diff --git a/src/lib/server/utils/incident-backfill.ts b/src/lib/server/utils/incident-backfill.ts index 923612f..66c7458 100644 --- a/src/lib/server/utils/incident-backfill.ts +++ b/src/lib/server/utils/incident-backfill.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray } from 'drizzle-orm'; +import { and, eq, gte, inArray, sql } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DatabaseClient } from '$lib/server/db/db'; import { type Incident, incident, type LogLevel, log } from '$lib/server/db/schema'; @@ -112,6 +112,7 @@ export async function backfillProjectIncidents( }) .returning(); + if (!created) continue; incidentByFingerprint.set(aggregate.fingerprint, created); touchedIncidents.push(created); } @@ -119,8 +120,8 @@ export async function backfillProjectIncidents( let updatedLogs = 0; for (let i = 0; i < logs.length; i++) { - const original = logs[i]; - const enriched = assigned[i]; + const original = logs[i]!; + const enriched = assigned[i]!; if ( original.incidentId === enriched.incidentId && @@ -141,6 +142,45 @@ export async function backfillProjectIncidents( updatedLogs++; } + // Recompute stats for all touched incidents based on their actual linked logs + const touchedIncidentIds = touchedIncidents.map((i) => i.id); + if (touchedIncidentIds.length > 0) { + for (const incidentId of touchedIncidentIds) { + const [stats] = await tx + .select({ + firstSeen: sql`MIN(${log.timestamp})`, + lastSeen: sql`MAX(${log.timestamp})`, + totalEvents: sql`COUNT(*)`, + // Use CASE to order levels: debug=1 < info=2 < warn=3 < error=4 < fatal=5 + highestLevel: sql`(ARRAY['debug','info','warn','error','fatal'])[MAX( + CASE ${log.level} + WHEN 'debug' THEN 1 + WHEN 'info' THEN 2 + WHEN 'warn' THEN 3 + WHEN 'error' THEN 4 + WHEN 'fatal' THEN 5 + ELSE 0 + END + )]`, + }) + .from(log) + .where(eq(log.incidentId, incidentId)); + + if (stats && stats.firstSeen && stats.lastSeen) { + await tx + .update(incident) + .set({ + firstSeen: new Date(stats.firstSeen), + lastSeen: new Date(stats.lastSeen), + totalEvents: Number(stats.totalEvents), + highestLevel: stats.highestLevel, + updatedAt: new Date(), + }) + .where(eq(incident.id, incidentId)); + } + } + } + return { processedLogs: logs.length, updatedLogs, diff --git a/src/lib/server/utils/incident-fingerprint.ts b/src/lib/server/utils/incident-fingerprint.ts index 526e766..c22c0a3 100644 --- a/src/lib/server/utils/incident-fingerprint.ts +++ b/src/lib/server/utils/incident-fingerprint.ts @@ -8,8 +8,9 @@ const UUID_REGEX = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3} /** * Hex identifier matcher. * Matches long hex chunks and 0x-prefixed values. + * Requires at least one a-f letter so pure numeric tokens fall through to NUMBER_REGEX. */ -const HEX_ID_REGEX = /\b0x[0-9a-f]+\b|\b[0-9a-f]{12,}\b/gi; +const HEX_ID_REGEX = /\b0x[0-9a-f]+\b|\b(?=[0-9a-f]*[a-f])[0-9a-f]{12,}\b/gi; /** * IPv4 matcher. diff --git a/src/lib/server/utils/incidents.ts b/src/lib/server/utils/incidents.ts index 2f85ae7..7b1820b 100644 --- a/src/lib/server/utils/incidents.ts +++ b/src/lib/server/utils/incidents.ts @@ -235,6 +235,7 @@ export async function upsertIncidentsForPreparedLogs( }) .returning(); + if (!result) continue; incidentByFingerprint.set(aggregate.fingerprint, result); touchedIncidents.push(result); } diff --git a/src/lib/server/utils/incidents.unit.test.ts b/src/lib/server/utils/incidents.unit.test.ts index f0c5f76..70b8a01 100644 --- a/src/lib/server/utils/incidents.unit.test.ts +++ b/src/lib/server/utils/incidents.unit.test.ts @@ -70,8 +70,8 @@ describe('upsertIncidentsForPreparedLogs', () => { .from(incident) .where(eq(incident.projectId, projectId)); - expect(updatedIncident.firstSeen.toISOString()).toBe('2026-03-01T12:00:00.000Z'); - expect(updatedIncident.lastSeen.toISOString()).toBe('2026-03-02T12:00:00.000Z'); - expect(updatedIncident.totalEvents).toBe(2); + expect(updatedIncident!.firstSeen.toISOString()).toBe('2026-03-01T12:00:00.000Z'); + expect(updatedIncident!.lastSeen.toISOString()).toBe('2026-03-02T12:00:00.000Z'); + expect(updatedIncident!.totalEvents).toBe(2); }); }); diff --git a/src/lib/server/utils/otlp.ts b/src/lib/server/utils/otlp.ts index a20f587..8b8de3b 100644 --- a/src/lib/server/utils/otlp.ts +++ b/src/lib/server/utils/otlp.ts @@ -185,25 +185,6 @@ export function severityNumberToLogLevel(value: number | null | undefined): LogL return 'fatal'; } -export function logLevelToSeverityNumber(level: LogLevel): number { - switch (level) { - case 'debug': - return 5; - case 'info': - return 9; - case 'warn': - return 13; - case 'error': - return 17; - case 'fatal': - return 21; - } -} - -export function dateToUnixNanoString(date: Date): string { - return (BigInt(date.getTime()) * 1000000n).toString(); -} - function attributeString( attributes: Record | null, keys: string[], @@ -265,7 +246,8 @@ export function normalizeSpanId(value: unknown): string | null { return trimmed.toLowerCase(); } -export function parseOtlpAnyValue(value: OtlpAnyValue): unknown { +export function parseOtlpAnyValue(value: OtlpAnyValue, depth = 0): unknown { + if (depth > 32) return null; if (!isRecord(value)) return null; if (value.stringValue !== undefined) return value.stringValue; @@ -281,11 +263,11 @@ export function parseOtlpAnyValue(value: OtlpAnyValue): unknown { if (value.arrayValue !== undefined) { const values = Array.isArray(value.arrayValue?.values) ? (value.arrayValue?.values ?? []) : []; - return values.map((entry) => parseOtlpAnyValue(entry)); + return values.map((entry) => parseOtlpAnyValue(entry, depth + 1)); } if (value.kvlistValue !== undefined) { - return parseKeyValueList(value.kvlistValue?.values); + return parseKeyValueList(value.kvlistValue?.values, depth + 1); } if (value.bytesValue !== undefined) { @@ -295,14 +277,15 @@ export function parseOtlpAnyValue(value: OtlpAnyValue): unknown { return null; } -function parseKeyValueList(values?: OtlpKeyValue[]): Record { +function parseKeyValueList(values?: OtlpKeyValue[], depth = 0): Record { + if (depth > 32) return {}; if (!Array.isArray(values)) return {}; const record: Record = {}; for (const entry of values) { if (!isRecord(entry)) continue; const key = typeof entry.key === 'string' ? entry.key : null; if (!key) continue; - const parsedValue = entry.value ? parseOtlpAnyValue(entry.value) : null; + const parsedValue = entry.value ? parseOtlpAnyValue(entry.value, depth + 1) : null; record[key] = parsedValue; } return record; diff --git a/src/lib/server/utils/otlp.unit.test.ts b/src/lib/server/utils/otlp.unit.test.ts index 95fcc67..f4185a5 100644 --- a/src/lib/server/utils/otlp.unit.test.ts +++ b/src/lib/server/utils/otlp.unit.test.ts @@ -52,7 +52,7 @@ describe('normalizeOtlpLogsRequest', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - const record = records[0]; + const record = records[0]!; expect(record.resourceAttributes).toEqual({ 'service.name': 'api', @@ -99,7 +99,7 @@ describe('normalizeOtlpLogsRequest', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0].message).toBe('{"action":"login","success":true}'); + expect(records[0]!.message).toBe('{"action":"login","success":true}'); }); }); @@ -199,10 +199,10 @@ describe('normalizeOtlpLogsRequest edge cases', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0].timeUnixNano).toBeNull(); + expect(records[0]!.timeUnixNano).toBeNull(); const now = new Date(); - expect(records[0].timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); - expect(records[0].timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); + expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); + expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); }); it('rejects negative observedTimeUnixNano and falls back to current timestamp', () => { @@ -224,10 +224,10 @@ describe('normalizeOtlpLogsRequest edge cases', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0].observedTimeUnixNano).toBeNull(); + expect(records[0]!.observedTimeUnixNano).toBeNull(); const now = new Date(); - expect(records[0].timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); - expect(records[0].timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); + expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); + expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); }); it('normalizes empty attributes to null', () => { @@ -250,7 +250,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0].attributes).toBeNull(); + expect(records[0]!.attributes).toBeNull(); }); it('handles extremely large timeUnixNano without producing Invalid Date', () => { @@ -272,11 +272,11 @@ describe('normalizeOtlpLogsRequest edge cases', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0].timeUnixNano).toBe('999999999999999999999999999999'); - expect(Number.isNaN(records[0].timestamp.getTime())).toBe(false); + expect(records[0]!.timeUnixNano).toBe('999999999999999999999999999999'); + expect(Number.isNaN(records[0]!.timestamp.getTime())).toBe(false); const now = new Date(); - expect(records[0].timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); - expect(records[0].timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); + expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); + expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); }); }); diff --git a/src/lib/server/utils/rate-limit.ts b/src/lib/server/utils/rate-limit.ts new file mode 100644 index 0000000..9098686 --- /dev/null +++ b/src/lib/server/utils/rate-limit.ts @@ -0,0 +1,34 @@ +// Token bucket per key. Env-configurable via RATE_LIMIT_INGEST_RPM and RATE_LIMIT_LOGIN_RPM. +interface Bucket { + tokens: number; + last: number; +} + +const buckets = new Map(); + +export const INGEST_RPM = Number(process.env.RATE_LIMIT_INGEST_RPM ?? 600); // 600 req/min per key +export const LOGIN_RPM = Number(process.env.RATE_LIMIT_LOGIN_RPM ?? 10); // 10 req/min per IP +const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 min + +// Clean up stale buckets +setInterval(() => { + const now = Date.now(); + for (const [k, b] of buckets) { + if (now - b.last > CLEANUP_INTERVAL) buckets.delete(k); + } +}, CLEANUP_INTERVAL).unref?.(); + +export function checkRateLimit(key: string, rpm: number): boolean { + const now = Date.now(); + const bucket = buckets.get(key) ?? { tokens: rpm, last: now }; + const elapsed = (now - bucket.last) / 60000; // minutes + bucket.tokens = Math.min(rpm, bucket.tokens + elapsed * rpm); + bucket.last = now; + if (bucket.tokens < 1) { + buckets.set(key, bucket); + return false; // rate limited + } + bucket.tokens -= 1; + buckets.set(key, bucket); + return true; +} diff --git a/src/lib/server/utils/search.ts b/src/lib/server/utils/search.ts index 160e952..555daca 100644 --- a/src/lib/server/utils/search.ts +++ b/src/lib/server/utils/search.ts @@ -24,8 +24,8 @@ export function buildSearchQuery(searchTerm: string): string { } // PostgreSQL tsquery special characters that need to be removed - // & | ! ( ) : * \ ' " - const specialCharsRegex = /[&|!():*\\'"]/g; + // & | ! ( ) : * \ ' " < > + const specialCharsRegex = /[&|!():*\\'"<>]/g; // Remove special characters, then split on whitespace const sanitized = searchTerm.replace(specialCharsRegex, ' '); @@ -91,6 +91,23 @@ export async function searchLogs( level: log.level, message: log.message, metadata: log.metadata, + timeUnixNano: log.timeUnixNano, + observedTimeUnixNano: log.observedTimeUnixNano, + severityNumber: log.severityNumber, + severityText: log.severityText, + body: log.body, + droppedAttributesCount: log.droppedAttributesCount, + flags: log.flags, + traceId: log.traceId, + spanId: log.spanId, + resourceAttributes: log.resourceAttributes, + resourceDroppedAttributesCount: log.resourceDroppedAttributesCount, + resourceSchemaUrl: log.resourceSchemaUrl, + scopeName: log.scopeName, + scopeVersion: log.scopeVersion, + scopeAttributes: log.scopeAttributes, + scopeDroppedAttributesCount: log.scopeDroppedAttributesCount, + scopeSchemaUrl: log.scopeSchemaUrl, sourceFile: log.sourceFile, lineNumber: log.lineNumber, requestId: log.requestId, @@ -98,7 +115,7 @@ export async function searchLogs( ipAddress: log.ipAddress, timestamp: log.timestamp, search: log.search, - // Calculate rank for ordering + // Calculate rank for ordering (computed once, referenced by alias in ORDER BY) rank: sql`ts_rank(${log.search}, to_tsquery('english', ${query}))`, }) .from(log) @@ -114,5 +131,5 @@ export async function searchLogs( .limit(limit); // Remove the rank field from results (only used for ordering) - return results.map(({ rank, ...logData }) => logData) as Log[]; + return results.map(({ rank: _rank, ...logData }) => logData); } diff --git a/src/lib/server/utils/simple-ingest.unit.test.ts b/src/lib/server/utils/simple-ingest.unit.test.ts index 9374120..c51924a 100644 --- a/src/lib/server/utils/simple-ingest.unit.test.ts +++ b/src/lib/server/utils/simple-ingest.unit.test.ts @@ -37,50 +37,50 @@ describe('parseSimpleIngestRequest', () => { it('rejects entry missing level', () => { const result = parseSimpleIngestRequest({ message: 'test' }); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain("missing required field 'level'"); + expect(result.errors[0]!).toContain("missing required field 'level'"); }); it('rejects entry with invalid level', () => { const result = parseSimpleIngestRequest({ level: 'invalid', message: 'test' }); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain("invalid level 'invalid'"); - expect(result.errors[0]).toContain('must be one of'); + expect(result.errors[0]!).toContain("invalid level 'invalid'"); + expect(result.errors[0]!).toContain('must be one of'); }); it('rejects entry missing message', () => { const result = parseSimpleIngestRequest({ level: 'info' }); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain("missing required field 'message'"); + expect(result.errors[0]!).toContain("missing required field 'message'"); }); it('rejects entry with non-string message', () => { const result = parseSimpleIngestRequest({ level: 'info', message: 123 }); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain('message must be a string'); + expect(result.errors[0]!).toContain('message must be a string'); }); it('rejects entry with empty message', () => { const result = parseSimpleIngestRequest({ level: 'info', message: ' ' }); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain('message cannot be empty'); + expect(result.errors[0]!).toContain('message cannot be empty'); }); it('rejects null entry', () => { const result = parseSimpleIngestRequest([null]); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain('must be an object'); + expect(result.errors[0]!).toContain('must be an object'); }); it('rejects string entry', () => { const result = parseSimpleIngestRequest(['not an object']); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain('must be an object'); + expect(result.errors[0]!).toContain('must be an object'); }); it('rejects number entry', () => { const result = parseSimpleIngestRequest([123]); expect(result.rejected).toBe(1); - expect(result.errors[0]).toContain('must be an object'); + expect(result.errors[0]!).toContain('must be an object'); }); }); @@ -88,7 +88,7 @@ describe('parseSimpleIngestRequest', () => { it.each(['debug', 'info', 'warn', 'error', 'fatal'] as const)('accepts level "%s"', (level) => { const result = parseSimpleIngestRequest({ level, message: 'test' }); expect(result.accepted).toBe(1); - expect(result.records[0].level).toBe(level); + expect(result.records[0]!.level).toBe(level); }); }); @@ -97,48 +97,48 @@ describe('parseSimpleIngestRequest', () => { it('parses valid ISO8601 timestamp', () => { const timestamp = '2024-01-15T10:30:00Z'; const result = parseSimpleIngestRequest({ ...validEntry, timestamp }); - expect(result.records[0].timestamp).toEqual(new Date(timestamp)); + expect(result.records[0]!.timestamp).toEqual(new Date(timestamp)); }); it('uses current date for invalid timestamp', () => { const before = Date.now(); const result = parseSimpleIngestRequest({ ...validEntry, timestamp: 'invalid' }); const after = Date.now(); - expect(result.records[0].timestamp.getTime()).toBeGreaterThanOrEqual(before); - expect(result.records[0].timestamp.getTime()).toBeLessThanOrEqual(after); + expect(result.records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(before); + expect(result.records[0]!.timestamp.getTime()).toBeLessThanOrEqual(after); }); it('uses current date for missing timestamp', () => { const before = Date.now(); const result = parseSimpleIngestRequest(validEntry); const after = Date.now(); - expect(result.records[0].timestamp.getTime()).toBeGreaterThanOrEqual(before); - expect(result.records[0].timestamp.getTime()).toBeLessThanOrEqual(after); + expect(result.records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(before); + expect(result.records[0]!.timestamp.getTime()).toBeLessThanOrEqual(after); }); it('uses current date for non-string timestamp', () => { const before = Date.now(); const result = parseSimpleIngestRequest({ ...validEntry, timestamp: 12345 }); const after = Date.now(); - expect(result.records[0].timestamp.getTime()).toBeGreaterThanOrEqual(before); - expect(result.records[0].timestamp.getTime()).toBeLessThanOrEqual(after); + expect(result.records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(before); + expect(result.records[0]!.timestamp.getTime()).toBeLessThanOrEqual(after); }); }); describe('service', () => { it('parses service name into resourceAttributes', () => { const result = parseSimpleIngestRequest({ ...validEntry, service: 'my-app' }); - expect(result.records[0].resourceAttributes).toEqual({ 'service.name': 'my-app' }); + expect(result.records[0]!.resourceAttributes).toEqual({ 'service.name': 'my-app' }); }); it('returns null resourceAttributes for missing service', () => { const result = parseSimpleIngestRequest(validEntry); - expect(result.records[0].resourceAttributes).toBeNull(); + expect(result.records[0]!.resourceAttributes).toBeNull(); }); it('returns null resourceAttributes for non-string service', () => { const result = parseSimpleIngestRequest({ ...validEntry, service: 123 }); - expect(result.records[0].resourceAttributes).toBeNull(); + expect(result.records[0]!.resourceAttributes).toBeNull(); }); }); @@ -146,27 +146,27 @@ describe('parseSimpleIngestRequest', () => { it('parses metadata object', () => { const metadata = { foo: 'bar', nested: { a: 1 } }; const result = parseSimpleIngestRequest({ ...validEntry, metadata }); - expect(result.records[0].metadata).toEqual(metadata); + expect(result.records[0]!.metadata).toEqual(metadata); }); it('returns null metadata for missing metadata', () => { const result = parseSimpleIngestRequest(validEntry); - expect(result.records[0].metadata).toBeNull(); + expect(result.records[0]!.metadata).toBeNull(); }); it('returns null metadata for non-object metadata', () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: 'string' }); - expect(result.records[0].metadata).toBeNull(); + expect(result.records[0]!.metadata).toBeNull(); }); it('returns null metadata for null metadata', () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: null }); - expect(result.records[0].metadata).toBeNull(); + expect(result.records[0]!.metadata).toBeNull(); }); it('returns null metadata for empty object metadata', () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: {} }); - expect(result.records[0].metadata).toBeNull(); + expect(result.records[0]!.metadata).toBeNull(); }); }); }); @@ -175,54 +175,54 @@ describe('parseSimpleIngestRequest', () => { describe('sourceFile', () => { it('parses valid sourceFile', () => { const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: '/app/index.ts' }); - expect(result.records[0].sourceFile).toBe('/app/index.ts'); + expect(result.records[0]!.sourceFile).toBe('/app/index.ts'); }); it('returns null sourceFile for missing sourceFile', () => { const result = parseSimpleIngestRequest(validEntry); - expect(result.records[0].sourceFile).toBeNull(); + expect(result.records[0]!.sourceFile).toBeNull(); }); it('returns null sourceFile for non-string sourceFile', () => { const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: 123 }); - expect(result.records[0].sourceFile).toBeNull(); + expect(result.records[0]!.sourceFile).toBeNull(); }); it('returns null sourceFile for null sourceFile', () => { const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: null }); - expect(result.records[0].sourceFile).toBeNull(); + expect(result.records[0]!.sourceFile).toBeNull(); }); }); describe('lineNumber', () => { it('parses valid lineNumber', () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: 42 }); - expect(result.records[0].lineNumber).toBe(42); + expect(result.records[0]!.lineNumber).toBe(42); }); it('returns null lineNumber for missing lineNumber', () => { const result = parseSimpleIngestRequest(validEntry); - expect(result.records[0].lineNumber).toBeNull(); + expect(result.records[0]!.lineNumber).toBeNull(); }); it('returns null lineNumber for non-number lineNumber', () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: '42' }); - expect(result.records[0].lineNumber).toBeNull(); + expect(result.records[0]!.lineNumber).toBeNull(); }); it('returns null lineNumber for zero', () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: 0 }); - expect(result.records[0].lineNumber).toBeNull(); + expect(result.records[0]!.lineNumber).toBeNull(); }); it('returns null lineNumber for negative number', () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: -5 }); - expect(result.records[0].lineNumber).toBeNull(); + expect(result.records[0]!.lineNumber).toBeNull(); }); it('returns null lineNumber for null', () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: null }); - expect(result.records[0].lineNumber).toBeNull(); + expect(result.records[0]!.lineNumber).toBeNull(); }); }); @@ -233,8 +233,8 @@ describe('parseSimpleIngestRequest', () => { sourceFile: '/app/utils.ts', lineNumber: 100, }); - expect(result.records[0].sourceFile).toBe('/app/utils.ts'); - expect(result.records[0].lineNumber).toBe(100); + expect(result.records[0]!.sourceFile).toBe('/app/utils.ts'); + expect(result.records[0]!.lineNumber).toBe(100); }); }); }); @@ -245,7 +245,7 @@ describe('parseSimpleIngestRequest', () => { ...validEntry, metadata: { 'request.id': 'req-123' }, }); - expect(result.records[0].requestId).toBe('req-123'); + expect(result.records[0]!.requestId).toBe('req-123'); }); it('extracts userId from metadata using OTLP attribute keys', () => { @@ -253,7 +253,7 @@ describe('parseSimpleIngestRequest', () => { ...validEntry, metadata: { 'enduser.id': 'user-456' }, }); - expect(result.records[0].userId).toBe('user-456'); + expect(result.records[0]!.userId).toBe('user-456'); }); it('extracts ipAddress from metadata using OTLP attribute keys', () => { @@ -261,7 +261,7 @@ describe('parseSimpleIngestRequest', () => { ...validEntry, metadata: { 'client.address': '192.168.1.1' }, }); - expect(result.records[0].ipAddress).toBe('192.168.1.1'); + expect(result.records[0]!.ipAddress).toBe('192.168.1.1'); }); it('falls back to alternate metadata keys', () => { @@ -269,16 +269,16 @@ describe('parseSimpleIngestRequest', () => { ...validEntry, metadata: { request_id: 'req-789', user_id: 'user-999', ip_address: '10.0.0.1' }, }); - expect(result.records[0].requestId).toBe('req-789'); - expect(result.records[0].userId).toBe('user-999'); - expect(result.records[0].ipAddress).toBe('10.0.0.1'); + expect(result.records[0]!.requestId).toBe('req-789'); + expect(result.records[0]!.userId).toBe('user-999'); + expect(result.records[0]!.ipAddress).toBe('10.0.0.1'); }); it('returns null for missing metadata fields', () => { const result = parseSimpleIngestRequest(validEntry); - expect(result.records[0].requestId).toBeNull(); - expect(result.records[0].userId).toBeNull(); - expect(result.records[0].ipAddress).toBeNull(); + expect(result.records[0]!.requestId).toBeNull(); + expect(result.records[0]!.userId).toBeNull(); + expect(result.records[0]!.ipAddress).toBeNull(); }); it('returns null for empty metadata', () => { @@ -286,9 +286,9 @@ describe('parseSimpleIngestRequest', () => { ...validEntry, metadata: {}, }); - expect(result.records[0].requestId).toBeNull(); - expect(result.records[0].userId).toBeNull(); - expect(result.records[0].ipAddress).toBeNull(); + expect(result.records[0]!.requestId).toBeNull(); + expect(result.records[0]!.userId).toBeNull(); + expect(result.records[0]!.ipAddress).toBeNull(); }); }); @@ -324,14 +324,14 @@ describe('parseSimpleIngestRequest', () => { ]; const result = parseSimpleIngestRequest(entries); expect(result.records).toHaveLength(2); - expect(result.records[0].message).toBe('test message'); - expect(result.records[1].message).toBe('good'); + expect(result.records[0]!.message).toBe('test message'); + expect(result.records[1]!.message).toBe('good'); }); it('includes index in error messages', () => { const entries = [validEntry, validEntry, { level: 'invalid', message: 'bad' }]; const result = parseSimpleIngestRequest(entries); - expect(result.errors[0]).toContain('index 2'); + expect(result.errors[0]!).toContain('index 2'); }); }); }); diff --git a/src/lib/shared/schemas/incident.ts b/src/lib/shared/schemas/incident.ts index 573b59a..772cd93 100644 --- a/src/lib/shared/schemas/incident.ts +++ b/src/lib/shared/schemas/incident.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { LOG_LEVELS, type LogLevel } from './log'; +import type { LogLevel } from './log'; /** * Valid incident status values. @@ -106,6 +106,3 @@ const LEVEL_RANK: Record = { export function maxIncidentLevel(a: LogLevel, b: LogLevel): LogLevel { return LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b; } - -// Preserve runtime reference to LOG_LEVELS so bundlers keep a single source of truth. -void LOG_LEVELS; diff --git a/src/lib/stores/__tests__/logs.unit.test.ts b/src/lib/stores/__tests__/logs.unit.test.ts index d520122..e382962 100644 --- a/src/lib/stores/__tests__/logs.unit.test.ts +++ b/src/lib/stores/__tests__/logs.unit.test.ts @@ -1,277 +1,25 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { type ClientLog, createLogStreamStore } from '../logs.svelte'; - -/** - * Helper function to create sample logs for testing - */ -function createSampleLog(overrides: Partial = {}): ClientLog { - return { - id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`, - projectId: 'project-1', - level: 'info', - message: 'Test log message', - metadata: null, - incidentId: null, - fingerprint: null, - serviceName: null, - sourceFile: null, - lineNumber: null, - requestId: null, - userId: null, - ipAddress: null, - timestamp: new Date().toISOString(), - ...overrides, - }; -} - -/** - * Helper function to create multiple logs with unique IDs - */ -function createSampleLogs(count: number, overrides: Partial = {}): ClientLog[] { - return Array.from({ length: count }, (_, i) => - createSampleLog({ - id: `log-${i + 1}`, - message: `Log message ${i + 1}`, - ...overrides, - }), - ); -} - -describe('Log Stream Store', () => { - let store: ReturnType; - - beforeEach(() => { - store = createLogStreamStore(); - }); - - describe('initialization', () => { - it('initializes with empty logs array', () => { - expect(store.logs).toEqual([]); - }); - - it('initializes with null projectId', () => { - expect(store.projectId).toBeNull(); - }); - - it('initializes with default maxLogs of 1000', () => { - expect(store.maxLogs).toBe(1000); - }); - - it('accepts custom maxLogs limit', () => { - const customStore = createLogStreamStore({ maxLogs: 500 }); - expect(customStore.maxLogs).toBe(500); - }); - }); - - describe('addLogs', () => { - it('adds logs to empty store', () => { - const logs = createSampleLogs(3); - store.addLogs(logs); - - expect(store.logs).toHaveLength(3); - }); - - it('prepends new logs to existing logs', () => { - const initialLogs = createSampleLogs(2, { message: 'Initial' }); - const newLogs = createSampleLogs(2, { message: 'New' }); - - store.addLogs(initialLogs); - store.addLogs(newLogs); - - // New logs should be at the beginning - expect(store.logs).toHaveLength(4); - expect(store.logs[0].message).toBe('New'); - expect(store.logs[1].message).toBe('New'); - expect(store.logs[2].message).toBe('Initial'); - expect(store.logs[3].message).toBe('Initial'); - }); - - it('maintains maxLogs limit when adding logs', () => { - const store = createLogStreamStore({ maxLogs: 5 }); - - // Add 3 logs - store.addLogs(createSampleLogs(3)); - expect(store.logs).toHaveLength(3); - - // Add 4 more logs (total would be 7, but max is 5) - store.addLogs(createSampleLogs(4)); - expect(store.logs).toHaveLength(5); - }); - - it('keeps newest logs when exceeding maxLogs', () => { - const store = createLogStreamStore({ maxLogs: 3 }); - - // Add initial logs with IDs log-1, log-2, log-3 - store.addLogs([ - createSampleLog({ id: 'old-1' }), - createSampleLog({ id: 'old-2' }), - createSampleLog({ id: 'old-3' }), - ]); - - // Add new logs that will push out old ones - store.addLogs([createSampleLog({ id: 'new-1' }), createSampleLog({ id: 'new-2' })]); - - // Should have new-1, new-2, old-1 (oldest logs trimmed) - expect(store.logs).toHaveLength(3); - expect(store.logs[0].id).toBe('new-1'); - expect(store.logs[1].id).toBe('new-2'); - expect(store.logs[2].id).toBe('old-1'); - }); - - it('handles adding single log', () => { - const log = createSampleLog(); - store.addLogs([log]); - - expect(store.logs).toHaveLength(1); - expect(store.logs[0]).toEqual(log); - }); - - it('handles adding empty array', () => { - store.addLogs([]); - expect(store.logs).toHaveLength(0); - }); - }); - - describe('clearLogs', () => { - it('clears all logs', () => { - store.addLogs(createSampleLogs(5)); - expect(store.logs).toHaveLength(5); - - store.clearLogs(); - expect(store.logs).toHaveLength(0); - }); - - it('can add logs after clearing', () => { - store.addLogs(createSampleLogs(3)); - store.clearLogs(); - store.addLogs(createSampleLogs(2)); - - expect(store.logs).toHaveLength(2); - }); - }); - - describe('project change', () => { - it('clears logs when project changes', () => { - store.setProjectId('project-1'); - store.addLogs(createSampleLogs(5, { projectId: 'project-1' })); - expect(store.logs).toHaveLength(5); - - store.setProjectId('project-2'); - expect(store.logs).toHaveLength(0); - }); - - it('does not clear logs when setting same project', () => { - store.setProjectId('project-1'); - store.addLogs(createSampleLogs(5)); - - store.setProjectId('project-1'); - expect(store.logs).toHaveLength(5); - }); - - it('clears logs when project is set to null', () => { - store.setProjectId('project-1'); - store.addLogs(createSampleLogs(5)); - - store.setProjectId(null); - expect(store.logs).toHaveLength(0); - }); - - it('updates projectId when changed', () => { - store.setProjectId('project-1'); - expect(store.projectId).toBe('project-1'); - - store.setProjectId('project-2'); - expect(store.projectId).toBe('project-2'); - }); - - it('sets projectId from null to value', () => { - expect(store.projectId).toBeNull(); - - store.setProjectId('project-1'); - expect(store.projectId).toBe('project-1'); - }); - }); - - describe('logCount', () => { - it('returns 0 for empty store', () => { - expect(store.logCount).toBe(0); - }); - - it('returns correct count after adding logs', () => { - store.addLogs(createSampleLogs(5)); - expect(store.logCount).toBe(5); - }); - - it('updates after clearing logs', () => { - store.addLogs(createSampleLogs(5)); - store.clearLogs(); - expect(store.logCount).toBe(0); - }); - }); - - describe('performance optimizations', () => { - it('handles rapid successive addLogs calls efficiently', () => { - const store = createLogStreamStore({ maxLogs: 100 }); - const startTime = performance.now(); - - // Simulate rapid SSE updates (50 batches of 5 logs each) - for (let batch = 0; batch < 50; batch++) { - store.addLogs(createSampleLogs(5, { message: `Batch ${batch}` })); - } - - const elapsed = performance.now() - startTime; - - // Should complete in under 100ms (very generous for 250 log operations) - expect(elapsed).toBeLessThan(100); - expect(store.logs).toHaveLength(100); - // Newest logs should be at the beginning - expect(store.logs[0].message).toContain('Batch 49'); - }); - - it('does not leak memory when repeatedly hitting maxLogs limit', () => { - const store = createLogStreamStore({ maxLogs: 10 }); - - // Add 100 batches of 5 logs each - for (let i = 0; i < 100; i++) { - store.addLogs(createSampleLogs(5)); - } - - // Should never exceed maxLogs - expect(store.logs).toHaveLength(10); - }); - - it('efficiently handles single log additions', () => { - const store = createLogStreamStore({ maxLogs: 1000 }); - const startTime = performance.now(); - - // Simulate real-time single log arrivals (common SSE pattern) - for (let i = 0; i < 200; i++) { - store.addLogs([createSampleLog({ id: `single-${i}` })]); - } - - const elapsed = performance.now() - startTime; - - // Should complete in under 50ms - expect(elapsed).toBeLessThan(50); - expect(store.logs).toHaveLength(200); - }); - - it('handles large batch additions without creating excessive intermediate arrays', () => { - const store = createLogStreamStore({ maxLogs: 500 }); - - // Add a large batch - const largeBatch = createSampleLogs(300); - store.addLogs(largeBatch); - - expect(store.logs).toHaveLength(300); - - // Add another batch that will cause trimming - const anotherBatch = createSampleLogs(300, { message: 'second batch' }); - store.addLogs(anotherBatch); - - expect(store.logs).toHaveLength(500); - // First 300 should be from second batch - expect(store.logs[0].message).toBe('second batch'); - }); +import { describe, expect, it } from 'vitest'; +import type { ClientLog } from '../logs.svelte'; + +describe('ClientLog type', () => { + it('can create a ClientLog-shaped object', () => { + const log: ClientLog = { + id: 'log-1', + projectId: 'project-1', + level: 'info', + message: 'Test log message', + metadata: null, + incidentId: null, + fingerprint: null, + serviceName: null, + sourceFile: null, + lineNumber: null, + requestId: null, + userId: null, + ipAddress: null, + timestamp: new Date().toISOString(), + }; + expect(log.id).toBe('log-1'); + expect(log.level).toBe('info'); }); }); diff --git a/src/lib/stores/logs.svelte.ts b/src/lib/stores/logs.svelte.ts index 0542d42..fcf07c8 100644 --- a/src/lib/stores/logs.svelte.ts +++ b/src/lib/stores/logs.svelte.ts @@ -20,119 +20,3 @@ export interface ClientLog { ipAddress: string | null; timestamp: string; } - -/** - * Configuration options for the log stream store - */ -export interface LogStreamStoreOptions { - maxLogs?: number; -} - -/** - * Default maximum number of logs to keep in memory - */ -const DEFAULT_MAX_LOGS = 1000; - -/** - * Creates a reactive log stream store for managing real-time logs - * - * Features: - * - Prepends new logs to maintain newest-first order - * - Enforces maxLogs limit to prevent memory issues - * - Clears logs when project changes - * - * @param options - Store configuration options - * @returns Log stream store instance - */ -export function createLogStreamStore(options: LogStreamStoreOptions = {}) { - const maxLogs = options.maxLogs ?? DEFAULT_MAX_LOGS; - - // Internal state - let _logs: ClientLog[] = []; - let _projectId: string | null = null; - - return { - /** - * Maximum number of logs to keep in memory - */ - get maxLogs(): number { - return maxLogs; - }, - - /** - * Current logs array (newest first) - */ - get logs(): ClientLog[] { - return _logs; - }, - - /** - * Current project ID - */ - get projectId(): string | null { - return _projectId; - }, - - /** - * Number of logs currently in the store - */ - get logCount(): number { - return _logs.length; - }, - - /** - * Adds new logs to the store (prepends to maintain newest-first order) - * Trims oldest logs if exceeding maxLogs limit - * - * Optimized to avoid creating intermediate arrays larger than maxLogs. - * - * @param newLogs - Array of logs to add - */ - addLogs(newLogs: ClientLog[]): void { - if (newLogs.length === 0) return; - - const newCount = newLogs.length; - - // If new logs alone fill or exceed maxLogs, just use them (trimmed) - if (newCount >= maxLogs) { - _logs = newLogs.slice(0, maxLogs); - return; - } - - // Calculate how many existing logs we can keep - const keepFromExisting = Math.min(_logs.length, maxLogs - newCount); - - if (keepFromExisting === 0) { - // No existing logs or none to keep - _logs = newLogs; - } else if (keepFromExisting === _logs.length) { - // Can keep all existing logs - just prepend - _logs = [...newLogs, ..._logs]; - } else { - // Need to trim existing logs - slice before spreading to avoid large intermediate array - _logs = [...newLogs, ..._logs.slice(0, keepFromExisting)]; - } - }, - - /** - * Clears all logs from the store - */ - clearLogs(): void { - _logs = []; - }, - - /** - * Sets the current project ID - * Clears logs if project changes (to a different non-null value or to null) - * - * @param projectId - New project ID or null - */ - setProjectId(projectId: string | null): void { - // Only clear logs if the project actually changes - if (_projectId !== projectId) { - _logs = []; - _projectId = projectId; - } - }, - }; -} diff --git a/src/lib/utils/focus-trap.ts b/src/lib/utils/focus-trap.ts index f3fa5b8..1624579 100644 --- a/src/lib/utils/focus-trap.ts +++ b/src/lib/utils/focus-trap.ts @@ -64,6 +64,8 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; + if (!firstFocusable || !lastFocusable) return; + if (event.shiftKey) { // Shift + Tab: if on first element, wrap to last if (document.activeElement === firstFocusable) { @@ -94,7 +96,7 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) elementToFocus = initialFocus; } else { // Default to first focusable element - elementToFocus = focusableElements[0]; + elementToFocus = focusableElements[0] ?? null; } if (elementToFocus) { @@ -121,18 +123,6 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) previouslyFocused.focus(); } }, - - /** - * Updates the initial focus element and focuses it. - */ - updateInitialFocus(element: HTMLElement | string | null) { - if (typeof element === 'string') { - const el = container.querySelector(element); - el?.focus(); - } else if (element) { - element.focus(); - } - }, }; } diff --git a/src/lib/utils/timeseries.ts b/src/lib/utils/timeseries.ts index d983e10..dac02ef 100644 --- a/src/lib/utils/timeseries.ts +++ b/src/lib/utils/timeseries.ts @@ -73,33 +73,3 @@ export function fillMissingBuckets( return result; } - -/** - * Format a bucket timestamp for display on chart axis - */ -export function formatBucketLabel(timestamp: Date, range: TimeRange): string { - const hours = timestamp.getUTCHours().toString().padStart(2, '0'); - const minutes = timestamp.getUTCMinutes().toString().padStart(2, '0'); - - if (range === '7d') { - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - const month = months[timestamp.getUTCMonth()]; - const day = timestamp.getUTCDate(); - return `${month} ${day} ${hours}:${minutes}`; - } - - return `${hours}:${minutes}`; -} diff --git a/src/lib/utils/timeseries.unit.test.ts b/src/lib/utils/timeseries.unit.test.ts index 8b03fe4..f09d62a 100644 --- a/src/lib/utils/timeseries.unit.test.ts +++ b/src/lib/utils/timeseries.unit.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - bucketTimestamps, - fillMissingBuckets, - formatBucketLabel, - getTimeBucketConfig, -} from './timeseries'; +import { bucketTimestamps, fillMissingBuckets, getTimeBucketConfig } from './timeseries'; describe('getTimeBucketConfig', () => { it('returns 60000ms interval for 15m range', () => { @@ -103,9 +98,9 @@ describe('fillMissingBuckets', () => { const result = fillMissingBuckets(bucketCounts, config, rangeStart, rangeEnd); expect(result).toHaveLength(3); - expect(result[0].count).toBe(5); - expect(result[1].count).toBe(0); // filled with zero - expect(result[2].count).toBe(3); + expect(result[0]!.count).toBe(5); + expect(result[1]!.count).toBe(0); // filled with zero + expect(result[2]!.count).toBe(3); }); it('generates all buckets for completely empty input', () => { @@ -128,8 +123,8 @@ describe('fillMissingBuckets', () => { const result = fillMissingBuckets(bucketCounts, config, rangeStart, rangeEnd); - expect(result[0].count).toBe(10); - expect(result[1].count).toBe(20); + expect(result[0]!.count).toBe(10); + expect(result[1]!.count).toBe(20); }); it('returns buckets with valid ISO timestamps', () => { @@ -139,35 +134,7 @@ describe('fillMissingBuckets', () => { const result = fillMissingBuckets({}, config, rangeStart, rangeEnd); - expect(result[0].timestamp).toBe('2024-01-15T10:00:00.000Z'); - expect(result[1].timestamp).toBe('2024-01-15T11:00:00.000Z'); - }); -}); - -describe('formatBucketLabel', () => { - it('formats minute bucket as HH:mm', () => { - const date = new Date('2024-01-15T14:35:00.000Z'); - expect(formatBucketLabel(date, '15m')).toBe('14:35'); - expect(formatBucketLabel(date, '1h')).toBe('14:35'); - }); - - it('formats hour bucket as HH:00', () => { - const date = new Date('2024-01-15T14:00:00.000Z'); - expect(formatBucketLabel(date, '24h')).toBe('14:00'); - }); - - it('formats 6-hour bucket as MMM DD HH:00', () => { - const date = new Date('2024-01-15T18:00:00.000Z'); - expect(formatBucketLabel(date, '7d')).toBe('Jan 15 18:00'); - }); - - it('handles single-digit day correctly', () => { - const date = new Date('2024-01-05T06:00:00.000Z'); - expect(formatBucketLabel(date, '7d')).toBe('Jan 5 06:00'); - }); - - it('handles midnight correctly', () => { - const date = new Date('2024-01-15T00:00:00.000Z'); - expect(formatBucketLabel(date, '24h')).toBe('00:00'); + expect(result[0]!.timestamp).toBe('2024-01-15T10:00:00.000Z'); + expect(result[1]!.timestamp).toBe('2024-01-15T11:00:00.000Z'); }); }); diff --git a/src/lib/utils/toast.ts b/src/lib/utils/toast.ts index 7a39a1b..52a6fbb 100644 --- a/src/lib/utils/toast.ts +++ b/src/lib/utils/toast.ts @@ -1,14 +1,5 @@ import { type ExternalToast, toast } from 'svelte-sonner'; -export type ToastType = 'success' | 'error' | 'info' | 'warning'; - -/** - * Show a toast notification with the specified type - */ -export function showToast(type: ToastType, message: string, options?: ExternalToast): void { - toast[type](message, options); -} - /** * Show a success toast notification */ @@ -33,17 +24,3 @@ export function toastError(error: string | Error | unknown, options?: ExternalTo toast.error(message, options); } - -/** - * Show an info toast notification - */ -export function toastInfo(message: string, options?: ExternalToast): void { - toast.info(message, options); -} - -/** - * Show a warning toast notification - */ -export function toastWarning(message: string, options?: ExternalToast): void { - toast.warning(message, options); -} diff --git a/src/lib/utils/toast.unit.test.ts b/src/lib/utils/toast.unit.test.ts index 0285b3d..48abba2 100644 --- a/src/lib/utils/toast.unit.test.ts +++ b/src/lib/utils/toast.unit.test.ts @@ -14,37 +14,9 @@ vi.mock('svelte-sonner', () => ({ })); // Import after mocking -import { showToast, toastError, toastInfo, toastSuccess, toastWarning } from './toast'; +import { toastError, toastSuccess } from './toast'; describe('Toast Utility', () => { - describe('showToast', () => { - it('calls toast.success for success type', () => { - showToast('success', 'Success message'); - expect(sonner.toast.success).toHaveBeenCalledWith('Success message', undefined); - }); - - it('calls toast.error for error type', () => { - showToast('error', 'Error message'); - expect(sonner.toast.error).toHaveBeenCalledWith('Error message', undefined); - }); - - it('calls toast.info for info type', () => { - showToast('info', 'Info message'); - expect(sonner.toast.info).toHaveBeenCalledWith('Info message', undefined); - }); - - it('calls toast.warning for warning type', () => { - showToast('warning', 'Warning message'); - expect(sonner.toast.warning).toHaveBeenCalledWith('Warning message', undefined); - }); - - it('passes options to toast function', () => { - const options = { duration: 5000, description: 'More details' }; - showToast('success', 'Success', options); - expect(sonner.toast.success).toHaveBeenCalledWith('Success', options); - }); - }); - describe('toastSuccess', () => { it('calls toast.success with message', () => { toastSuccess('Operation completed'); @@ -73,18 +45,4 @@ describe('Toast Utility', () => { expect(sonner.toast.error).toHaveBeenCalledWith('An unexpected error occurred', undefined); }); }); - - describe('toastInfo', () => { - it('calls toast.info with message', () => { - toastInfo('FYI'); - expect(sonner.toast.info).toHaveBeenCalledWith('FYI', undefined); - }); - }); - - describe('toastWarning', () => { - it('calls toast.warning with message', () => { - toastWarning('Be careful'); - expect(sonner.toast.warning).toHaveBeenCalledWith('Be careful', undefined); - }); - }); }); diff --git a/src/routes/(app)/projects/[id]/+page.server.ts b/src/routes/(app)/projects/[id]/+page.server.ts index eb27872..df9f9ee 100644 --- a/src/routes/(app)/projects/[id]/+page.server.ts +++ b/src/routes/(app)/projects/[id]/+page.server.ts @@ -10,7 +10,7 @@ import type { PageServerLoad } from './$types'; // Constants for pagination const DEFAULT_LIMIT = 100; -const MIN_LIMIT = 100; +const MIN_LIMIT = 1; const MAX_LIMIT = 500; /** @@ -20,10 +20,7 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -/** - * Parse and validate level filter from query string - * Returns array of valid log levels or null if no filter - */ +// TODO(RT-10): deduplicate with api/projects/[id]/logs/+server.ts parseLevelFilter function parseLevelFilter(levelParam: string | null): LogLevel[] | null { if (!levelParam) return null; @@ -35,9 +32,7 @@ function parseLevelFilter(levelParam: string | null): LogLevel[] | null { return levels.length > 0 ? levels : null; } -/** - * Get time range start date based on range parameter - */ +// TODO(RT-10): deduplicate with $lib/utils/format getTimeRangeStart function getTimeRangeStart(range: string | null): Date | null { if (!range) return null; @@ -109,9 +104,9 @@ export const load: PageServerLoad = async (event) => { and(eq(log.timestamp, cursorTimestamp), lt(log.id, cursorId)), ) as SQL, ); - } catch { - // Invalid cursor - ignore it and continue without cursor pagination - // This provides better UX than throwing an error + } catch (err) { + // Invalid cursor - log and fall back to first page (consistent with API behavior) + console.error('[page/logs] invalid cursor, falling back to first page:', err); } } @@ -172,10 +167,7 @@ export const load: PageServerLoad = async (event) => { // Compute next cursor if there are more logs const nextCursor = hasMore && logsToReturn.length > 0 - ? encodeCursor( - logsToReturn[logsToReturn.length - 1].timestamp as Date, - logsToReturn[logsToReturn.length - 1].id, - ) + ? encodeCursor(logsToReturn.at(-1)!.timestamp as Date, logsToReturn.at(-1)!.id) : null; return { @@ -183,6 +175,7 @@ export const load: PageServerLoad = async (event) => { id: projectData.id, name: projectData.name, apiKey: projectData.apiKey, + apiKeyHash: projectData.apiKeyHash, retentionDays: projectData.retentionDays, createdAt: projectData.createdAt?.toISOString() ?? null, updatedAt: projectData.updatedAt?.toISOString() ?? null, diff --git a/src/routes/(app)/projects/[id]/+page.svelte b/src/routes/(app)/projects/[id]/+page.svelte index bb49749..de0ecd5 100644 --- a/src/routes/(app)/projects/[id]/+page.svelte +++ b/src/routes/(app)/projects/[id]/+page.svelte @@ -59,6 +59,7 @@ const projectData = $derived>({ id: data.project.id, name: data.project.name, apiKey: data.project.apiKey, + apiKeyHash: data.project.apiKeyHash, retentionDays: data.project.retentionDays ?? null, createdAt: data.project.createdAt ? new Date(data.project.createdAt) : null, updatedAt: data.project.updatedAt ? new Date(data.project.updatedAt) : null, @@ -75,12 +76,13 @@ let selectedRange = $state((data.filters.range as TimeRange) || '1h') let selectedLog = $state(null); let showDetailModal = $state(false); let showHelpModal = $state(false); -let selectedIndex = $state(-1); +let selectedLogId = $state(null); let loading = $state(false); let searchInputRef = $state(null); // Track new log IDs for highlighting let newLogIds = $state>(new Set()); +const highlightTimers: ReturnType[] = []; // Pagination state for Load More let loadedMoreLogs = $state([]); @@ -110,10 +112,11 @@ function handleIncomingLogs(logs: ClientLog[]) { const ids = parsedLogs.map((l) => l.id); newLogIds = new Set([...ids, ...newLogIds]); - // Remove highlight after 3s - setTimeout(() => { + // Remove highlight after 3s; track timer for cleanup + const timer = setTimeout(() => { newLogIds = new Set([...newLogIds].filter((id) => !ids.includes(id))); }, 3000); + highlightTimers.push(timer); streamedLogs = [...parsedLogs, ...streamedLogs]; } @@ -142,6 +145,8 @@ $effect(() => { return () => { logStream.disconnect(); + for (const t of highlightTimers) clearTimeout(t); + highlightTimers.length = 0; }; }); @@ -184,19 +189,19 @@ const allLogs = $derived.by(() => { function handleSearch(value: string) { searchValue = value; - selectedIndex = -1; + selectedLogId = null; updateFilters(); } function handleLevelChange(levels: LogLevel[]) { selectedLevels = levels; - selectedIndex = -1; + selectedLogId = null; updateFilters(); } function handleTimeRangeChange(range: TimeRange) { selectedRange = range; - selectedIndex = -1; + selectedLogId = null; updateFilters(); } @@ -259,7 +264,7 @@ function clearFilters() { searchValue = ''; selectedLevels = []; selectedRange = '1h'; - selectedIndex = -1; + selectedLogId = null; updateFilters(); } @@ -279,7 +284,10 @@ function handleRemoveRange() { } function scrollSelectedIntoView() { - const element = document.querySelector('[data-selected="true"]'); + // Prefer table row (desktop) to avoid matching hidden mobile card first + const element = + document.querySelector('table [data-selected="true"]') ?? + document.querySelector('[data-selected="true"]'); element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } @@ -302,15 +310,13 @@ function handleKeyboardShortcut(event: KeyboardEvent) { // Skip navigation when loading or no logs if (isLoading || allLogs.length === 0) return; // Navigate to next log - const prevIndexJ = selectedIndex; - if (selectedIndex < allLogs.length - 1) { - selectedIndex++; - } else if (selectedIndex === -1 && allLogs.length > 0) { - selectedIndex = 0; - } - if (selectedIndex !== prevIndexJ) { + const currentIdxJ = selectedLogId ? allLogs.findIndex((l) => l.id === selectedLogId) : -1; + const nextIdxJ = currentIdxJ < allLogs.length - 1 ? currentIdxJ + 1 : currentIdxJ; + if (nextIdxJ !== currentIdxJ || currentIdxJ === -1) { + const newIdx = currentIdxJ === -1 ? 0 : nextIdxJ; + selectedLogId = allLogs[newIdx]?.id ?? null; scrollSelectedIntoView(); - announceToScreenReader(`Log ${selectedIndex + 1} of ${allLogs.length}`); + announceToScreenReader(`Log ${newIdx + 1} of ${allLogs.length}`); } break; } @@ -318,25 +324,25 @@ function handleKeyboardShortcut(event: KeyboardEvent) { // Skip navigation when loading or no logs if (isLoading || allLogs.length === 0) return; // Navigate to previous log - const prevIndexK = selectedIndex; - if (selectedIndex > 0) { - selectedIndex--; - } - if (selectedIndex !== prevIndexK) { + const currentIdxK = selectedLogId ? allLogs.findIndex((l) => l.id === selectedLogId) : -1; + if (currentIdxK > 0) { + selectedLogId = allLogs[currentIdxK - 1]?.id ?? null; scrollSelectedIntoView(); - announceToScreenReader(`Log ${selectedIndex + 1} of ${allLogs.length}`); + announceToScreenReader(`Log ${currentIdxK} of ${allLogs.length}`); } break; } - case 'Enter': + case 'Enter': { // Open modal for selected log (only when a log is selected) - if (selectedIndex >= 0 && selectedIndex < allLogs.length) { - selectedLog = allLogs[selectedIndex]; + const selectedForEnter = selectedLogId ? allLogs.find((l) => l.id === selectedLogId) : null; + if (selectedForEnter) { + selectedLog = selectedForEnter; showDetailModal = true; event.preventDefault(); } // Don't preventDefault when no selection - allow buttons to work normally break; + } case '/': // Focus search input event.preventDefault(); @@ -361,6 +367,7 @@ function handleKeyboardShortcut(event: KeyboardEvent) { {#if isNavigating} {:else} + {#key data.project.id}
@@ -480,7 +487,7 @@ function handleKeyboardShortcut(event: KeyboardEvent) { {newLogIds} project={projectData} appUrl={data.appUrl ?? undefined} - {selectedIndex} + selectedId={selectedLogId} /> @@ -512,6 +519,7 @@ function handleKeyboardShortcut(event: KeyboardEvent) {
{/if}
+ {/key} {/if} diff --git a/src/routes/(app)/projects/[id]/incidents/+page.server.ts b/src/routes/(app)/projects/[id]/incidents/+page.server.ts index 208863e..ff7c4d7 100644 --- a/src/routes/(app)/projects/[id]/incidents/+page.server.ts +++ b/src/routes/(app)/projects/[id]/incidents/+page.server.ts @@ -73,8 +73,9 @@ export const load: PageServerLoad = async (event) => { and(eq(incident.lastSeen, cursorTimestamp), lt(incident.id, cursorId)), ) as SQL, ); - } catch { - // Ignore invalid cursor in page load; fallback to first page. + } catch (err) { + // Invalid cursor - log and fall back to first page (consistent with API behavior) + console.error('[page/incidents] invalid cursor, falling back to first page:', err); } } @@ -94,10 +95,7 @@ export const load: PageServerLoad = async (event) => { const nextCursor = hasMore && incidentsToReturn.length > 0 - ? encodeCursor( - incidentsToReturn[incidentsToReturn.length - 1].lastSeen as Date, - incidentsToReturn[incidentsToReturn.length - 1].id, - ) + ? encodeCursor(incidentsToReturn.at(-1)!.lastSeen as Date, incidentsToReturn.at(-1)!.id) : null; return { diff --git a/src/routes/(app)/projects/[id]/incidents/+page.svelte b/src/routes/(app)/projects/[id]/incidents/+page.svelte index 9a45f36..1598db4 100644 --- a/src/routes/(app)/projects/[id]/incidents/+page.svelte +++ b/src/routes/(app)/projects/[id]/incidents/+page.svelte @@ -1,6 +1,7 @@ + + + +{#if open} + + + + + +{/if} diff --git a/src/lib/components/empty-state-quickstart.svelte b/src/lib/components/empty-state-quickstart.svelte index 6e400d2..f39dee9 100644 --- a/src/lib/components/empty-state-quickstart.svelte +++ b/src/lib/components/empty-state-quickstart.svelte @@ -7,12 +7,15 @@ import Button from './ui/button/button.svelte'; import * as Select from './ui/select'; interface Props { - apiKey: string; + // Plaintext keys are no longer persisted, so the live key is usually + // unavailable here — fall back to a placeholder the user replaces with the + // key they saved at creation. + apiKey?: string; baseUrl: string; class?: string; } -const { apiKey, baseUrl, class: className }: Props = $props(); +const { apiKey = 'YOUR_API_KEY', baseUrl, class: className }: Props = $props(); let selectedExample = $state('curl'); diff --git a/src/lib/components/log-table.svelte b/src/lib/components/log-table.svelte index 33b7781..902935e 100644 --- a/src/lib/components/log-table.svelte +++ b/src/lib/components/log-table.svelte @@ -15,7 +15,7 @@ interface Props { onLogClick?: (log: Log) => void; class?: string; newLogIds?: Set; - project?: { apiKey: string }; + project?: { apiKeyHash: string }; appUrl?: string; selectedIndex?: number; selectedId?: string | null; @@ -114,7 +114,7 @@ const sortedLogs = $derived.by(() => { {/each} {:else if sortedLogs.length === 0} {#if showQuickstartEmptyState && project && appUrl} - + {:else}
{emptyStateMessage} @@ -209,7 +209,7 @@ const sortedLogs = $derived.by(() => { {#if showQuickstartEmptyState && project && appUrl} - + {:else} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 3a1f3bd..8ce1486 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -18,8 +18,9 @@ export const project = pgTable( { id: text('id').primaryKey(), name: text('name').notNull(), - apiKey: text('api_key').notNull().unique().$type(), - apiKeyHash: text('api_key_hash').notNull().default('').unique(), + // API keys are stored hashed only (SHA-256). The plaintext key is shown to + // the user once at creation/regeneration and never persisted. + apiKeyHash: text('api_key_hash').notNull().unique(), // Owner of the project - required for authorization ownerId: text('owner_id') .notNull() diff --git a/src/lib/server/utils/api-key.ts b/src/lib/server/utils/api-key.ts index 9f3c24d..6b76868 100644 --- a/src/lib/server/utils/api-key.ts +++ b/src/lib/server/utils/api-key.ts @@ -62,6 +62,12 @@ const NEGATIVE_CACHE_TTL_MS = 30 * 1000; */ const MAX_CACHE_SIZE = 1000; +/** + * Maximum number of entries in the negative cache (bounds memory under a flood + * of unique invalid keys) + */ +const MAX_NEGATIVE_CACHE_SIZE = 5000; + /** * Regex pattern for API key validation * Format: lw_[32 alphanumeric characters including - and _] @@ -118,6 +124,24 @@ function evictCacheEntry(): void { } } +/** + * Records a key hash in the negative cache, pruning expired entries and + * enforcing a size bound (evicts the oldest entry when at capacity). + */ +function setNegativeCache(keyHash: string): void { + const now = Date.now(); + // Prune expired entries first + for (const [k, v] of NEGATIVE_CACHE) { + if (v.expiresAt <= now) NEGATIVE_CACHE.delete(k); + } + // Enforce the size bound — evict the oldest (first inserted) entry + if (NEGATIVE_CACHE.size >= MAX_NEGATIVE_CACHE_SIZE) { + const oldest = NEGATIVE_CACHE.keys().next().value; + if (oldest !== undefined) NEGATIVE_CACHE.delete(oldest); + } + NEGATIVE_CACHE.set(keyHash, { expiresAt: now + NEGATIVE_CACHE_TTL_MS }); +} + /** * Validates API key from request Authorization header and returns project ID * Implements caching with 5-minute TTL for performance @@ -145,10 +169,13 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient const keyHash = hashApiKey(apiKey); - // Check negative cache + // Check negative cache (prune expired entries on read) const negCached = NEGATIVE_CACHE.get(keyHash); - if (negCached && negCached.expiresAt > Date.now()) { - throw new ApiKeyError(401, 'Invalid API key'); + if (negCached) { + if (negCached.expiresAt > Date.now()) { + throw new ApiKeyError(401, 'Invalid API key'); + } + NEGATIVE_CACHE.delete(keyHash); } // Check positive cache — validate stored hash matches @@ -167,8 +194,8 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient .where(eq(project.apiKeyHash, keyHash)); if (!result) { - // Store in negative cache - NEGATIVE_CACHE.set(keyHash, { expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS }); + // Store in negative cache (bounded + prunes expired) + setNegativeCache(keyHash); throw new ApiKeyError(401, 'Invalid API key'); } @@ -200,7 +227,17 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient * @param apiKey - API key to remove from cache */ export function invalidateApiKeyCache(apiKey: string): void { - const keyHash = hashApiKey(apiKey); + invalidateApiKeyCacheByHash(hashApiKey(apiKey)); +} + +/** + * Invalidates a specific API key from the cache by its SHA-256 hash. + * Useful when only the stored hash is available (e.g. regenerating a key where + * the previous plaintext is no longer persisted). + * + * @param keyHash - SHA-256 hash of the API key to remove from cache + */ +export function invalidateApiKeyCacheByHash(keyHash: string): void { API_KEY_CACHE.delete(keyHash); NEGATIVE_CACHE.delete(keyHash); } diff --git a/src/lib/server/utils/content-type.ts b/src/lib/server/utils/content-type.ts index 8f4b74b..9849c54 100644 --- a/src/lib/server/utils/content-type.ts +++ b/src/lib/server/utils/content-type.ts @@ -6,7 +6,10 @@ import { json } from '@sveltejs/kit'; */ export function requireJsonContentType(request: Request): Response | null { const contentType = request.headers.get('content-type') ?? ''; - if (!contentType.toLowerCase().startsWith('application/json')) { + // Compare the media type token exactly (ignoring parameters like charset) so + // look-alikes such as application/jsonp are rejected. + const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase(); + if (mediaType !== 'application/json') { return json( { error: 'unsupported_media_type', message: 'Content-Type must be application/json' }, { status: 415 }, diff --git a/src/lib/server/utils/incident-backfill.ts b/src/lib/server/utils/incident-backfill.ts index 66c7458..5c8c8db 100644 --- a/src/lib/server/utils/incident-backfill.ts +++ b/src/lib/server/utils/incident-backfill.ts @@ -112,7 +112,9 @@ export async function backfillProjectIncidents( }) .returning(); - if (!created) continue; + if (!created) { + throw new Error(`Failed to create incident for fingerprint: ${aggregate.fingerprint}`); + } incidentByFingerprint.set(aggregate.fingerprint, created); touchedIncidents.push(created); } diff --git a/src/lib/server/utils/incidents.ts b/src/lib/server/utils/incidents.ts index 7b1820b..84c81fd 100644 --- a/src/lib/server/utils/incidents.ts +++ b/src/lib/server/utils/incidents.ts @@ -235,7 +235,9 @@ export async function upsertIncidentsForPreparedLogs( }) .returning(); - if (!result) continue; + if (!result) { + throw new Error(`Incident upsert returned no row for fingerprint: ${aggregate.fingerprint}`); + } incidentByFingerprint.set(aggregate.fingerprint, result); touchedIncidents.push(result); } diff --git a/src/lib/server/utils/incidents.unit.test.ts b/src/lib/server/utils/incidents.unit.test.ts index 70b8a01..baa0492 100644 --- a/src/lib/server/utils/incidents.unit.test.ts +++ b/src/lib/server/utils/incidents.unit.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type * as schema from '../db/schema'; import { incident, project, user } from '../db/schema'; import { setupTestDatabase } from '../db/test-db'; +import { hashApiKey } from './api-key'; import { type PreparedIncidentLog, upsertIncidentsForPreparedLogs } from './incidents'; describe('upsertIncidentsForPreparedLogs', () => { @@ -30,7 +31,7 @@ describe('upsertIncidentsForPreparedLogs', () => { await db.insert(project).values({ id: projectId, name: `project-${projectId}`, - apiKey: `lw_${nanoid(32)}`, + apiKeyHash: hashApiKey(`lw_${nanoid(32)}`), ownerId, }); }); @@ -70,6 +71,7 @@ describe('upsertIncidentsForPreparedLogs', () => { .from(incident) .where(eq(incident.projectId, projectId)); + expect(updatedIncident).toBeDefined(); expect(updatedIncident!.firstSeen.toISOString()).toBe('2026-03-01T12:00:00.000Z'); expect(updatedIncident!.lastSeen.toISOString()).toBe('2026-03-02T12:00:00.000Z'); expect(updatedIncident!.totalEvents).toBe(2); diff --git a/src/lib/server/utils/rate-limit.ts b/src/lib/server/utils/rate-limit.ts index 9098686..d430fc6 100644 --- a/src/lib/server/utils/rate-limit.ts +++ b/src/lib/server/utils/rate-limit.ts @@ -6,8 +6,17 @@ interface Bucket { const buckets = new Map(); -export const INGEST_RPM = Number(process.env.RATE_LIMIT_INGEST_RPM ?? 600); // 600 req/min per key -export const LOGIN_RPM = Number(process.env.RATE_LIMIT_LOGIN_RPM ?? 10); // 10 req/min per IP +/** + * Parse an RPM env value into a finite positive integer, falling back to the + * default when the value is missing, non-numeric, zero, or negative. + */ +function parsePositiveRpm(raw: string | undefined, fallback: number): number { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export const INGEST_RPM = parsePositiveRpm(process.env.RATE_LIMIT_INGEST_RPM, 600); // 600 req/min per key +export const LOGIN_RPM = parsePositiveRpm(process.env.RATE_LIMIT_LOGIN_RPM, 10); // 10 req/min per IP const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 min // Clean up stale buckets @@ -19,10 +28,12 @@ setInterval(() => { }, CLEANUP_INTERVAL).unref?.(); export function checkRateLimit(key: string, rpm: number): boolean { + // Guard against NaN / non-positive capacities reaching the bucket math. + const capacity = Number.isFinite(rpm) && rpm > 0 ? Math.floor(rpm) : 1; const now = Date.now(); - const bucket = buckets.get(key) ?? { tokens: rpm, last: now }; + const bucket = buckets.get(key) ?? { tokens: capacity, last: now }; const elapsed = (now - bucket.last) / 60000; // minutes - bucket.tokens = Math.min(rpm, bucket.tokens + elapsed * rpm); + bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * capacity); bucket.last = now; if (bucket.tokens < 1) { buckets.set(key, bucket); diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index d7a8751..869677b 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -2,6 +2,7 @@ import FolderPlusIcon from '@lucide/svelte/icons/folder-plus'; import PlusIcon from '@lucide/svelte/icons/plus'; import { navigating } from '$app/stores'; +import ApiKeyRevealModal from '$lib/components/api-key-reveal-modal.svelte'; import CreateProjectModal from '$lib/components/create-project-modal.svelte'; import DashboardSkeleton from '$lib/components/dashboard-skeleton.svelte'; import ProjectCard from '$lib/components/project-card.svelte'; @@ -21,6 +22,10 @@ const isLoading = $derived( let projects = $state([...data.projects]); let isCreateModalOpen = $state(false); +// One-time API key reveal shown right after a project is created +let revealedApiKey = $state(''); +let isKeyRevealOpen = $state(false); + function openCreateModal() { isCreateModalOpen = true; } @@ -29,6 +34,11 @@ function closeCreateModal() { isCreateModalOpen = false; } +function closeKeyReveal() { + isKeyRevealOpen = false; + revealedApiKey = ''; +} + async function handleCreateProject(name: string) { const response = await fetch('/api/projects', { method: 'POST', @@ -56,6 +66,12 @@ async function handleCreateProject(name: string) { ...projects, ]; + // Surface the plaintext key once — it is never returned again. + if (result.apiKey) { + revealedApiKey = result.apiKey; + isKeyRevealOpen = true; + } + toastSuccess(`Project "${name}" created successfully`); } @@ -118,3 +134,6 @@ async function handleCreateProject(name: string) { onClose={closeCreateModal} onCreate={handleCreateProject} /> + + + diff --git a/src/routes/(app)/projects/[id]/+page.server.ts b/src/routes/(app)/projects/[id]/+page.server.ts index df9f9ee..d93c0d9 100644 --- a/src/routes/(app)/projects/[id]/+page.server.ts +++ b/src/routes/(app)/projects/[id]/+page.server.ts @@ -174,7 +174,6 @@ export const load: PageServerLoad = async (event) => { project: { id: projectData.id, name: projectData.name, - apiKey: projectData.apiKey, apiKeyHash: projectData.apiKeyHash, retentionDays: projectData.retentionDays, createdAt: projectData.createdAt?.toISOString() ?? null, diff --git a/src/routes/(app)/projects/[id]/+page.svelte b/src/routes/(app)/projects/[id]/+page.svelte index de0ecd5..0766abf 100644 --- a/src/routes/(app)/projects/[id]/+page.svelte +++ b/src/routes/(app)/projects/[id]/+page.svelte @@ -58,7 +58,6 @@ function parseClientLog(log: ClientLog): Log { const projectData = $derived>({ id: data.project.id, name: data.project.name, - apiKey: data.project.apiKey, apiKeyHash: data.project.apiKeyHash, retentionDays: data.project.retentionDays ?? null, createdAt: data.project.createdAt ? new Date(data.project.createdAt) : null, diff --git a/src/routes/(app)/projects/[id]/settings/+page.server.ts b/src/routes/(app)/projects/[id]/settings/+page.server.ts index feb398e..91ce39b 100644 --- a/src/routes/(app)/projects/[id]/settings/+page.server.ts +++ b/src/routes/(app)/projects/[id]/settings/+page.server.ts @@ -35,7 +35,6 @@ export const load: PageServerLoad = async (event) => { project: { id: projectData.id, name: projectData.name, - apiKey: projectData.apiKey, retentionDays: projectData.retentionDays, createdAt: projectData.createdAt?.toISOString() ?? null, updatedAt: projectData.updatedAt?.toISOString() ?? null, diff --git a/src/routes/(app)/projects/[id]/settings/+page.svelte b/src/routes/(app)/projects/[id]/settings/+page.svelte index d97d1d2..ac8817a 100644 --- a/src/routes/(app)/projects/[id]/settings/+page.svelte +++ b/src/routes/(app)/projects/[id]/settings/+page.svelte @@ -15,7 +15,6 @@ interface Props { project: { id: string; name: string; - apiKey: string; retentionDays: number | null; createdAt: string | null; updatedAt: string | null; @@ -36,8 +35,10 @@ const { data }: Props = $props(); // These intentionally capture initial values - we update them locally after API calls // svelte-ignore state_referenced_locally let projectName = $state(data.project.name); -// svelte-ignore state_referenced_locally -let projectApiKey = $state(data.project.apiKey); +// API keys are stored hashed and never returned on load. This holds the +// plaintext key only transiently, right after a regenerate, so it can be shown +// once and copied. +let projectApiKey = $state(''); // svelte-ignore state_referenced_locally let projectRetentionDays = $state(data.project.retentionDays); @@ -86,10 +87,13 @@ const baseUrl = $derived( typeof window !== 'undefined' ? window.location.origin : 'https://your-domain.com', ); -// Code examples +// Code examples. The live key is only available transiently after a regenerate; +// otherwise show a placeholder the user replaces with the key they saved. +const exampleApiKey = $derived(projectApiKey || 'YOUR_API_KEY'); + const simpleCurlCommand = $derived( `curl -X POST ${baseUrl}/v1/ingest \\ - -H "Authorization: Bearer ${projectApiKey}" \\ + -H "Authorization: Bearer ${exampleApiKey}" \\ -H "Content-Type: application/json" \\ -d '{"level": "info", "message": "Hello from my app"}'`, ); @@ -98,7 +102,7 @@ const sdkExample = (packageName: string) => `import { Logwell } from '${packageName}'; const logger = new Logwell({ - apiKey: '${projectApiKey}', + apiKey: '${exampleApiKey}', endpoint: '${baseUrl}', }); @@ -334,38 +338,57 @@ function formatDate(isoDate: string | null): string {

API Key

- Use this key to authenticate your log ingestion requests. + Keys are stored hashed and shown only once. Regenerate to issue a new key — + copy it immediately, as it can't be retrieved later.

-
- {projectApiKey} -
- -
- - -
+ {projectApiKey} +
+

+ Copy this key now — for security it won't be shown again. +

+ +
+ + +
+ {:else} +
+ +
+ {/if}
diff --git a/src/routes/(app)/projects/[id]/stats/+page.server.ts b/src/routes/(app)/projects/[id]/stats/+page.server.ts index e19f32d..afd11b4 100644 --- a/src/routes/(app)/projects/[id]/stats/+page.server.ts +++ b/src/routes/(app)/projects/[id]/stats/+page.server.ts @@ -91,7 +91,6 @@ export const load: PageServerLoad = async (event) => { project: { id: projectData.id, name: projectData.name, - apiKey: projectData.apiKey, createdAt: projectData.createdAt?.toISOString() ?? null, updatedAt: projectData.updatedAt?.toISOString() ?? null, }, diff --git a/src/routes/api/projects/+server.ts b/src/routes/api/projects/+server.ts index c41f354..de8775d 100644 --- a/src/routes/api/projects/+server.ts +++ b/src/routes/api/projects/+server.ts @@ -139,12 +139,12 @@ export async function POST(event: RequestEvent): Promise { return apiError(400, 'duplicate_name', 'A project with this name already exists'); } - // Generate new project with current user as owner + // Generate new project with current user as owner. The plaintext key is + // returned once below and never persisted — only its hash is stored. const generatedApiKey = generateApiKey(); const newProject = { id: nanoid(), name, - apiKey: generatedApiKey, apiKeyHash: hashApiKey(generatedApiKey), ownerId: user.id, }; @@ -157,7 +157,8 @@ export async function POST(event: RequestEvent): Promise { { id: created.id, name: created.name, - apiKey: created.apiKey, + // Shown only once; the plaintext key is not stored and cannot be retrieved later. + apiKey: generatedApiKey, createdAt: created.createdAt?.toISOString(), updatedAt: created.updatedAt?.toISOString(), }, diff --git a/src/routes/api/projects/[id]/+server.ts b/src/routes/api/projects/[id]/+server.ts index 7c6eb79..57149c7 100644 --- a/src/routes/api/projects/[id]/+server.ts +++ b/src/routes/api/projects/[id]/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { and, count, eq, ne } from 'drizzle-orm'; import { getDbClient } from '$lib/server/db/db'; import { log, project } from '$lib/server/db/schema'; -import { invalidateApiKeyCache } from '$lib/server/utils/api-key'; +import { invalidateApiKeyCacheByHash } from '$lib/server/utils/api-key'; import { requireJsonContentType } from '$lib/server/utils/content-type'; import { checkCsrfOrigin } from '$lib/server/utils/csrf'; import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; @@ -19,7 +19,6 @@ import type { RequestEvent } from './$types'; * { * id: string, * name: string, - * apiKey: string, * retentionDays: number | null, * createdAt: string, * updatedAt: string, @@ -74,7 +73,6 @@ export async function GET(event: RequestEvent): Promise { return json({ id: projectData.id, name: projectData.name, - apiKey: projectData.apiKey, retentionDays: projectData.retentionDays, createdAt: projectData.createdAt?.toISOString(), updatedAt: projectData.updatedAt?.toISOString(), @@ -101,7 +99,6 @@ export async function GET(event: RequestEvent): Promise { * { * id: string, * name: string, - * apiKey: string, * retentionDays: number | null, * createdAt: string, * updatedAt: string @@ -168,7 +165,6 @@ export async function PATCH(event: RequestEvent): Promise { return json({ id: currentProject.id, name: currentProject.name, - apiKey: currentProject.apiKey, retentionDays: currentProject.retentionDays, createdAt: currentProject.createdAt?.toISOString(), updatedAt: currentProject.updatedAt?.toISOString(), @@ -204,7 +200,6 @@ export async function PATCH(event: RequestEvent): Promise { return json({ id: updated.id, name: updated.name, - apiKey: updated.apiKey, retentionDays: updated.retentionDays, createdAt: updated.createdAt?.toISOString(), updatedAt: updated.updatedAt?.toISOString(), @@ -241,7 +236,7 @@ export async function DELETE(event: RequestEvent): Promise { const projectId = event.params.id; // Invalidate API key cache BEFORE deleting project to close TOCTOU window - invalidateApiKeyCache(projectData.apiKey); + invalidateApiKeyCacheByHash(projectData.apiKeyHash); // Delete project (logs will cascade delete via FK constraint) await db.delete(project).where(eq(project.id, projectId)); diff --git a/src/routes/api/projects/[id]/incidents/stream/+server.ts b/src/routes/api/projects/[id]/incidents/stream/+server.ts index de930cf..4620001 100644 --- a/src/routes/api/projects/[id]/incidents/stream/+server.ts +++ b/src/routes/api/projects/[id]/incidents/stream/+server.ts @@ -35,26 +35,26 @@ export async function POST(event: RequestEvent): Promise { let flushTimeout: ReturnType | null = null; let isClosed = false; - const sendEvent = (eventName: string, data: string): boolean => { - if (isClosed) return false; + const sendEvent = (eventName: string, data: string): 'sent' | 'backpressure' | 'closed' => { + if (isClosed) return 'closed'; try { const size = (controller as ReadableStreamDefaultController).desiredSize; if (size !== null && size <= 0) { - // Stream backpressure: slow consumer, drop the event - return false; + // Stream backpressure: slow consumer — drop this event but keep the stream open + return 'backpressure'; } controller.enqueue(encoder.encode(formatSSEEvent(eventName, data))); - return true; + return 'sent'; } catch { // Controller closed - return false; + return 'closed'; } }; const flushBatch = () => { if (batch.length > 0) { - const success = sendEvent('incidents', JSON.stringify(batch)); - if (!success) cleanup(); + // Only a closed controller is terminal; backpressure just drops this event. + if (sendEvent('incidents', JSON.stringify(batch)) === 'closed') cleanup(); batch = []; } flushTimeout = null; @@ -79,8 +79,7 @@ export async function POST(event: RequestEvent): Promise { const unsubscribe = logEventBus.onIncident(projectId, handleIncident); const heartbeatInterval = setInterval(() => { - const success = sendEvent('heartbeat', JSON.stringify({ ts: Date.now() })); - if (!success) cleanup(); + if (sendEvent('heartbeat', JSON.stringify({ ts: Date.now() })) === 'closed') cleanup(); }, HEARTBEAT_INTERVAL_MS); const cleanup = () => { diff --git a/src/routes/api/projects/[id]/regenerate/+server.ts b/src/routes/api/projects/[id]/regenerate/+server.ts index 20de6f7..05d0f50 100644 --- a/src/routes/api/projects/[id]/regenerate/+server.ts +++ b/src/routes/api/projects/[id]/regenerate/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { eq } from 'drizzle-orm'; import { getDbClient } from '$lib/server/db/db'; import { project } from '$lib/server/db/schema'; -import { generateApiKey, hashApiKey, invalidateApiKeyCache } from '$lib/server/utils/api-key'; +import { generateApiKey, hashApiKey, invalidateApiKeyCacheByHash } from '$lib/server/utils/api-key'; import { checkCsrfOrigin } from '$lib/server/utils/csrf'; import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; import type { RequestEvent } from './$types'; @@ -35,21 +35,21 @@ export async function POST(event: RequestEvent): Promise { const db = await getDbClient(event.locals); const projectId = event.params.id; - // Generate new API key + // Generate new API key. Only the hash is persisted; the plaintext key is + // returned once below and cannot be retrieved later. const newApiKey = generateApiKey(); - // Update project with new API key and its hash + // Update project with the new API key hash await db .update(project) .set({ - apiKey: newApiKey, apiKeyHash: hashApiKey(newApiKey), updatedAt: new Date(), }) .where(eq(project.id, projectId)); - // Invalidate old API key cache - invalidateApiKeyCache(projectData.apiKey); + // Invalidate the old API key's cache entry by its stored hash + invalidateApiKeyCacheByHash(projectData.apiKeyHash); return json({ apiKey: newApiKey, diff --git a/src/routes/v1/ingest/+server.ts b/src/routes/v1/ingest/+server.ts index 8766231..bf0f5f6 100644 --- a/src/routes/v1/ingest/+server.ts +++ b/src/routes/v1/ingest/+server.ts @@ -57,10 +57,10 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Apply rate limiting per project if (!checkRateLimit(`ingest:${projectId}`, INGEST_RPM)) { - return new Response(JSON.stringify({ error: 'rate_limited' }), { - status: 429, - headers: { 'Content-Type': 'application/json', 'Retry-After': '60' }, - }); + return json( + { error: 'rate_limited', message: 'Rate limit exceeded. Retry in 60 seconds.' }, + { status: 429, headers: { 'Retry-After': '60' } }, + ); } // Parse JSON body diff --git a/src/routes/v1/logs/+server.ts b/src/routes/v1/logs/+server.ts index edcf8ca..7e70cc7 100644 --- a/src/routes/v1/logs/+server.ts +++ b/src/routes/v1/logs/+server.ts @@ -80,23 +80,9 @@ export const POST: RequestHandler = async ({ request, locals }) => { ); } - // Early batch size check before full parse (BU-7): count resourceLogs entries as a heuristic - if ( - body !== null && - typeof body === 'object' && - 'resourceLogs' in body && - Array.isArray((body as { resourceLogs: unknown }).resourceLogs) && - (body as { resourceLogs: unknown[] }).resourceLogs.length > API_CONFIG.BATCH_INSERT_LIMIT - ) { - return json( - { - error: 'batch_too_large', - message: `Batch exceeds maximum limit of ${API_CONFIG.BATCH_INSERT_LIMIT} logs.`, - }, - { status: 400 }, - ); - } - + // Batch size is enforced accurately against the real log-record count after + // normalization below (resourceLogs.length is the resource count, not the + // record count, so an early heuristic could reject valid payloads). let normalized: NormalizedOtlpLogsResult; try { normalized = normalizeOtlpLogsRequest(body); diff --git a/tests/e2e/project-settings.spec.ts b/tests/e2e/project-settings.spec.ts index 750e03c..02068e0 100644 --- a/tests/e2e/project-settings.spec.ts +++ b/tests/e2e/project-settings.spec.ts @@ -212,22 +212,49 @@ test.describe('Project Settings - API Key Section', () => { } }); - test('should display API key', async ({ page }) => { + test('should not display API key on load, only a regenerate button', async ({ page }) => { + // Keys are hashed and shown only once at creation; the settings page no + // longer surfaces the live key on load. + await expect(page.getByTestId('api-key-display')).toHaveCount(0); + await expect(page.getByTestId('api-key-once-warning')).toHaveCount(0); + await expect(page.getByTestId('regenerate-button')).toBeVisible(); + }); + + test('should reveal the new API key after regenerating', async ({ page }) => { + // The key only appears transiently after a regenerate. + await page.getByTestId('regenerate-button').click(); + await page.getByTestId('confirm-regenerate-button').click(); + const apiKeyDisplay = page.getByTestId('api-key-display'); await expect(apiKeyDisplay).toBeVisible(); - await expect(apiKeyDisplay).toContainText(testProject.apiKey); + await expect(apiKeyDisplay).toContainText(/^lw_[A-Za-z0-9_-]{32}$/); + + // The original key is never re-displayed. + await expect(apiKeyDisplay).not.toContainText(testProject.apiKey); + await expect(page.getByTestId('api-key-once-warning')).toBeVisible(); }); - test('should copy API key to clipboard', async ({ page, context, browserName }) => { + test('should copy the regenerated API key to clipboard', async ({ + page, + context, + browserName, + }) => { test.skip(browserName !== 'chromium', 'Clipboard permissions only supported in Chromium'); // Grant clipboard permissions await context.grantPermissions(['clipboard-read', 'clipboard-write']); + // The key (and its copy button) only exist after a regenerate. + await page.getByTestId('regenerate-button').click(); + await page.getByTestId('confirm-regenerate-button').click(); + + const newKey = (await page.getByTestId('api-key-display').textContent())?.trim() ?? ''; + expect(newKey).toMatch(/^lw_[A-Za-z0-9_-]{32}$/); + await page.getByTestId('copy-api-key-button').click(); - // Verify clipboard content + // Verify clipboard content matches the freshly regenerated key const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); - expect(clipboardText).toBe(testProject.apiKey); + expect(clipboardText).toBe(newKey); }); test('should show regenerate confirmation dialog', async ({ page }) => { @@ -247,8 +274,8 @@ test.describe('Project Settings - API Key Section', () => { // Dialog should close await expect(page.getByTestId('regenerate-confirm-dialog')).not.toBeVisible(); - // API key should be unchanged - await expect(page.getByTestId('api-key-display')).toContainText(testProject.apiKey); + // No new key was issued, so nothing is displayed. + await expect(page.getByTestId('api-key-display')).toHaveCount(0); }); test('should regenerate API key', async ({ page }) => { @@ -428,7 +455,8 @@ test.describe('Project Settings - Quick Start Section', () => { const codeBlock = page.getByTestId('example-code'); await expect(codeBlock).toBeVisible(); await expect(codeBlock).toContainText('curl'); - await expect(codeBlock).toContainText(testProject.apiKey); + // On load the live key is not shown; the example uses a placeholder. + await expect(codeBlock).toContainText('YOUR_API_KEY'); }); test('should switch to TypeScript example', async ({ page }) => { @@ -438,7 +466,7 @@ test.describe('Project Settings - Quick Start Section', () => { const codeBlock = page.getByTestId('example-code'); await expect(codeBlock).toContainText('import'); await expect(codeBlock).toContainText('Logwell'); - await expect(codeBlock).toContainText(testProject.apiKey); + await expect(codeBlock).toContainText('YOUR_API_KEY'); }); test('should switch to JSR example', async ({ page }) => { @@ -447,7 +475,20 @@ test.describe('Project Settings - Quick Start Section', () => { const codeBlock = page.getByTestId('example-code'); await expect(codeBlock).toContainText('@divkix/logwell'); - await expect(codeBlock).toContainText(testProject.apiKey); + await expect(codeBlock).toContainText('YOUR_API_KEY'); + }); + + test('should inline the live key into examples after regenerating', async ({ page }) => { + // After regenerating, the freshly issued key is woven into the examples. + await page.getByTestId('regenerate-button').click(); + await page.getByTestId('confirm-regenerate-button').click(); + + const newKey = (await page.getByTestId('api-key-display').textContent())?.trim() ?? ''; + expect(newKey).toMatch(/^lw_[A-Za-z0-9_-]{32}$/); + + const codeBlock = page.getByTestId('example-code'); + await expect(codeBlock).toContainText(newKey); + await expect(codeBlock).not.toContainText('YOUR_API_KEY'); }); test('should copy example code to clipboard', async ({ page, context, browserName }) => { @@ -458,7 +499,7 @@ test.describe('Project Settings - Quick Start Section', () => { const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboardText).toContain('curl'); - expect(clipboardText).toContain(testProject.apiKey); + expect(clipboardText).toContain('YOUR_API_KEY'); }); }); diff --git a/tests/fixtures/db.ts b/tests/fixtures/db.ts index c7c1880..c07745f 100644 --- a/tests/fixtures/db.ts +++ b/tests/fixtures/db.ts @@ -71,13 +71,16 @@ export async function getOrCreateDefaultUser( export function createProjectFactory( overrides: Partial & { ownerId: string }, ): ProjectInsert { - const apiKey = overrides.apiKey ?? generateApiKey(); + // API keys are stored hashed only. Derive the hash from a fresh random key + // unless the caller provides an explicit apiKeyHash. apiKeyHash is applied + // after the spread so overrides can't leave it inconsistent. + const apiKeyHash = + overrides.apiKeyHash ?? createHash('sha256').update(generateApiKey()).digest('hex'); return { id: nanoid(), name: `test-project-${nanoid(8)}`, - apiKey, - apiKeyHash: createHash('sha256').update(apiKey).digest('hex'), ...overrides, + apiKeyHash, }; } @@ -136,6 +139,21 @@ export async function seedProject( return result; } +/** + * Seed a single project and return the row together with the plaintext API key. + * The plaintext key is never stored (only its hash is) — this helper exposes it + * for tests that need to authenticate ingestion requests. + */ +export async function seedProjectWithApiKey( + db: PgliteDatabase, + overrides: Omit, 'apiKeyHash'> = {}, +): Promise { + const apiKey = generateApiKey(); + const apiKeyHash = createHash('sha256').update(apiKey).digest('hex'); + const result = await seedProject(db, { ...overrides, apiKeyHash }); + return { ...result, apiKey }; +} + /** * Seed multiple logs into the database */ diff --git a/tests/integration/api/projects/authorization.integration.test.ts b/tests/integration/api/projects/authorization.integration.test.ts index 6cc420f..a2adcb6 100644 --- a/tests/integration/api/projects/authorization.integration.test.ts +++ b/tests/integration/api/projects/authorization.integration.test.ts @@ -6,6 +6,7 @@ import type * as schema from '$lib/server/db/schema'; import { project } from '$lib/server/db/schema'; import { setupTestDatabase } from '$lib/server/db/test-db'; import { getSession } from '$lib/server/session'; +import { hashApiKey } from '$lib/server/utils/api-key'; import { GET as GET_PROJECTS, POST as POST_PROJECTS, @@ -18,7 +19,7 @@ import { import { GET as GET_LOGS } from '../../../../src/routes/api/projects/[id]/logs/+server'; import { POST as POST_REGENERATE } from '../../../../src/routes/api/projects/[id]/regenerate/+server'; import { GET as GET_STATS } from '../../../../src/routes/api/projects/[id]/stats/+server'; -import { seedProject } from '../../../fixtures/db'; +import { seedProject, seedProjectWithApiKey } from '../../../fixtures/db'; /** * Helper to create a mock SvelteKit RequestEvent for session-authenticated routes @@ -297,7 +298,10 @@ describe('Project Authorization - Ownership Isolation', () => { describe('POST /api/projects/[id]/regenerate - API Key Regeneration Authorization', () => { it("returns 404 when regenerating another user's project API key", async () => { // Create project owned by User B - const projectB = await seedProject(db, { name: 'project-b', ownerId: userB.userId }); + const projectB = await seedProjectWithApiKey(db, { + name: 'project-b', + ownerId: userB.userId, + }); const originalApiKey = projectB.apiKey; // User A tries to regenerate User B's API key @@ -313,12 +317,15 @@ describe('Project Authorization - Ownership Isolation', () => { // Verify API key was NOT changed const [dbProject] = await db.select().from(project).where(eq(project.id, projectB.id)); - expect(dbProject!.apiKey).toBe(originalApiKey); + expect(dbProject!.apiKeyHash).toBe(hashApiKey(originalApiKey)); }); it('allows regenerating own project API key', async () => { // Create project owned by User A - const projectA = await seedProject(db, { name: 'project-a', ownerId: userA.userId }); + const projectA = await seedProjectWithApiKey(db, { + name: 'project-a', + ownerId: userA.userId, + }); const originalApiKey = projectA.apiKey; // User A regenerates their own API key diff --git a/tests/integration/api/projects/project-detail.integration.test.ts b/tests/integration/api/projects/project-detail.integration.test.ts index 86d64f4..181dba1 100644 --- a/tests/integration/api/projects/project-detail.integration.test.ts +++ b/tests/integration/api/projects/project-detail.integration.test.ts @@ -7,11 +7,11 @@ import type * as schema from '$lib/server/db/schema'; import { log, project } from '$lib/server/db/schema'; import { setupTestDatabase } from '$lib/server/db/test-db'; import { getSession } from '$lib/server/session'; -import { clearApiKeyCache, validateApiKey } from '$lib/server/utils/api-key'; +import { clearApiKeyCache, hashApiKey, validateApiKey } from '$lib/server/utils/api-key'; import { DELETE, GET } from '../../../../src/routes/api/projects/[id]/+server'; import { POST as POST_REGENERATE } from '../../../../src/routes/api/projects/[id]/regenerate/+server'; import { POST as POST_INGEST } from '../../../../src/routes/v1/ingest/+server'; -import { seedLogs, seedProject } from '../../../fixtures/db'; +import { seedLogs, seedProject, seedProjectWithApiKey } from '../../../fixtures/db'; /** * Helper to create a mock SvelteKit RequestEvent for [id] routes @@ -164,7 +164,7 @@ describe('GET /api/projects/[id]', () => { // Basic project fields expect(body).toHaveProperty('id', testProject.id); expect(body).toHaveProperty('name', 'my-test-project'); - expect(body).toHaveProperty('apiKey', testProject.apiKey); + expect(body).not.toHaveProperty('apiKey'); expect(body).toHaveProperty('createdAt'); expect(body).toHaveProperty('updatedAt'); @@ -306,7 +306,7 @@ describe('DELETE /api/projects/[id]', () => { }); it('invalidates API key cache on deletion', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); // Validate API key to add to cache const apiKeyRequest = new Request('http://localhost', { @@ -329,7 +329,7 @@ describe('DELETE /api/projects/[id]', () => { }); it('prevents ingestion with deleted project API key', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); // Populate cache by validating the API key const apiKeyRequest = new Request('http://localhost', { @@ -430,7 +430,7 @@ describe('POST /api/projects/[id]/regenerate', () => { describe('API Key Regeneration', () => { it('returns new API key', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); const oldApiKey = testProject.apiKey; const request = new Request(`http://localhost/api/projects/${testProject.id}/regenerate`, { @@ -452,11 +452,11 @@ describe('POST /api/projects/[id]/regenerate', () => { .select() .from(project) .where(eq(project.id, testProject.id)); - expect(updatedProject!.apiKey).toBe(body.apiKey); + expect(updatedProject!.apiKeyHash).toBe(hashApiKey(body.apiKey)); }); it('invalidates old API key', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); const oldApiKey = testProject.apiKey; // Validate old API key to add to cache diff --git a/tests/integration/api/projects/project-rename.integration.test.ts b/tests/integration/api/projects/project-rename.integration.test.ts index 9156dcc..c559087 100644 --- a/tests/integration/api/projects/project-rename.integration.test.ts +++ b/tests/integration/api/projects/project-rename.integration.test.ts @@ -143,7 +143,7 @@ describe('PATCH /api/projects/[id]', () => { expect(body).toHaveProperty('id', testProject.id); expect(body).toHaveProperty('name', 'new-name'); - expect(body).toHaveProperty('apiKey', testProject.apiKey); + expect(body).not.toHaveProperty('apiKey'); expect(body).toHaveProperty('updatedAt'); // Verify in database diff --git a/tests/integration/api/projects/regenerate.integration.test.ts b/tests/integration/api/projects/regenerate.integration.test.ts index 351ac94..d8ea964 100644 --- a/tests/integration/api/projects/regenerate.integration.test.ts +++ b/tests/integration/api/projects/regenerate.integration.test.ts @@ -7,8 +7,9 @@ import type * as schema from '$lib/server/db/schema'; import { project } from '$lib/server/db/schema'; import { setupTestDatabase } from '$lib/server/db/test-db'; import { getSession } from '$lib/server/session'; +import { hashApiKey } from '$lib/server/utils/api-key'; import { POST as POST_REGENERATE } from '../../../../src/routes/api/projects/[id]/regenerate/+server'; -import { seedProject } from '../../../fixtures/db'; +import { seedProject, seedProjectWithApiKey } from '../../../fixtures/db'; function createRequestEvent( request: Request, @@ -124,7 +125,7 @@ describe('POST /api/projects/[id]/regenerate', () => { }); it('returns a new API key different from the old one', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); const oldApiKey = testProject.apiKey; const request = new Request(`http://localhost/api/projects/${testProject.id}/regenerate`, { @@ -142,7 +143,7 @@ describe('POST /api/projects/[id]/regenerate', () => { }); it('updates the API key in the database', async () => { - const testProject = await seedProject(db, { ownerId: userId }); + const testProject = await seedProjectWithApiKey(db, { ownerId: userId }); const request = new Request(`http://localhost/api/projects/${testProject.id}/regenerate`, { method: 'POST', @@ -155,13 +156,13 @@ describe('POST /api/projects/[id]/regenerate', () => { const body = await response.json(); const [dbProject] = await db - .select({ apiKey: project.apiKey }) + .select({ apiKeyHash: project.apiKeyHash }) .from(project) .where(eq(project.id, testProject.id)); expect(dbProject).toBeDefined(); - expect(dbProject!.apiKey).toBe(body.apiKey); - expect(dbProject!.apiKey).not.toBe(testProject.apiKey); + expect(dbProject!.apiKeyHash).toBe(hashApiKey(body.apiKey)); + expect(dbProject!.apiKeyHash).not.toBe(hashApiKey(testProject.apiKey)); }); it('rejects cross-origin request (CSRF)', async () => { diff --git a/tests/integration/api/projects/server.integration.test.ts b/tests/integration/api/projects/server.integration.test.ts index f6f87d4..34c1de1 100644 --- a/tests/integration/api/projects/server.integration.test.ts +++ b/tests/integration/api/projects/server.integration.test.ts @@ -7,6 +7,7 @@ import type * as schema from '$lib/server/db/schema'; import { project } from '$lib/server/db/schema'; import { setupTestDatabase } from '$lib/server/db/test-db'; import { getSession } from '$lib/server/session'; +import { hashApiKey } from '$lib/server/utils/api-key'; import { GET, POST } from '../../../../src/routes/api/projects/+server'; import { seedLogs, seedProject, seedProjects } from '../../../fixtures/db'; @@ -286,7 +287,7 @@ describe('POST /api/projects', () => { const [dbProject] = await db.select().from(project).where(eq(project.id, body.id)); expect(dbProject).toBeDefined(); expect(dbProject!.name).toBe('my-new-project'); - expect(dbProject!.apiKey).toBe(body.apiKey); + expect(dbProject!.apiKeyHash).toBe(hashApiKey(body.apiKey)); }); it('returns 400 for duplicate name for same user', async () => { diff --git a/tests/integration/db/project.integration.test.ts b/tests/integration/db/project.integration.test.ts index 9e1b238..8526ba3 100644 --- a/tests/integration/db/project.integration.test.ts +++ b/tests/integration/db/project.integration.test.ts @@ -40,7 +40,7 @@ describe('Project Table Schema', () => { .values({ id: projectId, name: projectName, - apiKey: apiKey, + apiKeyHash: hashApiKey(apiKey), ownerId: userId, }) .returning(); @@ -48,7 +48,7 @@ describe('Project Table Schema', () => { expect(createdProject).toBeDefined(); expect(createdProject!.id).toBe(projectId); expect(createdProject!.name).toBe(projectName); - expect(createdProject!.apiKey).toBe(apiKey); + expect(createdProject!.apiKeyHash).toBe(hashApiKey(apiKey)); expect(createdProject!.createdAt).toBeInstanceOf(Date); expect(createdProject!.updatedAt).toBeInstanceOf(Date); }); @@ -62,7 +62,6 @@ describe('Project Table Schema', () => { await db.insert(project).values({ id: nanoid(), name: projectName, - apiKey: apiKey1, apiKeyHash: hashApiKey(apiKey1), ownerId: userId, }); @@ -72,7 +71,6 @@ describe('Project Table Schema', () => { db.insert(project).values({ id: nanoid(), name: projectName, - apiKey: apiKey2, apiKeyHash: hashApiKey(apiKey2), ownerId: userId, }), @@ -94,7 +92,6 @@ describe('Project Table Schema', () => { .values({ id: nanoid(), name: projectName, - apiKey: apiKey3, apiKeyHash: hashApiKey(apiKey3), ownerId: otherUserId, }) @@ -114,17 +111,19 @@ describe('Project Table Schema', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); - // Find by API key - const [foundProject] = await db.select().from(project).where(eq(project.apiKey, apiKey)); + // Find by API key hash + const [foundProject] = await db + .select() + .from(project) + .where(eq(project.apiKeyHash, hashApiKey(apiKey))); expect(foundProject).toBeDefined(); expect(foundProject!.id).toBe(projectId); expect(foundProject!.name).toBe(projectName); - expect(foundProject!.apiKey).toBe(apiKey); + expect(foundProject!.apiKeyHash).toBe(hashApiKey(apiKey)); }); }); diff --git a/tests/integration/jobs/log-cleanup-ordering.integration.test.ts b/tests/integration/jobs/log-cleanup-ordering.integration.test.ts index 2820c2a..54f7bf4 100644 --- a/tests/integration/jobs/log-cleanup-ordering.integration.test.ts +++ b/tests/integration/jobs/log-cleanup-ordering.integration.test.ts @@ -1,26 +1,87 @@ -import { describe, expect, it, vi } from 'vitest'; +import { asc, eq } from 'drizzle-orm'; +import type { PgliteDatabase } from 'drizzle-orm/pglite'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type * as schema from '../../../src/lib/server/db/schema'; +import { log } from '../../../src/lib/server/db/schema'; +import { setupTestDatabase } from '../../../src/lib/server/db/test-db'; import { cleanupOldLogs } from '../../../src/lib/server/jobs/log-cleanup'; +import { seedLog, seedProject } from '../../fixtures/db'; + +const DAY_MS = 24 * 60 * 60 * 1000; describe('cleanupOldLogs batch selection', () => { - it('orders batch selection by log id for deterministic deletes', async () => { - let executeCalled = false; - const executeMock = vi.fn().mockImplementation(() => { - executeCalled = true; - // Return empty rows to stop the while loop - return Promise.resolve({ rows: [] }); - }); - - const db = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockResolvedValue([{ id: 'project-1', retentionDays: 7 }]), - }), - execute: executeMock, - }; - - const result = await cleanupOldLogs(db as unknown as Parameters[0]); - - expect(executeCalled).toBe(true); + let db: PgliteDatabase; + + beforeEach(async () => { + const setup = await setupTestDatabase(); + db = setup.db; + }); + + it('deletes only logs older than retention, keeping the most recent', async () => { + const project1 = await seedProject(db, { retentionDays: 7 }); + + const now = new Date(); + // Seed logs across a spread of timestamps, oldest first. + // With a 7-day retention, the three oldest (>7 days) should be deleted and + // the three newest (<7 days) should remain. + const ages = [12, 10, 8, 5, 3, 1]; // days ago + for (const days of ages) { + await seedLog(db, project1.id, { + message: `log-${days}d`, + timestamp: new Date(now.getTime() - days * DAY_MS), + }); + } + + const result = await cleanupOldLogs(db); + + expect(result.errors).toEqual([]); + expect(result.totalLogsDeleted).toBe(3); + expect(result.projectsProcessed).toBe(1); + expect(result.projectsSkipped).toBe(0); + + // The surviving logs must be the three most recent ones, in ascending order. + const remaining = await db + .select() + .from(log) + .where(eq(log.projectId, project1.id)) + .orderBy(asc(log.timestamp)); + + expect(remaining).toHaveLength(3); + expect(remaining.map((l) => l.message)).toEqual(['log-5d', 'log-3d', 'log-1d']); + + // Every surviving log must be newer than the 7-day cutoff. + const cutoff = new Date(now.getTime() - 7 * DAY_MS); + for (const l of remaining) { + expect(l.timestamp.getTime()).toBeGreaterThanOrEqual(cutoff.getTime()); + } + }); + + it('deletes the correct logs per project when retention differs', async () => { + const project1 = await seedProject(db, { retentionDays: 7 }); + const project2 = await seedProject(db, { retentionDays: 30 }); + + const now = new Date(); + const tenDaysAgo = new Date(now.getTime() - 10 * DAY_MS); + const fortyDaysAgo = new Date(now.getTime() - 40 * DAY_MS); + + // project1 (7d): 10-day-old log is deleted. + await seedLog(db, project1.id, { message: 'p1-old', timestamp: tenDaysAgo }); + await seedLog(db, project1.id, { message: 'p1-fresh', timestamp: now }); + + // project2 (30d): 10-day-old kept, 40-day-old deleted. + await seedLog(db, project2.id, { message: 'p2-recent', timestamp: tenDaysAgo }); + await seedLog(db, project2.id, { message: 'p2-old', timestamp: fortyDaysAgo }); + + const result = await cleanupOldLogs(db); + expect(result.errors).toEqual([]); - expect(result.totalLogsDeleted).toBe(0); + expect(result.totalLogsDeleted).toBe(2); // 1 from p1, 1 from p2 + expect(result.projectsProcessed).toBe(2); + + const p1Remaining = await db.select().from(log).where(eq(log.projectId, project1.id)); + expect(p1Remaining.map((l) => l.message)).toEqual(['p1-fresh']); + + const p2Remaining = await db.select().from(log).where(eq(log.projectId, project2.id)); + expect(p2Remaining.map((l) => l.message)).toEqual(['p2-recent']); }); }); diff --git a/tests/integration/otlp/logs.integration.test.ts b/tests/integration/otlp/logs.integration.test.ts index bfa4f36..5162ba1 100644 --- a/tests/integration/otlp/logs.integration.test.ts +++ b/tests/integration/otlp/logs.integration.test.ts @@ -8,7 +8,7 @@ import { setupTestDatabase } from '../../../src/lib/server/db/test-db'; import { logEventBus } from '../../../src/lib/server/events'; import { clearApiKeyCache, validateApiKey } from '../../../src/lib/server/utils/api-key'; import { POST } from '../../../src/routes/v1/logs/+server'; -import { seedProject } from '../../fixtures/db'; +import { seedProjectWithApiKey } from '../../fixtures/db'; function createRequestEvent(request: Request, db: PgliteDatabase) { return { @@ -68,7 +68,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('returns 415 for non-JSON Content-Type', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/logs', { method: 'POST', @@ -89,7 +89,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('ingests OTLP log records and maps core fields', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -165,7 +165,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('returns partial success when invalid log records are present', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -200,7 +200,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('returns accepted count in unified response shape on full success', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -235,7 +235,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('creates incidents for error/fatal OTLP logs', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -297,7 +297,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('rejects negative timeUnixNano and falls back to current timestamp', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -338,7 +338,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it('stores null metadata for empty OTLP attributes', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const payload = { resourceLogs: [ @@ -375,7 +375,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it(`accepts a batch of exactly ${API_CONFIG.BATCH_INSERT_LIMIT} log records`, async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logRecords = Array.from({ length: API_CONFIG.BATCH_INSERT_LIMIT }, (_, i) => ({ body: { stringValue: `Log ${i}` }, @@ -411,7 +411,7 @@ describe('POST /v1/logs (OTLP)', () => { }); it(`rejects a batch exceeding ${API_CONFIG.BATCH_INSERT_LIMIT} log records`, async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logRecords = Array.from({ length: API_CONFIG.BATCH_INSERT_LIMIT + 1 }, (_, i) => ({ body: { stringValue: `Log ${i}` }, @@ -449,7 +449,7 @@ describe('POST /v1/logs (OTLP)', () => { describe('Stale cache handling', () => { it('returns 401 instead of 500 when project is deleted after API key is cached', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); // Populate cache by validating the API key const apiKeyRequest = new Request('http://localhost', { diff --git a/tests/integration/simple-ingest/logs.integration.test.ts b/tests/integration/simple-ingest/logs.integration.test.ts index 31fcbd4..f17b3ef 100644 --- a/tests/integration/simple-ingest/logs.integration.test.ts +++ b/tests/integration/simple-ingest/logs.integration.test.ts @@ -8,7 +8,7 @@ import { setupTestDatabase } from '../../../src/lib/server/db/test-db'; import { logEventBus } from '../../../src/lib/server/events'; import { clearApiKeyCache, validateApiKey } from '../../../src/lib/server/utils/api-key'; import { POST } from '../../../src/routes/v1/ingest/+server'; -import { seedProject } from '../../fixtures/db'; +import { seedProjectWithApiKey } from '../../fixtures/db'; function createRequestEvent(request: Request, db: PgliteDatabase) { return { @@ -85,7 +85,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Single log ingestion', () => { it('ingests a single log with minimal fields', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -111,7 +111,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('ingests a single log with all optional fields', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const timestamp = '2025-01-05T12:00:00.000Z'; const request = new Request('http://localhost/v1/ingest', { @@ -147,7 +147,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('uses current time when timestamp is invalid', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const before = new Date(); const request = new Request('http://localhost/v1/ingest', { @@ -179,7 +179,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Batch ingestion', () => { it('ingests multiple logs in a batch', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logs = [ { level: 'debug', message: 'Starting process' }, @@ -210,7 +210,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('handles partial success with mixed valid/invalid logs', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logs = [ { level: 'info', message: 'Valid log 1' }, @@ -245,7 +245,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it(`accepts a batch of exactly ${API_CONFIG.BATCH_INSERT_LIMIT} logs`, async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logs = Array.from({ length: API_CONFIG.BATCH_INSERT_LIMIT }, (_, i) => ({ level: 'info', @@ -270,7 +270,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it(`rejects a batch exceeding ${API_CONFIG.BATCH_INSERT_LIMIT} logs`, async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const logs = Array.from({ length: API_CONFIG.BATCH_INSERT_LIMIT + 1 }, (_, i) => ({ level: 'info', @@ -298,7 +298,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Validation errors', () => { it('returns 415 for non-JSON Content-Type', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -319,7 +319,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('returns 400 for invalid JSON', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -339,7 +339,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('returns 400 for empty array', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -360,7 +360,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('returns error for missing level field', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -382,7 +382,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('returns error for invalid level value', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -405,7 +405,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('returns error for empty message', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -429,7 +429,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Metadata extraction', () => { it('extracts requestId, userId, ipAddress from metadata into dedicated columns', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -460,7 +460,7 @@ describe('POST /v1/ingest (Simple API)', () => { }); it('stores null metadata for empty metadata object', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -487,7 +487,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Event bus integration', () => { it('emits logs to event bus for real-time streaming', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const emittedLogs: unknown[] = []; logEventBus.onLog(project.id, (log) => { @@ -515,7 +515,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Incident aggregation', () => { it('groups dynamic error variants into one incident fingerprint', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); const request = new Request('http://localhost/v1/ingest', { method: 'POST', @@ -574,7 +574,7 @@ describe('POST /v1/ingest (Simple API)', () => { describe('Stale cache handling', () => { it('returns 401 instead of 500 when project is deleted after API key is cached', async () => { - const project = await seedProject(db); + const project = await seedProjectWithApiKey(db); // Populate cache by validating the API key const apiKeyRequest = new Request('http://localhost', { diff --git a/tests/integration/utils/api-key.integration.test.ts b/tests/integration/utils/api-key.integration.test.ts index 140a86a..5e46508 100644 --- a/tests/integration/utils/api-key.integration.test.ts +++ b/tests/integration/utils/api-key.integration.test.ts @@ -36,7 +36,6 @@ describe('API Key Validation with Database', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); @@ -106,7 +105,6 @@ describe('API Key Validation with Database', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); @@ -144,7 +142,6 @@ describe('API Key Validation with Database', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); @@ -186,7 +183,6 @@ describe('API Key Validation with Database', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); @@ -232,7 +228,6 @@ describe('API Key Validation with Database', () => { await db.insert(project).values({ id: projectId, name: projectName, - apiKey: apiKey, apiKeyHash: hashApiKey(apiKey), ownerId: userId, }); @@ -267,14 +262,12 @@ describe('API Key Validation with Database', () => { { id: project1Id, name: 'project-1', - apiKey: apiKey1, apiKeyHash: hashApiKey(apiKey1), ownerId: userId, }, { id: project2Id, name: 'project-2', - apiKey: apiKey2, apiKeyHash: hashApiKey(apiKey2), ownerId: userId, }, From 5efaff8260042906da2a7b78e0801dbc278f50bf Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 01:42:45 -0700 Subject: [PATCH 04/16] test(e2e): align dashboard + log-stream specs with hash-only key UX - dashboard 'create project and show in list': handle the new one-time API key reveal modal that appears after UI creation - log-stream 'API key on settings page': key is no longer shown on load (hashed, shown once); assert it's absent and the regenerate button is present --- tests/e2e/dashboard.spec.ts | 7 +++++-- tests/e2e/log-stream.spec.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 3c16064..837c277 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -222,8 +222,11 @@ test.describe('Dashboard - Create Project Modal', () => { .getByRole('button', { name: /^create$/i }) .click(); - // Modal should close - await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + // The one-time API key reveal modal appears with the new plaintext key + await expect(page.getByTestId('api-key-reveal-content')).toBeVisible(); + await expect(page.getByTestId('api-key-reveal-value')).toContainText('lw_'); + await page.getByTestId('api-key-reveal-close').click(); + await expect(page.getByTestId('api-key-reveal-content')).not.toBeVisible(); // Project should appear in the list (use testid to avoid matching toast notification) await expect( diff --git a/tests/e2e/log-stream.spec.ts b/tests/e2e/log-stream.spec.ts index 59c2ed3..2c9c473 100644 --- a/tests/e2e/log-stream.spec.ts +++ b/tests/e2e/log-stream.spec.ts @@ -533,15 +533,18 @@ test.describe('Log Stream Page - Settings Navigation', () => { await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); }); - test('should display API key on settings page', async ({ page }) => { + test('should not display the API key on the settings page until regenerated', async ({ + page, + }) => { await page.goto(`/projects/${testProject.id}`); // Navigate to settings page await page.getByRole('link', { name: /settings/i }).click(); await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); - // API key should be displayed - await expect(page.locator('[data-testid="api-key-display"]')).toContainText('lw_'); + // Keys are hashed and shown only once at creation/regeneration — not on load. + await expect(page.getByTestId('api-key-display')).toHaveCount(0); + await expect(page.getByTestId('regenerate-button')).toBeVisible(); }); test('should show curl example on settings page', async ({ page }) => { From ab6ca82cc8f67be0eca13f1ae51c1ae247c282eb Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 02:29:27 -0700 Subject: [PATCH 05/16] chore: address code review feedback - ci: Disable bun cache for untrusted PRs and releases to prevent cache poisoning - ci: Pin oven-sh/setup-bun to 1.2.15 in SDK workflow - fix(ui): Prevent handleKeyDown from executing if API key modal is closed - test: Fix unclosed test DB handles in log-cleanup ordering - fix(sdk): Await in-flight queue flush during graceful shutdown - test: Use shared hashApiKey utility in DB fixtures --- .github/workflows/ci.yml | 6 ++ .github/workflows/release.yml | 15 ++-- .github/workflows/sdk-typescript.yml | 10 +-- sdks/typescript/src/queue.ts | 81 ++++++++++--------- .../components/api-key-reveal-modal.svelte | 1 + tests/fixtures/db.ts | 6 +- .../log-cleanup-ordering.integration.test.ts | 11 ++- 7 files changed, 79 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16f1f11..c5d7fe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -83,6 +84,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -126,6 +128,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -178,6 +181,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -259,6 +263,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -317,6 +322,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Install deps run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6e0db4..ffa2a78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -55,7 +56,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ steps.bun-cache-dir.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-bun-${{ github.sha }}-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- @@ -86,6 +87,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -95,7 +97,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ steps.bun-cache-dir.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-bun-${{ github.sha }}-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- @@ -123,6 +125,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -132,7 +135,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ steps.bun-cache-dir.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-bun-${{ github.sha }}-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- @@ -180,6 +183,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -189,7 +193,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ steps.bun-cache-dir.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-bun-${{ github.sha }}-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- @@ -258,6 +262,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.2.15" + no-cache: true - name: Get bun cache directory id: bun-cache-dir @@ -267,7 +272,7 @@ jobs: uses: actions/cache@v5 with: path: ${{ steps.bun-cache-dir.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-bun-${{ github.sha }}-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml index 3b0c122..4348560 100644 --- a/.github/workflows/sdk-typescript.yml +++ b/.github/workflows/sdk-typescript.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Install dependencies run: bun install --frozen-lockfile @@ -72,7 +72,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Install dependencies run: bun install --frozen-lockfile @@ -97,7 +97,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Install dependencies run: bun install --frozen-lockfile @@ -122,7 +122,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Install dependencies run: bun install --frozen-lockfile @@ -162,7 +162,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.2.15" - name: Setup Node.js (for npm publish) uses: actions/setup-node@v6 diff --git a/sdks/typescript/src/queue.ts b/sdks/typescript/src/queue.ts index 9d1c934..002e9f7 100644 --- a/sdks/typescript/src/queue.ts +++ b/sdks/typescript/src/queue.ts @@ -32,6 +32,7 @@ export class BatchQueue { private flushTimer: ReturnType | null = null; private flushing = false; private stopped = false; + private _flushPromise: Promise | null = null; constructor( private sendBatch: SendBatchFn, @@ -95,45 +96,49 @@ export class BatchQueue { this.flushing = true; this.stopTimer(); - // Snapshot current queue length so concurrent adds during flush are deferred - const snapshotLength = this.queue.length; - let sent = 0; - let lastResponse: IngestResponse | null = null; - try { - // Send in chunks bounded by batchSize, up to the snapshot count - while (sent < snapshotLength) { - const remaining = snapshotLength - sent; - const chunkSize = Math.min(this.config.batchSize, remaining); - const batch = this.queue.splice(0, chunkSize); - sent += batch.length; - try { - const response = await this.sendBatch(batch); - this.config.onFlush?.(batch.length); - lastResponse = response; - } catch (error) { - // Re-queue failed batch at front, respect maxQueueSize - const requeued = [...batch, ...this.queue]; - this.queue.length = 0; - this.queue.push(...requeued.slice(0, this.config.maxQueueSize)); - if (requeued.length > this.config.maxQueueSize) { - this.config.onError?.( - new LogwellError( - `Queue overflow: dropped ${requeued.length - this.config.maxQueueSize} logs`, - 'QUEUE_OVERFLOW', - ), - ); + this._flushPromise = (async () => { + // Snapshot current queue length so concurrent adds during flush are deferred + const snapshotLength = this.queue.length; + let sent = 0; + let lastResponse: IngestResponse | null = null; + try { + // Send in chunks bounded by batchSize, up to the snapshot count + while (sent < snapshotLength) { + const remaining = snapshotLength - sent; + const chunkSize = Math.min(this.config.batchSize, remaining); + const batch = this.queue.splice(0, chunkSize); + sent += batch.length; + try { + const response = await this.sendBatch(batch); + this.config.onFlush?.(batch.length); + lastResponse = response; + } catch (error) { + // Re-queue failed batch at front, respect maxQueueSize + const requeued = [...batch, ...this.queue]; + this.queue.length = 0; + this.queue.push(...requeued.slice(0, this.config.maxQueueSize)); + if (requeued.length > this.config.maxQueueSize) { + this.config.onError?.( + new LogwellError( + `Queue overflow: dropped ${requeued.length - this.config.maxQueueSize} logs`, + 'QUEUE_OVERFLOW', + ), + ); + } + this.config.onError?.(error as Error); + break; // stop flushing on error } - this.config.onError?.(error as Error); - break; // stop flushing on error + } + } finally { + this.flushing = false; + if (this.queue.length > 0 && !this.stopped) { + this.startTimer(); } } - } finally { - this.flushing = false; - if (this.queue.length > 0 && !this.stopped) { - this.startTimer(); - } - } - return lastResponse; + return lastResponse; + })(); + + return this._flushPromise; } /** @@ -152,6 +157,10 @@ export class BatchQueue { this.stopped = true; this.stopTimer(); + if (this._flushPromise) { + await this._flushPromise.catch(() => {}); + } + if (this.queue.length === 0) { return null; } diff --git a/src/lib/components/api-key-reveal-modal.svelte b/src/lib/components/api-key-reveal-modal.svelte index 2fc96af..00fbed5 100644 --- a/src/lib/components/api-key-reveal-modal.svelte +++ b/src/lib/components/api-key-reveal-modal.svelte @@ -20,6 +20,7 @@ function handleClose() { } function handleKeyDown(event: KeyboardEvent) { + if (!open) return; if (event.key === 'Escape') { handleClose(); } diff --git a/tests/fixtures/db.ts b/tests/fixtures/db.ts index c07745f..9139870 100644 --- a/tests/fixtures/db.ts +++ b/tests/fixtures/db.ts @@ -1,7 +1,7 @@ -import { createHash } from 'node:crypto'; import type { PgliteDatabase } from 'drizzle-orm/pglite'; import { nanoid } from 'nanoid'; import * as schema from '../../src/lib/server/db/schema'; +import { hashApiKey } from '../../src/lib/server/utils/api-key'; /** * Type for project creation/selection @@ -75,7 +75,7 @@ export function createProjectFactory( // unless the caller provides an explicit apiKeyHash. apiKeyHash is applied // after the spread so overrides can't leave it inconsistent. const apiKeyHash = - overrides.apiKeyHash ?? createHash('sha256').update(generateApiKey()).digest('hex'); + overrides.apiKeyHash ?? hashApiKey(generateApiKey()); return { id: nanoid(), name: `test-project-${nanoid(8)}`, @@ -149,7 +149,7 @@ export async function seedProjectWithApiKey( overrides: Omit, 'apiKeyHash'> = {}, ): Promise { const apiKey = generateApiKey(); - const apiKeyHash = createHash('sha256').update(apiKey).digest('hex'); + const apiKeyHash = hashApiKey(apiKey); const result = await seedProject(db, { ...overrides, apiKeyHash }); return { ...result, apiKey }; } diff --git a/tests/integration/jobs/log-cleanup-ordering.integration.test.ts b/tests/integration/jobs/log-cleanup-ordering.integration.test.ts index 54f7bf4..ad67393 100644 --- a/tests/integration/jobs/log-cleanup-ordering.integration.test.ts +++ b/tests/integration/jobs/log-cleanup-ordering.integration.test.ts @@ -1,6 +1,6 @@ import { asc, eq } from 'drizzle-orm'; import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type * as schema from '../../../src/lib/server/db/schema'; import { log } from '../../../src/lib/server/db/schema'; import { setupTestDatabase } from '../../../src/lib/server/db/test-db'; @@ -11,12 +11,19 @@ const DAY_MS = 24 * 60 * 60 * 1000; describe('cleanupOldLogs batch selection', () => { let db: PgliteDatabase; + let setup: { db: PgliteDatabase; cleanup: () => Promise }; beforeEach(async () => { - const setup = await setupTestDatabase(); + setup = await setupTestDatabase(); db = setup.db; }); + afterEach(async () => { + if (setup?.cleanup) { + await setup.cleanup(); + } + }); + it('deletes only logs older than retention, keeping the most recent', async () => { const project1 = await seedProject(db, { retentionDays: 7 }); From 1c5273381172781922f5e067b794c04a04421192 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 02:35:07 -0700 Subject: [PATCH 06/16] migrate to vp --- .github/workflows/ci.yml | 13 +- .github/workflows/opencode.yml | 2 +- .github/workflows/release.yml | 12 +- .vite-hooks/pre-commit | 1 + .zed/settings.json | 246 ++++++++++++ AGENTS.md | 80 ++-- README.md | 211 +++++----- bun.lock | 238 +++++++----- compose.yaml | 2 +- drizzle.config.ts | 8 +- fly.toml | 26 +- package.json | 57 +-- playwright.config.ts | 24 +- scripts/__tests__/backfill-incidents.test.ts | 48 +-- scripts/__tests__/seed-admin.test.ts | 72 ++-- scripts/backfill-incidents.ts | 22 +- scripts/seed-admin.ts | 32 +- sdks/go/README.md | 43 ++- sdks/python/README.md | 66 ++-- sdks/python/pyproject.toml | 38 +- sdks/typescript/README.md | 85 ++-- sdks/typescript/package.json | 30 +- sdks/typescript/src/client.ts | 20 +- sdks/typescript/src/config.ts | 40 +- sdks/typescript/src/errors.ts | 16 +- sdks/typescript/src/index.ts | 11 +- sdks/typescript/src/queue.ts | 10 +- sdks/typescript/src/source-location.ts | 6 +- sdks/typescript/src/transport.ts | 38 +- sdks/typescript/src/types.ts | 2 +- sdks/typescript/tests/fixtures/configs.ts | 58 +-- sdks/typescript/tests/fixtures/logs.ts | 60 +-- .../integration/client.integration.test.ts | 182 ++++----- .../integration/transport.integration.test.ts | 128 +++---- sdks/typescript/tests/mocks/handlers.ts | 26 +- sdks/typescript/tests/mocks/server.ts | 4 +- sdks/typescript/tests/setup.ts | 6 +- .../typescript/tests/unit/client.unit.test.ts | 117 +++--- .../typescript/tests/unit/config.unit.test.ts | 132 +++---- .../typescript/tests/unit/errors.unit.test.ts | 113 +++--- sdks/typescript/tests/unit/queue.unit.test.ts | 96 ++--- .../tests/unit/source-location.unit.test.ts | 134 +++---- sdks/typescript/tsup.config.ts | 12 +- sdks/typescript/vitest.config.ts | 16 +- src/app.d.ts | 6 +- src/app.html | 59 +-- src/hooks.server.test.ts | 92 ++--- src/hooks.server.ts | 26 +- src/lib/auth-client.ts | 4 +- .../__tests__/accessibility.component.test.ts | 222 +++++------ .../active-filter-chips.component.test.ts | 66 ++-- .../clear-filters-button.component.test.ts | 58 +-- .../connection-status.component.test.ts | 82 ++-- .../dashboard-skeleton.component.test.ts | 84 ++-- .../incident-status-badge.component.test.ts | 20 +- .../incident-table.component.test.ts | 36 +- .../incident-timeline-panel.component.test.ts | 48 +-- .../keyboard-help-modal.component.test.ts | 108 +++--- .../__tests__/level-badge.component.test.ts | 20 +- .../__tests__/level-chart.component.test.ts | 208 +++++----- .../__tests__/live-toggle.component.test.ts | 88 ++--- .../__tests__/log-card.component.test.ts | 118 +++--- .../log-detail-modal.component.test.ts | 182 ++++----- .../__tests__/log-row.component.test.ts | 246 ++++++------ .../log-stream-skeleton.component.test.ts | 40 +- .../__tests__/log-table.component.test.ts | 362 +++++++++--------- .../__tests__/project-card.component.test.ts | 50 +-- .../__tests__/search-input.component.test.ts | 138 +++---- .../stats-skeleton.component.test.ts | 106 ++--- .../__tests__/theme-toggle.component.test.ts | 32 +- .../time-range-picker.component.test.ts | 109 +++--- .../timeseries-chart.component.test.ts | 188 ++++----- src/lib/components/ui/button/index.ts | 2 +- src/lib/components/ui/card/index.ts | 12 +- src/lib/components/ui/dropdown-menu/index.ts | 8 +- src/lib/components/ui/input/index.ts | 2 +- src/lib/components/ui/select/index.ts | 22 +- src/lib/components/ui/separator/index.ts | 2 +- src/lib/components/ui/skeleton/index.ts | 2 +- src/lib/components/ui/sonner/index.ts | 2 +- src/lib/components/ui/switch/index.ts | 2 +- .../use-log-stream.component.test.ts | 218 +++++------ src/lib/hooks/use-incident-stream.svelte.ts | 36 +- src/lib/hooks/use-log-stream.svelte.ts | 36 +- src/lib/server/auth.ts | 20 +- src/lib/server/config/env.ts | 64 ++-- src/lib/server/config/env.unit.test.ts | 224 +++++------ src/lib/server/config/index.ts | 4 +- src/lib/server/config/performance.ts | 16 +- .../server/config/performance.unit.test.ts | 110 +++--- src/lib/server/config/retention.unit.test.ts | 70 ++-- src/lib/server/db/README.md | 30 +- src/lib/server/db/db.ts | 10 +- src/lib/server/db/index.ts | 12 +- src/lib/server/db/schema.ts | 240 ++++++------ src/lib/server/db/test-db.ts | 118 +++--- src/lib/server/db/test-utils.ts | 6 +- src/lib/server/error-handler.ts | 4 +- src/lib/server/events.ts | 6 +- src/lib/server/events.unit.test.ts | 168 ++++---- src/lib/server/jobs/cleanup-scheduler.ts | 8 +- src/lib/server/jobs/log-cleanup.ts | 12 +- src/lib/server/session.ts | 18 +- src/lib/server/utils/api-error.ts | 2 +- src/lib/server/utils/api-key.ts | 30 +- src/lib/server/utils/api-key.unit.test.ts | 66 ++-- src/lib/server/utils/auth-guard.ts | 12 +- src/lib/server/utils/content-type.ts | 10 +- src/lib/server/utils/csrf.ts | 16 +- src/lib/server/utils/csv-serializer.ts | 32 +- .../server/utils/csv-serializer.unit.test.ts | 198 +++++----- src/lib/server/utils/cursor.ts | 18 +- src/lib/server/utils/cursor.unit.test.ts | 110 +++--- src/lib/server/utils/incident-backfill.ts | 12 +- src/lib/server/utils/incident-fingerprint.ts | 20 +- .../utils/incident-fingerprint.unit.test.ts | 34 +- .../server/utils/incident-status.unit.test.ts | 30 +- src/lib/server/utils/incidents.ts | 26 +- src/lib/server/utils/incidents.unit.test.ts | 46 +-- src/lib/server/utils/otlp.ts | 96 ++--- src/lib/server/utils/otlp.unit.test.ts | 178 ++++----- src/lib/server/utils/project-guard.ts | 12 +- src/lib/server/utils/search.ts | 14 +- src/lib/server/utils/search.unit.test.ts | 112 +++--- src/lib/server/utils/simple-ingest.ts | 40 +- .../server/utils/simple-ingest.unit.test.ts | 248 ++++++------ src/lib/shared/schemas/incident.ts | 10 +- src/lib/shared/schemas/log-level.unit.test.ts | 12 +- src/lib/shared/schemas/log.ts | 4 +- src/lib/shared/schemas/project.ts | 14 +- src/lib/shared/schemas/project.unit.test.ts | 80 ++-- src/lib/shared/types.ts | 4 +- src/lib/stores/__tests__/logs.unit.test.ts | 20 +- src/lib/stores/logs.svelte.ts | 2 +- src/lib/types/export.ts | 2 +- src/lib/utils.ts | 8 +- src/lib/utils.unit.test.ts | 12 +- src/lib/utils/colors.ts | 42 +- src/lib/utils/colors.unit.test.ts | 36 +- src/lib/utils/focus-trap.ts | 44 +-- src/lib/utils/format.ts | 40 +- src/lib/utils/format.unit.test.ts | 148 +++---- src/lib/utils/keyboard.ts | 18 +- src/lib/utils/keyboard.unit.test.ts | 114 +++--- src/lib/utils/timeseries.ts | 10 +- src/lib/utils/timeseries.unit.test.ts | 86 ++--- src/lib/utils/toast.ts | 6 +- src/lib/utils/toast.unit.test.ts | 44 +-- src/routes/(app)/+layout.server.ts | 4 +- src/routes/(app)/+page.server.ts | 10 +- .../(app)/projects/[id]/+page.server.ts | 48 +-- .../projects/[id]/incidents/+page.server.ts | 46 +-- .../projects/[id]/settings/+page.server.ts | 16 +- .../(app)/projects/[id]/stats/+page.server.ts | 24 +- src/routes/api/health/+server.ts | 28 +- src/routes/api/projects/+server.ts | 36 +- src/routes/api/projects/[id]/+server.ts | 30 +- .../api/projects/[id]/incidents/+server.ts | 46 +-- .../[id]/incidents/[incidentId]/+server.ts | 18 +- .../[incidentId]/timeline/+server.ts | 26 +- .../projects/[id]/incidents/stream/+server.ts | 32 +- src/routes/api/projects/[id]/logs/+server.ts | 40 +- .../api/projects/[id]/logs/export/+server.ts | 90 ++--- .../api/projects/[id]/logs/stream/+server.ts | 38 +- .../api/projects/[id]/regenerate/+server.ts | 16 +- src/routes/api/projects/[id]/stats/+server.ts | 16 +- .../projects/[id]/stats/timeseries/+server.ts | 26 +- src/routes/login/+page.server.ts | 6 +- .../login-navigation.component.test.ts | 70 ++-- src/routes/v1/ingest/+server.ts | 40 +- src/routes/v1/logs/+server.ts | 38 +- svelte.config.js | 4 +- tests/README.md | 17 +- tests/e2e/auth-guard.spec.ts | 72 ++-- tests/e2e/dashboard.spec.ts | 154 ++++---- tests/e2e/export-logs.spec.ts | 246 ++++++------ tests/e2e/helpers/log-selectors.ts | 10 +- tests/e2e/helpers/otlp.ts | 18 +- tests/e2e/incidents.spec.ts | 48 +-- tests/e2e/live-stream.spec.ts | 48 +-- tests/e2e/log-stream.spec.ts | 300 +++++++-------- tests/e2e/login.spec.ts | 80 ++-- tests/e2e/otlp-ingestion.spec.ts | 30 +- tests/e2e/pagination.spec.ts | 44 +-- tests/e2e/project-settings.spec.ts | 342 ++++++++--------- tests/e2e/responsive.spec.ts | 192 +++++----- tests/e2e/stats.spec.ts | 156 ++++---- tests/e2e/theme-toggle.spec.ts | 44 +-- tests/fixtures/README.md | 38 +- tests/fixtures/db.ts | 23 +- .../api/health/health.integration.test.ts | 78 ++-- .../logs/stream/server.integration.test.ts | 211 +++++----- .../[id]/stats/timeseries.integration.test.ts | 148 +++---- .../authorization.integration.test.ts | 152 ++++---- .../api/projects/csrf.integration.test.ts | 158 ++++---- .../incident-detail.integration.test.ts | 100 ++--- .../incidents/incidents.integration.test.ts | 276 ++++++------- .../projects/logs/export.integration.test.ts | 192 +++++----- .../logs/logs-query.integration.test.ts | 294 +++++++------- .../project-detail.integration.test.ts | 228 +++++------ .../project-rename.integration.test.ts | 236 ++++++------ .../projects/regenerate.integration.test.ts | 88 ++--- .../projects/retention.integration.test.ts | 166 ++++---- .../api/projects/server.integration.test.ts | 300 +++++++-------- .../projects/stats/stats.integration.test.ts | 178 ++++----- .../integration/auth/auth.integration.test.ts | 116 +++--- tests/integration/db/log.integration.test.ts | 86 ++--- .../db/project-retention.integration.test.ts | 44 +-- .../db/project.integration.test.ts | 36 +- .../hooks/error-handler.integration.test.ts | 76 ++-- .../log-cleanup-ordering.integration.test.ts | 36 +- .../jobs/log-cleanup.integration.test.ts | 48 +-- tests/integration/logs-pagination.test.ts | 104 ++--- .../integration/otlp/logs.integration.test.ts | 228 +++++------ .../utils/auth-guard.integration.test.ts | 114 +++--- .../simple-ingest/logs.integration.test.ts | 356 ++++++++--------- .../utils/api-key.integration.test.ts | 100 ++--- tests/integration/utils/csrf.unit.test.ts | 94 ++--- .../utils/incidents.integration.test.ts | 58 +-- .../incidents.upsert.integration.test.ts | 78 ++-- .../utils/search.integration.test.ts | 154 ++++---- tests/setup.ts | 12 +- vite.config.ts | 17 +- vitest.config.ts | 92 ++--- 224 files changed, 8291 insertions(+), 7956 deletions(-) create mode 100755 .vite-hooks/pre-commit create mode 100644 .zed/settings.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5d7fe2..9d7a45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -517,7 +517,18 @@ jobs: name: CI Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [lint, test-unit, test-integration, test-e2e, test-migrations, build, docker-build, docker-publish, docker-merge] + needs: + [ + lint, + test-unit, + test-integration, + test-e2e, + test-migrations, + build, + docker-build, + docker-publish, + docker-merge, + ] if: always() steps: diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 421f8cc..2e55bcc 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -33,4 +33,4 @@ jobs: env: FIREPASS_API_KEY: ${{ secrets.FIREPASS_API_KEY }} with: - model: firepass/accounts/fireworks/routers/kimi-k2p6-turbo \ No newline at end of file + model: firepass/accounts/fireworks/routers/kimi-k2p6-turbo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffa2a78..4994a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -478,7 +478,17 @@ jobs: name: Release Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [lint, test-unit, test-integration, test-e2e, build, docker-publish, docker-merge, github-release] + needs: + [ + lint, + test-unit, + test-integration, + test-e2e, + build, + docker-publish, + docker-merge, + github-release, + ] if: always() steps: diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100755 index 0000000..85fb65b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..daadef5 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,246 @@ +{ + "lsp": { + "oxlint": { + "initialization_options": { + "settings": { + "run": "onType", + "fixKind": "safe_fix", + "typeAware": true, + "unusedDisableDirectives": "deny" + } + } + }, + "oxfmt": { + "initialization_options": { + "settings": { + "configPath": "./vite.config.ts", + "run": "onSave" + } + } + } + }, + "languages": { + "CSS": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "GraphQL": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "Handlebars": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "HTML": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "JavaScript": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ], + "code_action": "source.fixAll.oxc" + }, + "JSX": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "JSON": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "JSON5": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "JSONC": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "Less": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "Markdown": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "MDX": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "SCSS": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "TypeScript": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "TSX": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "Vue.js": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + }, + "YAML": { + "format_on_save": "on", + "prettier": { + "allowed": false + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt" + } + } + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 85c7bd3..16d8581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,35 +4,35 @@ Self-hosted logging platform (SvelteKit + PostgreSQL + Bun). ## Critical Commands -| Task | Command | -|------|---------| -| Dev server | `bun run dev` (port 5173) | -| Production preview | `bun run preview` (port 3000) | -| Database start | `bun run db:start` | -| Migrations | `bun run db:migrate` | -| Schema push (dev) | `bun run db:push` | -| Seed admin | `bun run db:seed` | -| Lint + typecheck | `bun run lint && bun run check` | -| Run all tests | `bun run test` | -| Unit tests | `bun run test:unit` | -| Integration tests | `bun run test:integration` | -| E2E tests | `bun run test:e2e` | -| SDK tests | `bun run sdk:test` | -| Dead code check | `bun run knip` | +| Task | Command | +| ------------------ | ------------------------------- | +| Dev server | `bun run dev` (port 5173) | +| Production preview | `bun run preview` (port 3000) | +| Database start | `bun run db:start` | +| Migrations | `bun run db:migrate` | +| Schema push (dev) | `bun run db:push` | +| Seed admin | `bun run db:seed` | +| Lint + typecheck | `bun run lint && bun run check` | +| Run all tests | `bun run test` | +| Unit tests | `bun run test:unit` | +| Integration tests | `bun run test:integration` | +| E2E tests | `bun run test:e2e` | +| SDK tests | `bun run sdk:test` | +| Dead code check | `bun run knip` | **Pre-commit checklist:** `bun run lint && bun run check && bun run knip` ## Architecture -| Layer | Tech | -|-------|------| -| Framework | SvelteKit (Bun runtime) | -| Database | PostgreSQL 18 | -| ORM | Drizzle | -| Auth | better-auth | -| UI | shadcn-svelte + Tailwind CSS v4 | -| Real-time | Server-Sent Events | -| Adapter | `svelte-adapter-bun` | +| Layer | Tech | +| --------- | ------------------------------- | +| Framework | SvelteKit (Bun runtime) | +| Database | PostgreSQL 18 | +| ORM | Drizzle | +| Auth | better-auth | +| UI | shadcn-svelte + Tailwind CSS v4 | +| Real-time | Server-Sent Events | +| Adapter | `svelte-adapter-bun` | ### Directory Structure @@ -70,6 +70,7 @@ sdks/ ### Environment Variables Required in `.env`: + ```env DATABASE_URL="postgresql://root:mysecretpassword@localhost:5432/local" BETTER_AUTH_SECRET="32-char-min-secret" @@ -80,12 +81,12 @@ Generate secret: `openssl rand -base64 32` ## Testing Strategy -| Type | Location | Runner | DB | -|------|----------|--------|-----| -| Unit | Colocated (`*.unit.test.ts`) | Vitest | None (mocked) | -| Component | `src/lib/components/__tests__/` | Vitest (jsdom) | None | -| Integration | `tests/integration/` | Vitest | PGlite (in-memory) | -| E2E | `tests/e2e/` | Playwright | Real PostgreSQL | +| Type | Location | Runner | DB | +| ----------- | ------------------------------- | -------------- | ------------------ | +| Unit | Colocated (`*.unit.test.ts`) | Vitest | None (mocked) | +| Component | `src/lib/components/__tests__/` | Vitest (jsdom) | None | +| Integration | `tests/integration/` | Vitest | PGlite (in-memory) | +| E2E | `tests/e2e/` | Playwright | Real PostgreSQL | **Integration tests** use `@electric-sql/pglite` (zero Docker overhead). **E2E tests** require `docker compose up -d` for PostgreSQL. @@ -172,12 +173,14 @@ golangci-lint run - Import organization enabled **Key overrides for `.svelte` files:** + - `noUnusedImports` / `noUnusedVariables` = off - `useConst` / `useImportType` = off ## CI/CD GitHub Actions workflows: + - `ci.yml` — Lint, test (unit/integration/e2e), build, Docker - `release.yml` — Multi-platform Docker images on tags - `sdk-*.yml` — SDK releases @@ -202,3 +205,20 @@ git push origin v1.0.7 ``` Release workflow builds multi-platform Docker images (`linux/amd64`, `linux/arm64`). + + + +# Using Vite+, the Unified Toolchain for the Web + +This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. + +Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/. + +## Review Checklist + +- [ ] Run `vp install` after pulling remote changes and before getting started. +- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. +- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run ``` @@ -249,11 +249,11 @@ export default { The SDK throws `LogwellError` on failures: ```typescript -import { Logwell, LogwellError } from 'logwell'; +import { Logwell, LogwellError } from "logwell"; const logger = new Logwell({ - apiKey: 'lw_xxx', - endpoint: 'https://logs.example.com', + apiKey: "lw_xxx", + endpoint: "https://logs.example.com", onError: (error) => { if (error instanceof LogwellError) { console.error(`Logwell error [${error.code}]: ${error.message}`); @@ -266,6 +266,7 @@ const logger = new Logwell({ ``` Error codes: + - `NETWORK_ERROR` - Network failure (retryable) - `UNAUTHORIZED` - Invalid API key - `VALIDATION_ERROR` - Invalid log format @@ -287,7 +288,7 @@ import type { IngestResponse, LogwellError, LogwellErrorCode, -} from 'logwell'; +} from "logwell"; ``` ## License diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 6de7c52..66b3de8 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -5,10 +5,14 @@ "keywords": [ "logging", "logs", - "observability", "logwell", + "observability", "typescript" ], + "homepage": "https://github.com/Divkix/Logwell/tree/main/sdks/typescript#readme", + "bugs": { + "url": "https://github.com/Divkix/Logwell/issues" + }, "license": "MIT", "author": "Logwell", "repository": { @@ -16,11 +20,13 @@ "url": "https://github.com/Divkix/Logwell", "directory": "sdks/typescript" }, - "homepage": "https://github.com/Divkix/Logwell/tree/main/sdks/typescript#readme", - "bugs": { - "url": "https://github.com/Divkix/Logwell/issues" - }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -37,15 +43,6 @@ }, "./package.json": "./package.json" }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "sideEffects": false, - "engines": { - "node": ">=18.0.0" - }, "scripts": { "build": "tsup", "dev": "tsup --watch", @@ -77,5 +74,8 @@ "path": "./dist/index.js", "limit": "10 KB" } - ] + ], + "engines": { + "node": ">=18.0.0" + } } diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 82fbcdc..119f47f 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,8 +1,8 @@ -import { type ResolvedConfig, validateConfig } from './config'; -import { BatchQueue, type QueueConfig } from './queue'; -import { captureSourceLocation } from './source-location'; -import { HttpTransport } from './transport'; -import type { IngestResponse, LogEntry, LogwellConfig } from './types'; +import { type ResolvedConfig, validateConfig } from "./config"; +import { BatchQueue, type QueueConfig } from "./queue"; +import { captureSourceLocation } from "./source-location"; +import { HttpTransport } from "./transport"; +import type { IngestResponse, LogEntry, LogwellConfig } from "./types"; /** * Child logger options @@ -120,35 +120,35 @@ export class Logwell { * Log a debug message */ debug(message: string, metadata?: Record): void { - this._addLog({ level: 'debug', message, metadata }, 2); + this._addLog({ level: "debug", message, metadata }, 2); } /** * Log an info message */ info(message: string, metadata?: Record): void { - this._addLog({ level: 'info', message, metadata }, 2); + this._addLog({ level: "info", message, metadata }, 2); } /** * Log a warning message */ warn(message: string, metadata?: Record): void { - this._addLog({ level: 'warn', message, metadata }, 2); + this._addLog({ level: "warn", message, metadata }, 2); } /** * Log an error message */ error(message: string, metadata?: Record): void { - this._addLog({ level: 'error', message, metadata }, 2); + this._addLog({ level: "error", message, metadata }, 2); } /** * Log a fatal error message */ fatal(message: string, metadata?: Record): void { - this._addLog({ level: 'fatal', message, metadata }, 2); + this._addLog({ level: "fatal", message, metadata }, 2); } /** diff --git a/sdks/typescript/src/config.ts b/sdks/typescript/src/config.ts index 84f17d1..834a3b4 100644 --- a/sdks/typescript/src/config.ts +++ b/sdks/typescript/src/config.ts @@ -1,5 +1,5 @@ -import { LogwellError } from './errors'; -import type { LogwellConfig } from './types'; +import { LogwellError } from "./errors"; +import type { LogwellConfig } from "./types"; /** * Default configuration values @@ -42,7 +42,7 @@ export const API_KEY_REGEX = /^lw_[A-Za-z0-9_-]{32}$/; * @returns true if valid format, false otherwise */ export function validateApiKeyFormat(apiKey: string): boolean { - if (!apiKey || typeof apiKey !== 'string') { + if (!apiKey || typeof apiKey !== "string") { return false; } return API_KEY_REGEX.test(apiKey); @@ -59,10 +59,10 @@ function validateEndpointUrl(url: string): void { try { parsed = new URL(url); } catch { - throw new LogwellError('Invalid endpoint URL', 'INVALID_CONFIG'); + throw new LogwellError("Invalid endpoint URL", "INVALID_CONFIG"); } - if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new LogwellError('endpoint must use http or https', 'INVALID_CONFIG'); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new LogwellError("endpoint must use http or https", "INVALID_CONFIG"); } } @@ -76,18 +76,18 @@ function validateEndpointUrl(url: string): void { export function validateConfig(config: Partial): ResolvedConfig { // Validate required fields if (!config.apiKey) { - throw new LogwellError('apiKey is required', 'INVALID_CONFIG'); + throw new LogwellError("apiKey is required", "INVALID_CONFIG"); } if (!config.endpoint) { - throw new LogwellError('endpoint is required', 'INVALID_CONFIG'); + throw new LogwellError("endpoint is required", "INVALID_CONFIG"); } // Validate API key format if (!validateApiKeyFormat(config.apiKey)) { throw new LogwellError( - 'Invalid API key format. Expected: lw_[32 characters]', - 'INVALID_CONFIG', + "Invalid API key format. Expected: lw_[32 characters]", + "INVALID_CONFIG", ); } @@ -96,44 +96,44 @@ export function validateConfig(config: Partial): ResolvedConfig { // Validate numeric options — lower bounds if (config.batchSize !== undefined && config.batchSize <= 0) { - throw new LogwellError('batchSize must be positive', 'INVALID_CONFIG'); + throw new LogwellError("batchSize must be positive", "INVALID_CONFIG"); } if (config.flushInterval !== undefined && config.flushInterval <= 0) { - throw new LogwellError('flushInterval must be positive', 'INVALID_CONFIG'); + throw new LogwellError("flushInterval must be positive", "INVALID_CONFIG"); } if (config.maxQueueSize !== undefined && config.maxQueueSize <= 0) { - throw new LogwellError('maxQueueSize must be positive', 'INVALID_CONFIG'); + throw new LogwellError("maxQueueSize must be positive", "INVALID_CONFIG"); } if (config.maxRetries !== undefined && config.maxRetries < 0) { - throw new LogwellError('maxRetries must be non-negative', 'INVALID_CONFIG'); + throw new LogwellError("maxRetries must be non-negative", "INVALID_CONFIG"); } if (config.timeout !== undefined && (!Number.isFinite(config.timeout) || config.timeout <= 0)) { - throw new LogwellError('timeout must be a positive finite number', 'INVALID_CONFIG'); + throw new LogwellError("timeout must be a positive finite number", "INVALID_CONFIG"); } // Validate numeric options — upper bounds (TS-7) if (config.batchSize !== undefined && config.batchSize > 100) { - throw new LogwellError('batchSize cannot exceed 100 (server limit)', 'INVALID_CONFIG'); + throw new LogwellError("batchSize cannot exceed 100 (server limit)", "INVALID_CONFIG"); } if (config.maxQueueSize !== undefined && config.maxQueueSize > 100000) { - throw new LogwellError('maxQueueSize cannot exceed 100000', 'INVALID_CONFIG'); + throw new LogwellError("maxQueueSize cannot exceed 100000", "INVALID_CONFIG"); } if (config.flushInterval !== undefined && config.flushInterval < 100) { - throw new LogwellError('flushInterval must be at least 100ms', 'INVALID_CONFIG'); + throw new LogwellError("flushInterval must be at least 100ms", "INVALID_CONFIG"); } if (config.flushInterval !== undefined && config.flushInterval > 60000) { - throw new LogwellError('flushInterval cannot exceed 60000ms', 'INVALID_CONFIG'); + throw new LogwellError("flushInterval cannot exceed 60000ms", "INVALID_CONFIG"); } // Normalize endpoint: strip trailing slash - const endpoint = config.endpoint.replace(/\/$/, ''); + const endpoint = config.endpoint.replace(/\/$/, ""); // Return merged config with all defaults — typed as ResolvedConfig (no cast needed) return { diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 4a77d75..bb37115 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -2,13 +2,13 @@ * Error codes for Logwell SDK errors */ export type LogwellErrorCode = - | 'NETWORK_ERROR' - | 'UNAUTHORIZED' - | 'VALIDATION_ERROR' - | 'RATE_LIMITED' - | 'SERVER_ERROR' - | 'QUEUE_OVERFLOW' - | 'INVALID_CONFIG'; + | "NETWORK_ERROR" + | "UNAUTHORIZED" + | "VALIDATION_ERROR" + | "RATE_LIMITED" + | "SERVER_ERROR" + | "QUEUE_OVERFLOW" + | "INVALID_CONFIG"; /** * Custom error class for Logwell SDK errors @@ -35,7 +35,7 @@ export class LogwellError extends Error { public readonly retryAfterMs?: number, ) { super(message); - this.name = 'LogwellError'; + this.name = "LogwellError"; // Maintains proper stack trace for where our error was thrown (V8 only) // Use type assertion to avoid global augmentation (required for JSR compatibility) diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 53b9429..2f1c374 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -5,13 +5,8 @@ */ // Main client -export { type ChildLoggerOptions, Logwell } from './client'; +export { type ChildLoggerOptions, Logwell } from "./client"; // Errors -export { LogwellError, type LogwellErrorCode } from './errors'; +export { LogwellError, type LogwellErrorCode } from "./errors"; // Types -export type { - IngestResponse, - LogEntry, - LogLevel, - LogwellConfig, -} from './types'; +export type { IngestResponse, LogEntry, LogLevel, LogwellConfig } from "./types"; diff --git a/sdks/typescript/src/queue.ts b/sdks/typescript/src/queue.ts index 002e9f7..a6bdf4b 100644 --- a/sdks/typescript/src/queue.ts +++ b/sdks/typescript/src/queue.ts @@ -1,5 +1,5 @@ -import { LogwellError } from './errors'; -import type { IngestResponse, LogEntry } from './types'; +import { LogwellError } from "./errors"; +import type { IngestResponse, LogEntry } from "./types"; /** * Callback type for sending batched logs @@ -63,7 +63,7 @@ export class BatchQueue { this.config.onError?.( new LogwellError( `Queue overflow. Dropped log: ${dropped?.message.substring(0, 50)}...`, - 'QUEUE_OVERFLOW', + "QUEUE_OVERFLOW", ), ); } @@ -121,7 +121,7 @@ export class BatchQueue { this.config.onError?.( new LogwellError( `Queue overflow: dropped ${requeued.length - this.config.maxQueueSize} logs`, - 'QUEUE_OVERFLOW', + "QUEUE_OVERFLOW", ), ); } @@ -174,7 +174,7 @@ export class BatchQueue { if (this.queue.length > 0) { throw new LogwellError( `Shutdown flush failed: ${this.queue.length} log(s) could not be delivered`, - 'NETWORK_ERROR', + "NETWORK_ERROR", undefined, false, ); diff --git a/sdks/typescript/src/source-location.ts b/sdks/typescript/src/source-location.ts index e85f982..49ccf09 100644 --- a/sdks/typescript/src/source-location.ts +++ b/sdks/typescript/src/source-location.ts @@ -122,13 +122,13 @@ export function captureSourceLocation(skipFrames: number): SourceLocation | unde return undefined; } - const lines = stack.split('\n'); + const lines = stack.split("\n"); // Detect stack format: // - V8 (Node/Bun/Chrome): Has "Error" header line, frames start with "at" // - SpiderMonkey/JSC (Firefox/Safari): No header, frames contain "@" - const firstLine = lines[0] || ''; - const hasErrorHeader = !firstLine.includes('@') && !/^\s*at\s/.test(firstLine); + const firstLine = lines[0] || ""; + const hasErrorHeader = !firstLine.includes("@") && !/^\s*at\s/.test(firstLine); // Calculate target frame index: // Skip: header (if present) + captureSourceLocation frame + skipFrames diff --git a/sdks/typescript/src/transport.ts b/sdks/typescript/src/transport.ts index 9b729c7..f506f89 100644 --- a/sdks/typescript/src/transport.ts +++ b/sdks/typescript/src/transport.ts @@ -1,5 +1,5 @@ -import { LogwellError } from './errors'; -import type { IngestResponse, LogEntry } from './types'; +import { LogwellError } from "./errors"; +import type { IngestResponse, LogEntry } from "./types"; /** * Transport configuration @@ -66,7 +66,7 @@ export class HttpTransport { private readonly ingestUrl: string; constructor(private config: TransportConfig) { - const cleanEndpoint = config.endpoint.replace(/\/$/, ''); + const cleanEndpoint = config.endpoint.replace(/\/$/, ""); this.ingestUrl = `${cleanEndpoint}/v1/ingest`; } @@ -79,8 +79,8 @@ export class HttpTransport { */ async send(logs: LogEntry[]): Promise { let lastError: LogwellError = new LogwellError( - 'Max retries exceeded', - 'NETWORK_ERROR', + "Max retries exceeded", + "NETWORK_ERROR", undefined, true, ); @@ -94,7 +94,7 @@ export class HttpTransport { } else { lastError = new LogwellError( `Unexpected error: ${(error as Error).message}`, - 'NETWORK_ERROR', + "NETWORK_ERROR", undefined, true, ); @@ -126,10 +126,10 @@ export class HttpTransport { try { response = await fetch(this.ingestUrl, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${this.config.apiKey}`, - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(logs), signal: AbortSignal.timeout(this.config.timeout ?? 30000), @@ -139,14 +139,14 @@ export class HttpTransport { // Timeout error (AbortError or TimeoutError) if ( error instanceof Error && - (error.name === 'AbortError' || error.name === 'TimeoutError') + (error.name === "AbortError" || error.name === "TimeoutError") ) { - throw new LogwellError('Request timed out', 'NETWORK_ERROR', undefined, true); + throw new LogwellError("Request timed out", "NETWORK_ERROR", undefined, true); } // Network error (fetch failed) throw new LogwellError( `Network error: ${(error as Error).message}`, - 'NETWORK_ERROR', + "NETWORK_ERROR", undefined, true, ); @@ -165,7 +165,7 @@ export class HttpTransport { private async tryParseError(response: Response): Promise { try { const body = await response.json(); - return body.message || body.error || 'Unknown error'; + return body.message || body.error || "Unknown error"; } catch { return `HTTP ${response.status}`; } @@ -174,10 +174,10 @@ export class HttpTransport { private createErrorWithRetryAfter(response: Response, message: string): LogwellError { const { status } = response; if (status === 429) { - const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); + const retryAfterMs = parseRetryAfter(response.headers.get("Retry-After")); return new LogwellError( `Rate limited: ${message}`, - 'RATE_LIMITED', + "RATE_LIMITED", status, true, retryAfterMs, @@ -189,16 +189,16 @@ export class HttpTransport { private createError(status: number, message: string): LogwellError { switch (status) { case 401: - return new LogwellError(`Unauthorized: ${message}`, 'UNAUTHORIZED', status, false); + return new LogwellError(`Unauthorized: ${message}`, "UNAUTHORIZED", status, false); case 400: - return new LogwellError(`Validation error: ${message}`, 'VALIDATION_ERROR', status, false); + return new LogwellError(`Validation error: ${message}`, "VALIDATION_ERROR", status, false); case 429: - return new LogwellError(`Rate limited: ${message}`, 'RATE_LIMITED', status, true); + return new LogwellError(`Rate limited: ${message}`, "RATE_LIMITED", status, true); default: if (status >= 500) { - return new LogwellError(`Server error: ${message}`, 'SERVER_ERROR', status, true); + return new LogwellError(`Server error: ${message}`, "SERVER_ERROR", status, true); } - return new LogwellError(`HTTP error ${status}: ${message}`, 'SERVER_ERROR', status, false); + return new LogwellError(`HTTP error ${status}: ${message}`, "SERVER_ERROR", status, false); } } } diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index aa8c0ed..da8eabf 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -1,7 +1,7 @@ /** * Valid log levels matching Logwell server */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; /** * Single log entry for the simple API diff --git a/sdks/typescript/tests/fixtures/configs.ts b/sdks/typescript/tests/fixtures/configs.ts index 1c632da..20bbd6e 100644 --- a/sdks/typescript/tests/fixtures/configs.ts +++ b/sdks/typescript/tests/fixtures/configs.ts @@ -1,18 +1,18 @@ -import type { LogwellConfig } from '../../src/types'; +import type { LogwellConfig } from "../../src/types"; /** * Valid configuration fixtures for testing */ export const validConfigs = { minimal: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", } satisfies LogwellConfig, full: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', - service: 'test-service', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", + service: "test-service", batchSize: 25, flushInterval: 3000, maxQueueSize: 500, @@ -22,14 +22,14 @@ export const validConfigs = { } satisfies LogwellConfig, withService: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', - service: 'my-app', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", + service: "my-app", } satisfies LogwellConfig, withSourceLocation: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", captureSourceLocation: true, } satisfies LogwellConfig, }; @@ -39,53 +39,53 @@ export const validConfigs = { */ export const invalidConfigs = { missingApiKey: { - endpoint: 'https://test.logwell.io', + endpoint: "https://test.logwell.io", }, missingEndpoint: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", }, emptyApiKey: { - apiKey: '', - endpoint: 'https://test.logwell.io', + apiKey: "", + endpoint: "https://test.logwell.io", }, invalidApiKeyFormat: { - apiKey: 'invalid_key_format', - endpoint: 'https://test.logwell.io', + apiKey: "invalid_key_format", + endpoint: "https://test.logwell.io", }, apiKeyTooShort: { - apiKey: 'lw_short', - endpoint: 'https://test.logwell.io', + apiKey: "lw_short", + endpoint: "https://test.logwell.io", }, apiKeyWrongPrefix: { - apiKey: 'xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", }, invalidEndpoint: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'not-a-valid-url', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "not-a-valid-url", }, negativeBatchSize: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", batchSize: -1, }, zeroBatchSize: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", batchSize: 0, }, negativeFlushInterval: { - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - endpoint: 'https://test.logwell.io', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + endpoint: "https://test.logwell.io", flushInterval: -100, }, }; diff --git a/sdks/typescript/tests/fixtures/logs.ts b/sdks/typescript/tests/fixtures/logs.ts index ef2dac7..7dbba88 100644 --- a/sdks/typescript/tests/fixtures/logs.ts +++ b/sdks/typescript/tests/fixtures/logs.ts @@ -1,11 +1,11 @@ -import type { LogEntry, LogLevel } from '../../src/types'; +import type { LogEntry, LogLevel } from "../../src/types"; /** * Creates a log entry fixture with optional overrides */ export function createLogFixture(overrides: Partial = {}): LogEntry { return { - level: 'info', + level: "info", message: `Test log message ${Date.now()}`, ...overrides, }; @@ -28,72 +28,72 @@ export function createLogBatch(count: number, overrides: Partial = {}) */ export const logFixtures = { minimal: { - level: 'info' as LogLevel, - message: 'minimal log', + level: "info" as LogLevel, + message: "minimal log", }, full: { - level: 'error' as LogLevel, - message: 'Full log with all fields', - timestamp: '2025-01-05T12:00:00.000Z', - service: 'test-service', + level: "error" as LogLevel, + message: "Full log with all fields", + timestamp: "2025-01-05T12:00:00.000Z", + service: "test-service", metadata: { - key: 'value', + key: "value", nested: { deep: true }, count: 42, }, }, debug: { - level: 'debug' as LogLevel, - message: 'Debug message', + level: "debug" as LogLevel, + message: "Debug message", }, info: { - level: 'info' as LogLevel, - message: 'Info message', + level: "info" as LogLevel, + message: "Info message", }, warn: { - level: 'warn' as LogLevel, - message: 'Warning message', + level: "warn" as LogLevel, + message: "Warning message", }, error: { - level: 'error' as LogLevel, - message: 'Error message', - metadata: { errorCode: 'ERR_001' }, + level: "error" as LogLevel, + message: "Error message", + metadata: { errorCode: "ERR_001" }, }, fatal: { - level: 'fatal' as LogLevel, - message: 'Fatal error occurred', - metadata: { stack: 'Error stack trace' }, + level: "fatal" as LogLevel, + message: "Fatal error occurred", + metadata: { stack: "Error stack trace" }, }, withLargeMetadata: { - level: 'info' as LogLevel, - message: 'Log with large metadata', + level: "info" as LogLevel, + message: "Log with large metadata", metadata: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`key_${i}`, `value_${i}`])), }, withUnicode: { - level: 'info' as LogLevel, - message: 'Unicode: \u4e2d\u6587 \u{1F600} \u0391\u03B2\u03B3', + level: "info" as LogLevel, + message: "Unicode: \u4e2d\u6587 \u{1F600} \u0391\u03B2\u03B3", }, withLongMessage: { - level: 'warn' as LogLevel, - message: 'A'.repeat(10000), + level: "warn" as LogLevel, + message: "A".repeat(10000), }, withSpecialChars: { - level: 'info' as LogLevel, - message: 'Special: "quotes" \'apostrophe\' & ampersand', + level: "info" as LogLevel, + message: "Special: \"quotes\" 'apostrophe' & ampersand", }, }; /** * All log levels for parametrized testing */ -export const allLogLevels: LogLevel[] = ['debug', 'info', 'warn', 'error', 'fatal']; +export const allLogLevels: LogLevel[] = ["debug", "info", "warn", "error", "fatal"]; diff --git a/sdks/typescript/tests/integration/client.integration.test.ts b/sdks/typescript/tests/integration/client.integration.test.ts index c97ed7e..89b6718 100644 --- a/sdks/typescript/tests/integration/client.integration.test.ts +++ b/sdks/typescript/tests/integration/client.integration.test.ts @@ -1,14 +1,14 @@ -import { HttpResponse, http } from 'msw'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Logwell } from '../../src/client'; -import { LogwellError } from '../../src/errors'; -import type { LogEntry, LogwellConfig } from '../../src/types'; -import { validConfigs } from '../fixtures/configs'; -import { allLogLevels } from '../fixtures/logs'; -import { errorHandlers } from '../mocks/handlers'; -import { server } from '../mocks/server'; - -describe('Logwell Client', () => { +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Logwell } from "../../src/client"; +import { LogwellError } from "../../src/errors"; +import type { LogEntry, LogwellConfig } from "../../src/types"; +import { validConfigs } from "../fixtures/configs"; +import { allLogLevels } from "../fixtures/logs"; +import { errorHandlers } from "../mocks/handlers"; +import { server } from "../mocks/server"; + +describe("Logwell Client", () => { let defaultConfig: LogwellConfig; let capturedLogs: LogEntry[]; @@ -23,7 +23,7 @@ describe('Logwell Client', () => { // Capture all logs sent to the server server.use( - http.post('*/v1/ingest', async ({ request }) => { + http.post("*/v1/ingest", async ({ request }) => { const body = (await request.json()) as LogEntry[]; capturedLogs.push(...body); return HttpResponse.json({ accepted: body.length }); @@ -35,24 +35,24 @@ describe('Logwell Client', () => { vi.useRealTimers(); }); - describe('constructor', () => { - it('creates client with valid config', () => { + describe("constructor", () => { + it("creates client with valid config", () => { const client = new Logwell(defaultConfig); expect(client).toBeInstanceOf(Logwell); }); - it('throws on invalid config', () => { - expect(() => new Logwell({ apiKey: 'invalid' } as LogwellConfig)).toThrow(LogwellError); + it("throws on invalid config", () => { + expect(() => new Logwell({ apiKey: "invalid" } as LogwellConfig)).toThrow(LogwellError); }); - it('initializes with zero queue size', () => { + it("initializes with zero queue size", () => { const client = new Logwell(defaultConfig); expect(client.queueSize).toBe(0); }); }); - describe('log methods', () => { - it.each(allLogLevels)('%s() logs with correct level', async (level) => { + describe("log methods", () => { + it.each(allLogLevels)("%s() logs with correct level", async (level) => { const client = new Logwell(defaultConfig); client[level](`Test ${level} message`); @@ -63,42 +63,42 @@ describe('Logwell Client', () => { expect(capturedLogs[0].message).toBe(`Test ${level} message`); }); - it('log() sends log with specified level', async () => { + it("log() sends log with specified level", async () => { const client = new Logwell(defaultConfig); - client.log({ level: 'error', message: 'Custom log' }); + client.log({ level: "error", message: "Custom log" }); await client.flush(); expect(capturedLogs).toHaveLength(1); - expect(capturedLogs[0].level).toBe('error'); + expect(capturedLogs[0].level).toBe("error"); }); - it('includes metadata in log', async () => { + it("includes metadata in log", async () => { const client = new Logwell(defaultConfig); - const metadata = { userId: '123', action: 'login' }; + const metadata = { userId: "123", action: "login" }; - client.info('User action', metadata); + client.info("User action", metadata); await client.flush(); expect(capturedLogs[0].metadata).toEqual(metadata); }); - it('includes service name from config', async () => { + it("includes service name from config", async () => { const client = new Logwell({ ...defaultConfig, - service: 'test-service', + service: "test-service", }); - client.info('With service'); + client.info("With service"); await client.flush(); - expect(capturedLogs[0].service).toBe('test-service'); + expect(capturedLogs[0].service).toBe("test-service"); }); - it('adds timestamp to log', async () => { + it("adds timestamp to log", async () => { const client = new Logwell(defaultConfig); - client.info('Timestamped log'); + client.info("Timestamped log"); await client.flush(); expect(capturedLogs[0].timestamp).toBeDefined(); @@ -108,19 +108,19 @@ describe('Logwell Client', () => { }); }); - describe('batching', () => { - it('queues logs until batch size', async () => { + describe("batching", () => { + it("queues logs until batch size", async () => { const client = new Logwell(defaultConfig); - client.info('Log 1'); - client.info('Log 2'); - client.info('Log 3'); + client.info("Log 1"); + client.info("Log 2"); + client.info("Log 3"); expect(client.queueSize).toBe(3); expect(capturedLogs).toHaveLength(0); }); - it('auto-flushes when batch size reached', async () => { + it("auto-flushes when batch size reached", async () => { const client = new Logwell(defaultConfig); // Add 5 logs (batchSize is 5) @@ -134,10 +134,10 @@ describe('Logwell Client', () => { expect(client.queueSize).toBe(0); }); - it('auto-flushes after flush interval', async () => { + it("auto-flushes after flush interval", async () => { const client = new Logwell(defaultConfig); - client.info('Delayed log'); + client.info("Delayed log"); await vi.advanceTimersByTimeAsync(1000); @@ -145,12 +145,12 @@ describe('Logwell Client', () => { }); }); - describe('flush', () => { - it('sends all queued logs', async () => { + describe("flush", () => { + it("sends all queued logs", async () => { const client = new Logwell(defaultConfig); - client.info('Log 1'); - client.info('Log 2'); + client.info("Log 1"); + client.info("Log 2"); await client.flush(); @@ -158,18 +158,18 @@ describe('Logwell Client', () => { expect(client.queueSize).toBe(0); }); - it('returns response from server', async () => { + it("returns response from server", async () => { const client = new Logwell(defaultConfig); - client.info('Log 1'); - client.info('Log 2'); + client.info("Log 1"); + client.info("Log 2"); const response = await client.flush(); expect(response?.accepted).toBe(2); }); - it('returns null if queue is empty', async () => { + it("returns null if queue is empty", async () => { const client = new Logwell(defaultConfig); const response = await client.flush(); @@ -178,40 +178,40 @@ describe('Logwell Client', () => { }); }); - describe('shutdown', () => { - it('flushes remaining logs', async () => { + describe("shutdown", () => { + it("flushes remaining logs", async () => { const client = new Logwell(defaultConfig); - client.info('Final log'); + client.info("Final log"); await client.shutdown(); expect(capturedLogs).toHaveLength(1); }); - it('prevents further logging after shutdown', async () => { + it("prevents further logging after shutdown", async () => { const client = new Logwell(defaultConfig); await client.shutdown(); - client.info('Should be ignored'); + client.info("Should be ignored"); expect(client.queueSize).toBe(0); }); }); - describe('callbacks', () => { - it('calls onFlush after successful flush', async () => { + describe("callbacks", () => { + it("calls onFlush after successful flush", async () => { const onFlush = vi.fn(); const client = new Logwell({ ...defaultConfig, onFlush }); - client.info('Log 1'); - client.info('Log 2'); + client.info("Log 1"); + client.info("Log 2"); await client.flush(); expect(onFlush).toHaveBeenCalledWith(2); }); - it('calls onError on send failure', async () => { + it("calls onError on send failure", async () => { // Use real timers for this test vi.useRealTimers(); @@ -223,61 +223,61 @@ describe('Logwell Client', () => { onError, }); - client.info('Will fail'); + client.info("Will fail"); await client.flush(); expect(onError).toHaveBeenCalled(); }); }); - describe('child logger', () => { - it('creates child with inherited config', async () => { + describe("child logger", () => { + it("creates child with inherited config", async () => { const client = new Logwell({ ...defaultConfig, - service: 'parent-service', + service: "parent-service", }); const child = client.child({}); - child.info('From child'); + child.info("From child"); await child.flush(); - expect(capturedLogs[0].service).toBe('parent-service'); + expect(capturedLogs[0].service).toBe("parent-service"); }); - it('child can override service', async () => { + it("child can override service", async () => { const client = new Logwell({ ...defaultConfig, - service: 'parent-service', + service: "parent-service", }); - const child = client.child({ service: 'child-service' }); - child.info('From child'); + const child = client.child({ service: "child-service" }); + child.info("From child"); await child.flush(); - expect(capturedLogs[0].service).toBe('child-service'); + expect(capturedLogs[0].service).toBe("child-service"); }); - it('child merges metadata', async () => { + it("child merges metadata", async () => { const client = new Logwell(defaultConfig); const child = client.child({ - metadata: { requestId: 'req-123' }, + metadata: { requestId: "req-123" }, }); - child.info('With context', { extra: 'data' }); + child.info("With context", { extra: "data" }); await child.flush(); expect(capturedLogs[0].metadata).toEqual({ - requestId: 'req-123', - extra: 'data', + requestId: "req-123", + extra: "data", }); }); - it('child shares queue with parent', async () => { + it("child shares queue with parent", async () => { const client = new Logwell(defaultConfig); const child = client.child({}); - client.info('Parent log'); - child.info('Child log'); + client.info("Parent log"); + child.info("Child log"); expect(client.queueSize).toBe(2); @@ -287,26 +287,26 @@ describe('Logwell Client', () => { }); }); - describe('error handling', () => { - it('continues working after transient error', async () => { + describe("error handling", () => { + it("continues working after transient error", async () => { // Use real timers for this test since it involves actual retries vi.useRealTimers(); let attempts = 0; server.use( - http.post('*/v1/ingest', async () => { + http.post("*/v1/ingest", async () => { attempts++; if (attempts < 2) { - return HttpResponse.json({ error: 'internal' }, { status: 500 }); + return HttpResponse.json({ error: "internal" }, { status: 500 }); } - capturedLogs.push({ level: 'info', message: 'Will retry' }); + capturedLogs.push({ level: "info", message: "Will retry" }); return HttpResponse.json({ accepted: 1 }); }), ); const client = new Logwell(defaultConfig); - client.info('Will retry'); + client.info("Will retry"); await client.flush(); // Should succeed on retry @@ -314,31 +314,31 @@ describe('Logwell Client', () => { }, 10000); }); - describe('source location capture', () => { - it('sends sourceFile and lineNumber when captureSourceLocation is enabled', async () => { + describe("source location capture", () => { + it("sends sourceFile and lineNumber when captureSourceLocation is enabled", async () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - client.info('Log with source location'); + client.info("Log with source location"); await client.flush(); expect(capturedLogs).toHaveLength(1); expect(capturedLogs[0].sourceFile).toBeDefined(); - expect(capturedLogs[0].sourceFile).toContain('client.integration.test.ts'); + expect(capturedLogs[0].sourceFile).toContain("client.integration.test.ts"); expect(capturedLogs[0].lineNumber).toBeDefined(); - expect(typeof capturedLogs[0].lineNumber).toBe('number'); + expect(typeof capturedLogs[0].lineNumber).toBe("number"); expect(capturedLogs[0].lineNumber).toBeGreaterThan(0); }); - it('does not send sourceFile when captureSourceLocation is disabled', async () => { + it("does not send sourceFile when captureSourceLocation is disabled", async () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: false, }); - client.info('Log without source location'); + client.info("Log without source location"); await client.flush(); expect(capturedLogs).toHaveLength(1); @@ -346,10 +346,10 @@ describe('Logwell Client', () => { expect(capturedLogs[0].lineNumber).toBeUndefined(); }); - it('does not send sourceFile by default', async () => { + it("does not send sourceFile by default", async () => { const client = new Logwell(defaultConfig); - client.info('Log with default config'); + client.info("Log with default config"); await client.flush(); expect(capturedLogs).toHaveLength(1); diff --git a/sdks/typescript/tests/integration/transport.integration.test.ts b/sdks/typescript/tests/integration/transport.integration.test.ts index feb46f7..9a14e12 100644 --- a/sdks/typescript/tests/integration/transport.integration.test.ts +++ b/sdks/typescript/tests/integration/transport.integration.test.ts @@ -1,27 +1,27 @@ -import { HttpResponse, http } from 'msw'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { LogwellError } from '../../src/errors'; -import { HttpTransport, type TransportConfig } from '../../src/transport'; -import { createLogBatch, createLogFixture, logFixtures } from '../fixtures/logs'; -import { BASE_URL, errorHandlers } from '../mocks/handlers'; -import { server } from '../mocks/server'; - -describe('HttpTransport', () => { +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { LogwellError } from "../../src/errors"; +import { HttpTransport, type TransportConfig } from "../../src/transport"; +import { createLogBatch, createLogFixture, logFixtures } from "../fixtures/logs"; +import { BASE_URL, errorHandlers } from "../mocks/handlers"; +import { server } from "../mocks/server"; + +describe("HttpTransport", () => { let defaultConfig: TransportConfig; beforeEach(() => { defaultConfig = { endpoint: BASE_URL, - apiKey: 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', + apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", maxRetries: 3, }; }); - describe('send', () => { - it('sends logs to the correct endpoint', async () => { + describe("send", () => { + it("sends logs to the correct endpoint", async () => { let capturedUrl: string | undefined; server.use( - http.post('*/v1/ingest', ({ request }) => { + http.post("*/v1/ingest", ({ request }) => { capturedUrl = request.url; return HttpResponse.json({ accepted: 1 }); }), @@ -33,11 +33,11 @@ describe('HttpTransport', () => { expect(capturedUrl).toBe(`${BASE_URL}/v1/ingest`); }); - it('sends Authorization Bearer header', async () => { + it("sends Authorization Bearer header", async () => { let capturedAuth: string | null = null; server.use( - http.post('*/v1/ingest', ({ request }) => { - capturedAuth = request.headers.get('Authorization'); + http.post("*/v1/ingest", ({ request }) => { + capturedAuth = request.headers.get("Authorization"); return HttpResponse.json({ accepted: 1 }); }), ); @@ -48,11 +48,11 @@ describe('HttpTransport', () => { expect(capturedAuth).toBe(`Bearer ${defaultConfig.apiKey}`); }); - it('sends Content-Type application/json', async () => { + it("sends Content-Type application/json", async () => { let capturedContentType: string | null = null; server.use( - http.post('*/v1/ingest', ({ request }) => { - capturedContentType = request.headers.get('Content-Type'); + http.post("*/v1/ingest", ({ request }) => { + capturedContentType = request.headers.get("Content-Type"); return HttpResponse.json({ accepted: 1 }); }), ); @@ -60,13 +60,13 @@ describe('HttpTransport', () => { const transport = new HttpTransport(defaultConfig); await transport.send([createLogFixture()]); - expect(capturedContentType).toBe('application/json'); + expect(capturedContentType).toBe("application/json"); }); - it('sends logs as JSON array in body', async () => { + it("sends logs as JSON array in body", async () => { let capturedBody: unknown; server.use( - http.post('*/v1/ingest', async ({ request }) => { + http.post("*/v1/ingest", async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ accepted: 2 }); }), @@ -79,9 +79,9 @@ describe('HttpTransport', () => { expect(capturedBody).toEqual(logs); }); - it('returns response on success', async () => { + it("returns response on success", async () => { server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { return HttpResponse.json({ accepted: 3 }); }), ); @@ -92,7 +92,7 @@ describe('HttpTransport', () => { expect(response).toEqual({ accepted: 3 }); }); - it('includes partial success info in response', async () => { + it("includes partial success info in response", async () => { server.use(errorHandlers.partialSuccess); const transport = new HttpTransport(defaultConfig); @@ -100,66 +100,66 @@ describe('HttpTransport', () => { expect(response.accepted).toBe(2); expect(response.rejected).toBe(1); - expect(response.errors).toContain('Entry at index 2: invalid level'); + expect(response.errors).toContain("Entry at index 2: invalid level"); }); }); - describe('error handling', () => { - it('throws LogwellError on 401 Unauthorized', async () => { + describe("error handling", () => { + it("throws LogwellError on 401 Unauthorized", async () => { server.use(errorHandlers.unauthorized); const transport = new HttpTransport(defaultConfig); await expect(transport.send([createLogFixture()])).rejects.toThrow(LogwellError); await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: 'UNAUTHORIZED', + code: "UNAUTHORIZED", statusCode: 401, retryable: false, }); }); - it('throws LogwellError on 400 validation error', async () => { + it("throws LogwellError on 400 validation error", async () => { server.use(errorHandlers.validationError); const transport = new HttpTransport(defaultConfig); await expect(transport.send([createLogFixture()])).rejects.toThrow(LogwellError); await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: 'VALIDATION_ERROR', + code: "VALIDATION_ERROR", statusCode: 400, retryable: false, }); }); - it('throws LogwellError on 500 server error', async () => { + it("throws LogwellError on 500 server error", async () => { server.use(errorHandlers.serverError); const transport = new HttpTransport(defaultConfig); await expect(transport.send([createLogFixture()])).rejects.toThrow(LogwellError); await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: 'SERVER_ERROR', + code: "SERVER_ERROR", statusCode: 500, retryable: true, }); }); - it('throws LogwellError on 429 rate limited', async () => { + it("throws LogwellError on 429 rate limited", async () => { server.use(errorHandlers.rateLimited); const transport = new HttpTransport(defaultConfig); await expect(transport.send([createLogFixture()])).rejects.toThrow(LogwellError); await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: 'RATE_LIMITED', + code: "RATE_LIMITED", statusCode: 429, retryable: true, }); }); - it('throws LogwellError on network failure', async () => { + it("throws LogwellError on network failure", async () => { server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { return HttpResponse.error(); }), ); @@ -168,20 +168,20 @@ describe('HttpTransport', () => { await expect(transport.send([createLogFixture()])).rejects.toThrow(LogwellError); await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: 'NETWORK_ERROR', + code: "NETWORK_ERROR", retryable: true, }); }); }); - describe('retry logic', () => { - it('retries on 5xx errors up to maxRetries', async () => { + describe("retry logic", () => { + it("retries on 5xx errors up to maxRetries", async () => { let attempts = 0; server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { attempts++; if (attempts < 3) { - return HttpResponse.json({ error: 'internal' }, { status: 500 }); + return HttpResponse.json({ error: "internal" }, { status: 500 }); } return HttpResponse.json({ accepted: 1 }); }), @@ -194,12 +194,12 @@ describe('HttpTransport', () => { expect(response.accepted).toBe(1); }); - it('does not retry on 4xx errors (except 429)', async () => { + it("does not retry on 4xx errors (except 429)", async () => { let attempts = 0; server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { attempts++; - return HttpResponse.json({ error: 'bad request' }, { status: 400 }); + return HttpResponse.json({ error: "bad request" }, { status: 400 }); }), ); @@ -209,15 +209,15 @@ describe('HttpTransport', () => { expect(attempts).toBe(1); }); - it('retries on 429 rate limited', async () => { + it("retries on 429 rate limited", async () => { let attempts = 0; server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { attempts++; if (attempts < 2) { return HttpResponse.json( - { error: 'rate_limited' }, - { status: 429, headers: { 'Retry-After': '0' } }, + { error: "rate_limited" }, + { status: 429, headers: { "Retry-After": "0" } }, ); } return HttpResponse.json({ accepted: 1 }); @@ -231,10 +231,10 @@ describe('HttpTransport', () => { expect(response.accepted).toBe(1); }); - it('retries on network errors', async () => { + it("retries on network errors", async () => { let attempts = 0; server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { attempts++; if (attempts < 2) { return HttpResponse.error(); @@ -250,12 +250,12 @@ describe('HttpTransport', () => { expect(response.accepted).toBe(1); }); - it('fails after exhausting retries', async () => { + it("fails after exhausting retries", async () => { let attempts = 0; server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { attempts++; - return HttpResponse.json({ error: 'internal' }, { status: 500 }); + return HttpResponse.json({ error: "internal" }, { status: 500 }); }), ); @@ -265,16 +265,16 @@ describe('HttpTransport', () => { expect(attempts).toBe(3); // initial + 2 retries }); - it('uses exponential backoff between retries', async () => { + it("uses exponential backoff between retries", async () => { const delays: number[] = []; let lastTime = Date.now(); server.use( - http.post('*/v1/ingest', () => { + http.post("*/v1/ingest", () => { const now = Date.now(); delays.push(now - lastTime); lastTime = now; - return HttpResponse.json({ error: 'internal' }, { status: 500 }); + return HttpResponse.json({ error: "internal" }, { status: 500 }); }), ); @@ -288,11 +288,11 @@ describe('HttpTransport', () => { }); }); - describe('log formatting', () => { - it('sends all log fields correctly', async () => { + describe("log formatting", () => { + it("sends all log fields correctly", async () => { let capturedBody: unknown; server.use( - http.post('*/v1/ingest', async ({ request }) => { + http.post("*/v1/ingest", async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ accepted: 1 }); }), @@ -304,10 +304,10 @@ describe('HttpTransport', () => { expect(capturedBody).toEqual([logFixtures.full]); }); - it('handles unicode in messages', async () => { + it("handles unicode in messages", async () => { let capturedBody: unknown; server.use( - http.post('*/v1/ingest', async ({ request }) => { + http.post("*/v1/ingest", async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ accepted: 1 }); }), @@ -319,10 +319,10 @@ describe('HttpTransport', () => { expect(capturedBody).toEqual([logFixtures.withUnicode]); }); - it('handles large metadata objects', async () => { + it("handles large metadata objects", async () => { let capturedBody: unknown; server.use( - http.post('*/v1/ingest', async ({ request }) => { + http.post("*/v1/ingest", async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ accepted: 1 }); }), diff --git a/sdks/typescript/tests/mocks/handlers.ts b/sdks/typescript/tests/mocks/handlers.ts index 579884d..c238ca4 100644 --- a/sdks/typescript/tests/mocks/handlers.ts +++ b/sdks/typescript/tests/mocks/handlers.ts @@ -1,6 +1,6 @@ -import { delay, HttpResponse, http } from 'msw'; +import { delay, HttpResponse, http } from "msw"; -const BASE_URL = 'https://test.logwell.io'; +const BASE_URL = "https://test.logwell.io"; /** * Default MSW handlers for Logwell API @@ -8,12 +8,12 @@ const BASE_URL = 'https://test.logwell.io'; export const handlers = [ // Success response for /v1/ingest http.post(`${BASE_URL}/v1/ingest`, async ({ request }) => { - const authHeader = request.headers.get('Authorization'); + const authHeader = request.headers.get("Authorization"); // Check authentication - if (!authHeader?.startsWith('Bearer lw_')) { + if (!authHeader?.startsWith("Bearer lw_")) { return HttpResponse.json( - { error: 'unauthorized', message: 'Missing or invalid authorization header' }, + { error: "unauthorized", message: "Missing or invalid authorization header" }, { status: 401 }, ); } @@ -24,7 +24,7 @@ export const handlers = [ body = await request.json(); } catch { return HttpResponse.json( - { error: 'invalid_json', message: 'Request body must be valid JSON' }, + { error: "invalid_json", message: "Request body must be valid JSON" }, { status: 400 }, ); } @@ -42,22 +42,22 @@ export const handlers = [ */ export const errorHandlers = { unauthorized: http.post(`${BASE_URL}/v1/ingest`, () => - HttpResponse.json({ error: 'unauthorized', message: 'Invalid API key' }, { status: 401 }), + HttpResponse.json({ error: "unauthorized", message: "Invalid API key" }, { status: 401 }), ), serverError: http.post(`${BASE_URL}/v1/ingest`, () => - HttpResponse.json({ error: 'internal_error' }, { status: 500 }), + HttpResponse.json({ error: "internal_error" }, { status: 500 }), ), timeout: http.post(`${BASE_URL}/v1/ingest`, async () => { - await delay('infinite'); + await delay("infinite"); return HttpResponse.json({}); }), rateLimited: http.post(`${BASE_URL}/v1/ingest`, () => HttpResponse.json( - { error: 'rate_limited', message: 'Too many requests' }, - { status: 429, headers: { 'Retry-After': '5' } }, + { error: "rate_limited", message: "Too many requests" }, + { status: 429, headers: { "Retry-After": "5" } }, ), ), @@ -65,13 +65,13 @@ export const errorHandlers = { HttpResponse.json({ accepted: 2, rejected: 1, - errors: ['Entry at index 2: invalid level'], + errors: ["Entry at index 2: invalid level"], }), ), validationError: http.post(`${BASE_URL}/v1/ingest`, () => HttpResponse.json( - { error: 'validation_error', message: 'Invalid log format' }, + { error: "validation_error", message: "Invalid log format" }, { status: 400 }, ), ), diff --git a/sdks/typescript/tests/mocks/server.ts b/sdks/typescript/tests/mocks/server.ts index e52fee0..7b37f2a 100644 --- a/sdks/typescript/tests/mocks/server.ts +++ b/sdks/typescript/tests/mocks/server.ts @@ -1,4 +1,4 @@ -import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; export const server = setupServer(...handlers); diff --git a/sdks/typescript/tests/setup.ts b/sdks/typescript/tests/setup.ts index 8f28cf9..49469ea 100644 --- a/sdks/typescript/tests/setup.ts +++ b/sdks/typescript/tests/setup.ts @@ -1,9 +1,9 @@ -import { afterAll, afterEach, beforeAll } from 'vitest'; -import { server } from './mocks/server'; +import { afterAll, afterEach, beforeAll } from "vite-plus/test"; +import { server } from "./mocks/server"; // Start MSW server before all tests beforeAll(() => { - server.listen({ onUnhandledRequest: 'error' }); + server.listen({ onUnhandledRequest: "error" }); }); // Reset handlers after each test diff --git a/sdks/typescript/tests/unit/client.unit.test.ts b/sdks/typescript/tests/unit/client.unit.test.ts index b6cffb1..b72ed09 100644 --- a/sdks/typescript/tests/unit/client.unit.test.ts +++ b/sdks/typescript/tests/unit/client.unit.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Logwell } from '../../src/client'; -import type { LogEntry, LogwellConfig } from '../../src/types'; -import { validConfigs } from '../fixtures/configs'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Logwell } from "../../src/client"; +import type { LogEntry, LogwellConfig } from "../../src/types"; +import { validConfigs } from "../fixtures/configs"; -describe('Logwell Client - Source Location', () => { +describe("Logwell Client - Source Location", () => { let defaultConfig: LogwellConfig; beforeEach(() => { @@ -19,12 +19,12 @@ describe('Logwell Client - Source Location', () => { vi.useRealTimers(); }); - describe('source location disabled (default)', () => { - it('does not include sourceFile when captureSourceLocation is false', () => { + describe("source location disabled (default)", () => { + it("does not include sourceFile when captureSourceLocation is false", () => { const client = new Logwell(defaultConfig); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.info('Test message'); + client.info("Test message"); expect(queueAddSpy).toHaveBeenCalledTimes(1); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; @@ -32,11 +32,11 @@ describe('Logwell Client - Source Location', () => { expect(entry.lineNumber).toBeUndefined(); }); - it('does not include sourceFile by default', () => { + it("does not include sourceFile by default", () => { const client = new Logwell(validConfigs.minimal); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.info('Test message'); + client.info("Test message"); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; expect(entry.sourceFile).toBeUndefined(); @@ -44,110 +44,107 @@ describe('Logwell Client - Source Location', () => { }); }); - describe('source location enabled', () => { - it('includes sourceFile when captureSourceLocation is true', () => { + describe("source location enabled", () => { + it("includes sourceFile when captureSourceLocation is true", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.info('Test message'); + client.info("Test message"); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; expect(entry.sourceFile).toBeDefined(); - expect(entry.sourceFile).toContain('client.unit.test.ts'); + expect(entry.sourceFile).toContain("client.unit.test.ts"); }); - it('includes lineNumber when captureSourceLocation is true', () => { + it("includes lineNumber when captureSourceLocation is true", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.info('Test message'); + client.info("Test message"); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; expect(entry.lineNumber).toBeDefined(); - expect(typeof entry.lineNumber).toBe('number'); + expect(typeof entry.lineNumber).toBe("number"); expect(entry.lineNumber).toBeGreaterThan(0); }); - it('captures correct location for info()', () => { + it("captures correct location for info()", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.info('Test message'); // This line's number should be captured + client.info("Test message"); // This line's number should be captured const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).not.toContain('client.ts'); - expect(entry.sourceFile).toContain('client.unit.test.ts'); + expect(entry.sourceFile).not.toContain("client.ts"); + expect(entry.sourceFile).toContain("client.unit.test.ts"); }); - it('captures correct location for log()', () => { + it("captures correct location for log()", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - client.log({ level: 'info', message: 'Direct log' }); + client.log({ level: "info", message: "Direct log" }); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).not.toContain('client.ts'); - expect(entry.sourceFile).toContain('client.unit.test.ts'); + expect(entry.sourceFile).not.toContain("client.ts"); + expect(entry.sourceFile).toContain("client.unit.test.ts"); }); - it.each([ - 'debug', - 'info', - 'warn', - 'error', - 'fatal', - ] as const)('captures correct location for %s()', (method) => { - const client = new Logwell({ - ...defaultConfig, - captureSourceLocation: true, - }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); - - client[method]('Test message'); - - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).toContain('client.unit.test.ts'); - expect(entry.lineNumber).toBeGreaterThan(0); - }); + it.each(["debug", "info", "warn", "error", "fatal"] as const)( + "captures correct location for %s()", + (method) => { + const client = new Logwell({ + ...defaultConfig, + captureSourceLocation: true, + }); + const queueAddSpy = vi.spyOn(client["queue"], "add"); + + client[method]("Test message"); + + const entry = queueAddSpy.mock.calls[0][0] as LogEntry; + expect(entry.sourceFile).toContain("client.unit.test.ts"); + expect(entry.lineNumber).toBeGreaterThan(0); + }, + ); }); - describe('child logger', () => { - it('child logger inherits captureSourceLocation setting', () => { + describe("child logger", () => { + it("child logger inherits captureSourceLocation setting", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: true, }); - const child = client.child({ metadata: { requestId: '123' } }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const child = client.child({ metadata: { requestId: "123" } }); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - child.info('Child message'); + child.info("Child message"); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; expect(entry.sourceFile).toBeDefined(); - expect(entry.sourceFile).toContain('client.unit.test.ts'); + expect(entry.sourceFile).toContain("client.unit.test.ts"); }); - it('child logger does not include source location when parent has it disabled', () => { + it("child logger does not include source location when parent has it disabled", () => { const client = new Logwell({ ...defaultConfig, captureSourceLocation: false, }); - const child = client.child({ metadata: { requestId: '123' } }); - const queueAddSpy = vi.spyOn(client['queue'], 'add'); + const child = client.child({ metadata: { requestId: "123" } }); + const queueAddSpy = vi.spyOn(client["queue"], "add"); - child.info('Child message'); + child.info("Child message"); const entry = queueAddSpy.mock.calls[0][0] as LogEntry; expect(entry.sourceFile).toBeUndefined(); diff --git a/sdks/typescript/tests/unit/config.unit.test.ts b/sdks/typescript/tests/unit/config.unit.test.ts index dd2d345..50726d4 100644 --- a/sdks/typescript/tests/unit/config.unit.test.ts +++ b/sdks/typescript/tests/unit/config.unit.test.ts @@ -1,90 +1,90 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; import { API_KEY_REGEX, DEFAULT_CONFIG, validateApiKeyFormat, validateConfig, -} from '../../src/config'; -import { LogwellError } from '../../src/errors'; -import { invalidConfigs, validConfigs } from '../fixtures/configs'; +} from "../../src/config"; +import { LogwellError } from "../../src/errors"; +import { invalidConfigs, validConfigs } from "../fixtures/configs"; -describe('API_KEY_REGEX', () => { - it('matches valid API key format', () => { - const validKey = 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456'; +describe("API_KEY_REGEX", () => { + it("matches valid API key format", () => { + const validKey = "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456"; expect(API_KEY_REGEX.test(validKey)).toBe(true); }); - it('rejects keys without lw_ prefix', () => { - const invalidKey = 'xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456'; + it("rejects keys without lw_ prefix", () => { + const invalidKey = "xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456"; expect(API_KEY_REGEX.test(invalidKey)).toBe(false); }); - it('rejects keys with wrong length', () => { - const tooShort = 'lw_short'; - const tooLong = 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456extra'; + it("rejects keys with wrong length", () => { + const tooShort = "lw_short"; + const tooLong = "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456extra"; expect(API_KEY_REGEX.test(tooShort)).toBe(false); expect(API_KEY_REGEX.test(tooLong)).toBe(false); }); - it('allows hyphens and underscores in key body', () => { + it("allows hyphens and underscores in key body", () => { // Must be exactly 32 chars after lw_ prefix - const withHyphen = 'lw_aBcDeFgHiJkLmNo-qRsTuVwXyZ123456'; - const withUnderscore = 'lw_aBcDeFgHiJkLmNo_qRsTuVwXyZ123456'; + const withHyphen = "lw_aBcDeFgHiJkLmNo-qRsTuVwXyZ123456"; + const withUnderscore = "lw_aBcDeFgHiJkLmNo_qRsTuVwXyZ123456"; expect(API_KEY_REGEX.test(withHyphen)).toBe(true); expect(API_KEY_REGEX.test(withUnderscore)).toBe(true); }); }); -describe('validateApiKeyFormat', () => { - it('returns true for valid API key', () => { - expect(validateApiKeyFormat('lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456')).toBe(true); +describe("validateApiKeyFormat", () => { + it("returns true for valid API key", () => { + expect(validateApiKeyFormat("lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456")).toBe(true); }); - it('returns false for empty string', () => { - expect(validateApiKeyFormat('')).toBe(false); + it("returns false for empty string", () => { + expect(validateApiKeyFormat("")).toBe(false); }); - it('returns false for invalid format', () => { - expect(validateApiKeyFormat('invalid')).toBe(false); + it("returns false for invalid format", () => { + expect(validateApiKeyFormat("invalid")).toBe(false); }); - it('returns false for wrong prefix', () => { - expect(validateApiKeyFormat('xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456')).toBe(false); + it("returns false for wrong prefix", () => { + expect(validateApiKeyFormat("xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456")).toBe(false); }); - it('returns false for short key', () => { - expect(validateApiKeyFormat('lw_short')).toBe(false); + it("returns false for short key", () => { + expect(validateApiKeyFormat("lw_short")).toBe(false); }); }); -describe('DEFAULT_CONFIG', () => { - it('has correct default batchSize', () => { +describe("DEFAULT_CONFIG", () => { + it("has correct default batchSize", () => { expect(DEFAULT_CONFIG.batchSize).toBe(50); }); - it('has correct default flushInterval', () => { + it("has correct default flushInterval", () => { expect(DEFAULT_CONFIG.flushInterval).toBe(5000); }); - it('has correct default maxQueueSize', () => { + it("has correct default maxQueueSize", () => { expect(DEFAULT_CONFIG.maxQueueSize).toBe(1000); }); - it('has correct default maxRetries', () => { + it("has correct default maxRetries", () => { expect(DEFAULT_CONFIG.maxRetries).toBe(3); }); }); -describe('validateConfig', () => { - describe('valid configurations', () => { - it('accepts minimal valid config', () => { +describe("validateConfig", () => { + describe("valid configurations", () => { + it("accepts minimal valid config", () => { const result = validateConfig(validConfigs.minimal); expect(result.apiKey).toBe(validConfigs.minimal.apiKey); expect(result.endpoint).toBe(validConfigs.minimal.endpoint); }); - it('merges defaults for missing optional fields', () => { + it("merges defaults for missing optional fields", () => { const result = validateConfig(validConfigs.minimal); expect(result.batchSize).toBe(DEFAULT_CONFIG.batchSize); @@ -93,7 +93,7 @@ describe('validateConfig', () => { expect(result.maxRetries).toBe(DEFAULT_CONFIG.maxRetries); }); - it('preserves provided optional values', () => { + it("preserves provided optional values", () => { const result = validateConfig(validConfigs.full); expect(result.batchSize).toBe(25); @@ -102,13 +102,13 @@ describe('validateConfig', () => { expect(result.maxRetries).toBe(5); }); - it('preserves service name', () => { + it("preserves service name", () => { const result = validateConfig(validConfigs.withService); - expect(result.service).toBe('my-app'); + expect(result.service).toBe("my-app"); }); - it('preserves callback functions', () => { + it("preserves callback functions", () => { const onError = vi.fn(); const onFlush = vi.fn(); const config = { @@ -124,88 +124,88 @@ describe('validateConfig', () => { }); }); - describe('invalid configurations', () => { - it('throws LogwellError for missing apiKey', () => { + describe("invalid configurations", () => { + it("throws LogwellError for missing apiKey", () => { expect(() => validateConfig(invalidConfigs.missingApiKey)).toThrow(LogwellError); - expect(() => validateConfig(invalidConfigs.missingApiKey)).toThrow('apiKey is required'); + expect(() => validateConfig(invalidConfigs.missingApiKey)).toThrow("apiKey is required"); }); - it('throws LogwellError for missing endpoint', () => { + it("throws LogwellError for missing endpoint", () => { expect(() => validateConfig(invalidConfigs.missingEndpoint)).toThrow(LogwellError); - expect(() => validateConfig(invalidConfigs.missingEndpoint)).toThrow('endpoint is required'); + expect(() => validateConfig(invalidConfigs.missingEndpoint)).toThrow("endpoint is required"); }); - it('throws LogwellError for empty apiKey', () => { + it("throws LogwellError for empty apiKey", () => { expect(() => validateConfig(invalidConfigs.emptyApiKey)).toThrow(LogwellError); }); - it('throws LogwellError for invalid apiKey format', () => { + it("throws LogwellError for invalid apiKey format", () => { expect(() => validateConfig(invalidConfigs.invalidApiKeyFormat)).toThrow(LogwellError); expect(() => validateConfig(invalidConfigs.invalidApiKeyFormat)).toThrow( - 'Invalid API key format', + "Invalid API key format", ); }); - it('throws LogwellError for apiKey with wrong prefix', () => { + it("throws LogwellError for apiKey with wrong prefix", () => { expect(() => validateConfig(invalidConfigs.apiKeyWrongPrefix)).toThrow(LogwellError); }); - it('throws LogwellError for apiKey too short', () => { + it("throws LogwellError for apiKey too short", () => { expect(() => validateConfig(invalidConfigs.apiKeyTooShort)).toThrow(LogwellError); }); - it('throws LogwellError for invalid endpoint URL', () => { + it("throws LogwellError for invalid endpoint URL", () => { expect(() => validateConfig(invalidConfigs.invalidEndpoint)).toThrow(LogwellError); - expect(() => validateConfig(invalidConfigs.invalidEndpoint)).toThrow('Invalid endpoint URL'); + expect(() => validateConfig(invalidConfigs.invalidEndpoint)).toThrow("Invalid endpoint URL"); }); - it('throws LogwellError for negative batchSize', () => { + it("throws LogwellError for negative batchSize", () => { expect(() => validateConfig(invalidConfigs.negativeBatchSize)).toThrow(LogwellError); expect(() => validateConfig(invalidConfigs.negativeBatchSize)).toThrow( - 'batchSize must be positive', + "batchSize must be positive", ); }); - it('throws LogwellError for zero batchSize', () => { + it("throws LogwellError for zero batchSize", () => { expect(() => validateConfig(invalidConfigs.zeroBatchSize)).toThrow(LogwellError); }); - it('throws LogwellError for negative flushInterval', () => { + it("throws LogwellError for negative flushInterval", () => { expect(() => validateConfig(invalidConfigs.negativeFlushInterval)).toThrow(LogwellError); expect(() => validateConfig(invalidConfigs.negativeFlushInterval)).toThrow( - 'flushInterval must be positive', + "flushInterval must be positive", ); }); }); - describe('error details', () => { - it('throws with INVALID_CONFIG error code', () => { + describe("error details", () => { + it("throws with INVALID_CONFIG error code", () => { try { validateConfig(invalidConfigs.missingApiKey); - expect.fail('Should have thrown'); + expect.fail("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(LogwellError); - expect((error as LogwellError).code).toBe('INVALID_CONFIG'); + expect((error as LogwellError).code).toBe("INVALID_CONFIG"); } }); - it('throws non-retryable error', () => { + it("throws non-retryable error", () => { try { validateConfig(invalidConfigs.missingApiKey); - expect.fail('Should have thrown'); + expect.fail("Should have thrown"); } catch (error) { expect((error as LogwellError).retryable).toBe(false); } }); }); - describe('captureSourceLocation', () => { - it('defaults captureSourceLocation to false', () => { + describe("captureSourceLocation", () => { + it("defaults captureSourceLocation to false", () => { const result = validateConfig(validConfigs.minimal); expect(result.captureSourceLocation).toBe(false); }); - it('preserves captureSourceLocation when set to true', () => { + it("preserves captureSourceLocation when set to true", () => { const result = validateConfig({ ...validConfigs.minimal, captureSourceLocation: true, @@ -213,7 +213,7 @@ describe('validateConfig', () => { expect(result.captureSourceLocation).toBe(true); }); - it('preserves captureSourceLocation when set to false explicitly', () => { + it("preserves captureSourceLocation when set to false explicitly", () => { const result = validateConfig({ ...validConfigs.minimal, captureSourceLocation: false, diff --git a/sdks/typescript/tests/unit/errors.unit.test.ts b/sdks/typescript/tests/unit/errors.unit.test.ts index 0b94f02..5196e45 100644 --- a/sdks/typescript/tests/unit/errors.unit.test.ts +++ b/sdks/typescript/tests/unit/errors.unit.test.ts @@ -1,113 +1,114 @@ -import { describe, expect, it } from 'vitest'; -import { LogwellError, type LogwellErrorCode } from '../../src/errors'; +import { describe, expect, it } from "vite-plus/test"; +import { LogwellError, type LogwellErrorCode } from "../../src/errors"; -describe('LogwellError', () => { - describe('constructor', () => { - it('creates error with message and code', () => { - const error = new LogwellError('Test error', 'NETWORK_ERROR'); +describe("LogwellError", () => { + describe("constructor", () => { + it("creates error with message and code", () => { + const error = new LogwellError("Test error", "NETWORK_ERROR"); - expect(error.message).toBe('Test error'); - expect(error.code).toBe('NETWORK_ERROR'); - expect(error.name).toBe('LogwellError'); + expect(error.message).toBe("Test error"); + expect(error.code).toBe("NETWORK_ERROR"); + expect(error.name).toBe("LogwellError"); }); - it('creates error with statusCode', () => { - const error = new LogwellError('Unauthorized', 'UNAUTHORIZED', 401); + it("creates error with statusCode", () => { + const error = new LogwellError("Unauthorized", "UNAUTHORIZED", 401); expect(error.statusCode).toBe(401); }); - it('creates error with retryable flag', () => { - const error = new LogwellError('Server error', 'SERVER_ERROR', 500, true); + it("creates error with retryable flag", () => { + const error = new LogwellError("Server error", "SERVER_ERROR", 500, true); expect(error.retryable).toBe(true); }); - it('defaults retryable to false', () => { - const error = new LogwellError('Bad request', 'VALIDATION_ERROR', 400); + it("defaults retryable to false", () => { + const error = new LogwellError("Bad request", "VALIDATION_ERROR", 400); expect(error.retryable).toBe(false); }); - it('allows undefined statusCode', () => { - const error = new LogwellError('Network failed', 'NETWORK_ERROR'); + it("allows undefined statusCode", () => { + const error = new LogwellError("Network failed", "NETWORK_ERROR"); expect(error.statusCode).toBeUndefined(); }); }); - describe('inheritance', () => { - it('is an instance of Error', () => { - const error = new LogwellError('Test', 'NETWORK_ERROR'); + describe("inheritance", () => { + it("is an instance of Error", () => { + const error = new LogwellError("Test", "NETWORK_ERROR"); expect(error).toBeInstanceOf(Error); }); - it('is an instance of LogwellError', () => { - const error = new LogwellError('Test', 'NETWORK_ERROR'); + it("is an instance of LogwellError", () => { + const error = new LogwellError("Test", "NETWORK_ERROR"); expect(error).toBeInstanceOf(LogwellError); }); - it('has correct stack trace', () => { - const error = new LogwellError('Test', 'NETWORK_ERROR'); + it("has correct stack trace", () => { + const error = new LogwellError("Test", "NETWORK_ERROR"); expect(error.stack).toBeDefined(); - expect(error.stack).toContain('LogwellError'); + expect(error.stack).toContain("LogwellError"); }); }); - describe('error codes', () => { + describe("error codes", () => { const testCases: Array<[LogwellErrorCode, string, number | undefined, boolean]> = [ - ['NETWORK_ERROR', 'Network failed', undefined, true], - ['UNAUTHORIZED', 'Invalid API key', 401, false], - ['VALIDATION_ERROR', 'Invalid format', 400, false], - ['RATE_LIMITED', 'Too many requests', 429, true], - ['SERVER_ERROR', 'Internal error', 500, true], - ['QUEUE_OVERFLOW', 'Queue full', undefined, false], - ['INVALID_CONFIG', 'Bad config', undefined, false], + ["NETWORK_ERROR", "Network failed", undefined, true], + ["UNAUTHORIZED", "Invalid API key", 401, false], + ["VALIDATION_ERROR", "Invalid format", 400, false], + ["RATE_LIMITED", "Too many requests", 429, true], + ["SERVER_ERROR", "Internal error", 500, true], + ["QUEUE_OVERFLOW", "Queue full", undefined, false], + ["INVALID_CONFIG", "Bad config", undefined, false], ]; - it.each( - testCases, - )('handles %s error code correctly', (code, message, statusCode, retryable) => { - const error = new LogwellError(message, code, statusCode, retryable); - - expect(error.code).toBe(code); - expect(error.message).toBe(message); - expect(error.statusCode).toBe(statusCode); - expect(error.retryable).toBe(retryable); - }); + it.each(testCases)( + "handles %s error code correctly", + (code, message, statusCode, retryable) => { + const error = new LogwellError(message, code, statusCode, retryable); + + expect(error.code).toBe(code); + expect(error.message).toBe(message); + expect(error.statusCode).toBe(statusCode); + expect(error.retryable).toBe(retryable); + }, + ); }); - describe('serialization', () => { - it('can be converted to JSON', () => { - const error = new LogwellError('Test error', 'SERVER_ERROR', 500, true); + describe("serialization", () => { + it("can be converted to JSON", () => { + const error = new LogwellError("Test error", "SERVER_ERROR", 500, true); const json = JSON.stringify(error); const parsed = JSON.parse(json); // Note: Error.message is not enumerable, so it won't be in JSON // But code, statusCode, and retryable should be - expect(parsed.code).toBe('SERVER_ERROR'); + expect(parsed.code).toBe("SERVER_ERROR"); expect(parsed.statusCode).toBe(500); expect(parsed.retryable).toBe(true); }); - it('has correct toString representation', () => { - const error = new LogwellError('Something went wrong', 'NETWORK_ERROR'); + it("has correct toString representation", () => { + const error = new LogwellError("Something went wrong", "NETWORK_ERROR"); const str = error.toString(); - expect(str).toContain('LogwellError'); - expect(str).toContain('Something went wrong'); + expect(str).toContain("LogwellError"); + expect(str).toContain("Something went wrong"); }); }); - describe('cause support', () => { - it('supports cause option for error chaining', () => { - const cause = new Error('Original error'); - const error = new LogwellError('Wrapped error', 'NETWORK_ERROR', undefined, true); + describe("cause support", () => { + it("supports cause option for error chaining", () => { + const cause = new Error("Original error"); + const error = new LogwellError("Wrapped error", "NETWORK_ERROR", undefined, true); // Set cause manually since we need to support it - Object.defineProperty(error, 'cause', { value: cause }); + Object.defineProperty(error, "cause", { value: cause }); expect(error.cause).toBe(cause); }); diff --git a/sdks/typescript/tests/unit/queue.unit.test.ts b/sdks/typescript/tests/unit/queue.unit.test.ts index e2e6d8a..7569d7d 100644 --- a/sdks/typescript/tests/unit/queue.unit.test.ts +++ b/sdks/typescript/tests/unit/queue.unit.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BatchQueue, type QueueConfig, type SendBatchFn } from '../../src/queue'; -import { createLogBatch, createLogFixture } from '../fixtures/logs'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { BatchQueue, type QueueConfig, type SendBatchFn } from "../../src/queue"; +import { createLogBatch, createLogFixture } from "../fixtures/logs"; -describe('BatchQueue', () => { +describe("BatchQueue", () => { let mockSendBatch: SendBatchFn; let defaultConfig: QueueConfig; @@ -20,8 +20,8 @@ describe('BatchQueue', () => { vi.useRealTimers(); }); - describe('constructor', () => { - it('creates queue with config', () => { + describe("constructor", () => { + it("creates queue with config", () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); expect(queue).toBeInstanceOf(BatchQueue); @@ -29,8 +29,8 @@ describe('BatchQueue', () => { }); }); - describe('add', () => { - it('adds entry to queue', () => { + describe("add", () => { + it("adds entry to queue", () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); const log = createLogFixture(); @@ -39,7 +39,7 @@ describe('BatchQueue', () => { expect(queue.size).toBe(1); }); - it('increments size for each added entry', () => { + it("increments size for each added entry", () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -49,7 +49,7 @@ describe('BatchQueue', () => { expect(queue.size).toBe(3); }); - it('triggers flush when batchSize reached', async () => { + it("triggers flush when batchSize reached", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); const logs = createLogBatch(5); @@ -64,7 +64,7 @@ describe('BatchQueue', () => { expect(mockSendBatch).toHaveBeenCalledWith(logs); }); - it('does not flush before batchSize reached', () => { + it("does not flush before batchSize reached", () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -74,8 +74,8 @@ describe('BatchQueue', () => { }); }); - describe('flush interval', () => { - it('flushes after flushInterval elapsed', async () => { + describe("flush interval", () => { + it("flushes after flushInterval elapsed", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); const log = createLogFixture(); @@ -88,7 +88,7 @@ describe('BatchQueue', () => { expect(mockSendBatch).toHaveBeenCalledWith([log]); }); - it('does not flush if queue is empty', async () => { + it("does not flush if queue is empty", async () => { new BatchQueue(mockSendBatch, defaultConfig); await vi.advanceTimersByTimeAsync(1000); @@ -96,7 +96,7 @@ describe('BatchQueue', () => { expect(mockSendBatch).not.toHaveBeenCalled(); }); - it('resets timer after manual flush', async () => { + it("resets timer after manual flush", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -110,8 +110,8 @@ describe('BatchQueue', () => { }); }); - describe('flush', () => { - it('sends all queued logs', async () => { + describe("flush", () => { + it("sends all queued logs", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); const logs = createLogBatch(3); @@ -125,7 +125,7 @@ describe('BatchQueue', () => { expect(queue.size).toBe(0); }); - it('returns response from sendBatch', async () => { + it("returns response from sendBatch", async () => { mockSendBatch = vi.fn().mockResolvedValue({ accepted: 3 }); const queue = new BatchQueue(mockSendBatch, defaultConfig); @@ -138,7 +138,7 @@ describe('BatchQueue', () => { expect(response).toEqual({ accepted: 3 }); }); - it('returns null if queue is empty', async () => { + it("returns null if queue is empty", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); const response = await queue.flush(); @@ -147,7 +147,7 @@ describe('BatchQueue', () => { expect(mockSendBatch).not.toHaveBeenCalled(); }); - it('clears queue after successful flush', async () => { + it("clears queue after successful flush", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -158,11 +158,11 @@ describe('BatchQueue', () => { expect(queue.size).toBe(0); }); - it('preserves log order', async () => { + it("preserves log order", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); - const log1 = createLogFixture({ message: 'first' }); - const log2 = createLogFixture({ message: 'second' }); - const log3 = createLogFixture({ message: 'third' }); + const log1 = createLogFixture({ message: "first" }); + const log2 = createLogFixture({ message: "second" }); + const log3 = createLogFixture({ message: "third" }); queue.add(log1); queue.add(log2); @@ -174,15 +174,15 @@ describe('BatchQueue', () => { }); }); - describe('queue overflow', () => { - it('drops oldest logs when maxQueueSize exceeded', () => { + describe("queue overflow", () => { + it("drops oldest logs when maxQueueSize exceeded", () => { const config = { ...defaultConfig, maxQueueSize: 3 }; const queue = new BatchQueue(mockSendBatch, config); - const log1 = createLogFixture({ message: 'oldest' }); - const log2 = createLogFixture({ message: 'middle' }); - const log3 = createLogFixture({ message: 'newest1' }); - const log4 = createLogFixture({ message: 'newest2' }); + const log1 = createLogFixture({ message: "oldest" }); + const log2 = createLogFixture({ message: "middle" }); + const log3 = createLogFixture({ message: "newest1" }); + const log4 = createLogFixture({ message: "newest2" }); queue.add(log1); queue.add(log2); @@ -192,22 +192,22 @@ describe('BatchQueue', () => { expect(queue.size).toBe(3); }); - it('calls onError when overflow occurs', () => { + it("calls onError when overflow occurs", () => { const onError = vi.fn(); const config = { ...defaultConfig, maxQueueSize: 2, onError }; const queue = new BatchQueue(mockSendBatch, config); - queue.add(createLogFixture({ message: 'first' })); - queue.add(createLogFixture({ message: 'second' })); - queue.add(createLogFixture({ message: 'overflow' })); + queue.add(createLogFixture({ message: "first" })); + queue.add(createLogFixture({ message: "second" })); + queue.add(createLogFixture({ message: "overflow" })); expect(onError).toHaveBeenCalled(); - expect(onError.mock.calls[0][0].message).toContain('overflow'); + expect(onError.mock.calls[0][0].message).toContain("overflow"); }); }); - describe('callbacks', () => { - it('calls onFlush after successful flush', async () => { + describe("callbacks", () => { + it("calls onFlush after successful flush", async () => { const onFlush = vi.fn(); const config = { ...defaultConfig, onFlush }; const queue = new BatchQueue(mockSendBatch, config); @@ -220,9 +220,9 @@ describe('BatchQueue', () => { expect(onFlush).toHaveBeenCalledWith(2); }); - it('calls onError on send failure', async () => { + it("calls onError on send failure", async () => { const onError = vi.fn(); - const error = new Error('Send failed'); + const error = new Error("Send failed"); mockSendBatch = vi.fn().mockRejectedValue(error); const config = { ...defaultConfig, onError }; const queue = new BatchQueue(mockSendBatch, config); @@ -234,8 +234,8 @@ describe('BatchQueue', () => { expect(onError).toHaveBeenCalledWith(error); }); - it('re-queues logs on send failure', async () => { - const error = new Error('Send failed'); + it("re-queues logs on send failure", async () => { + const error = new Error("Send failed"); mockSendBatch = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ accepted: 1 }); const queue = new BatchQueue(mockSendBatch, defaultConfig); @@ -248,8 +248,8 @@ describe('BatchQueue', () => { }); }); - describe('shutdown', () => { - it('flushes remaining logs', async () => { + describe("shutdown", () => { + it("flushes remaining logs", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -261,7 +261,7 @@ describe('BatchQueue', () => { expect(queue.size).toBe(0); }); - it('stops timer after shutdown', async () => { + it("stops timer after shutdown", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -275,7 +275,7 @@ describe('BatchQueue', () => { expect(mockSendBatch).toHaveBeenCalledTimes(1); }); - it('handles multiple shutdown calls gracefully', async () => { + it("handles multiple shutdown calls gracefully", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); @@ -288,8 +288,8 @@ describe('BatchQueue', () => { }); }); - describe('concurrent operations', () => { - it('handles concurrent add and flush', async () => { + describe("concurrent operations", () => { + it("handles concurrent add and flush", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); // Add logs while flushing @@ -303,7 +303,7 @@ describe('BatchQueue', () => { expect(queue.size).toBe(1); }); - it('prevents concurrent flushes', async () => { + it("prevents concurrent flushes", async () => { let resolveFirst: () => void; const firstFlush = new Promise((resolve) => { resolveFirst = resolve; diff --git a/sdks/typescript/tests/unit/source-location.unit.test.ts b/sdks/typescript/tests/unit/source-location.unit.test.ts index 7873994..f1be629 100644 --- a/sdks/typescript/tests/unit/source-location.unit.test.ts +++ b/sdks/typescript/tests/unit/source-location.unit.test.ts @@ -1,179 +1,179 @@ -import { describe, expect, it } from 'vitest'; -import { captureSourceLocation, parseStackFrame } from '../../src/source-location'; +import { describe, expect, it } from "vite-plus/test"; +import { captureSourceLocation, parseStackFrame } from "../../src/source-location"; -describe('parseStackFrame', () => { - describe('V8 format (Node/Bun/Chrome)', () => { - it('parses stack frame with function name', () => { - const frame = ' at myFunction (/Users/dev/app/src/index.ts:42:15)'; +describe("parseStackFrame", () => { + describe("V8 format (Node/Bun/Chrome)", () => { + it("parses stack frame with function name", () => { + const frame = " at myFunction (/Users/dev/app/src/index.ts:42:15)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/index.ts', + sourceFile: "/Users/dev/app/src/index.ts", lineNumber: 42, }); }); - it('parses stack frame without function name (anonymous)', () => { - const frame = ' at /Users/dev/app/src/index.ts:42:15'; + it("parses stack frame without function name (anonymous)", () => { + const frame = " at /Users/dev/app/src/index.ts:42:15"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/index.ts', + sourceFile: "/Users/dev/app/src/index.ts", lineNumber: 42, }); }); - it('parses stack frame with method name', () => { - const frame = ' at Object.myMethod (/Users/dev/app/src/utils.ts:100:5)'; + it("parses stack frame with method name", () => { + const frame = " at Object.myMethod (/Users/dev/app/src/utils.ts:100:5)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/utils.ts', + sourceFile: "/Users/dev/app/src/utils.ts", lineNumber: 100, }); }); - it('parses stack frame with async function', () => { - const frame = ' at async handleRequest (/Users/dev/app/src/handler.ts:25:10)'; + it("parses stack frame with async function", () => { + const frame = " at async handleRequest (/Users/dev/app/src/handler.ts:25:10)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/handler.ts', + sourceFile: "/Users/dev/app/src/handler.ts", lineNumber: 25, }); }); - it('parses stack frame with constructor (new)', () => { - const frame = ' at new Foo (/Users/dev/app/src/foo.ts:10:5)'; + it("parses stack frame with constructor (new)", () => { + const frame = " at new Foo (/Users/dev/app/src/foo.ts:10:5)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/foo.ts', + sourceFile: "/Users/dev/app/src/foo.ts", lineNumber: 10, }); }); - it('parses stack frame with aliased method [as alias]', () => { - const frame = ' at Object.method [as alias] (/Users/dev/app/src/utils.ts:50:12)'; + it("parses stack frame with aliased method [as alias]", () => { + const frame = " at Object.method [as alias] (/Users/dev/app/src/utils.ts:50:12)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/utils.ts', + sourceFile: "/Users/dev/app/src/utils.ts", lineNumber: 50, }); }); - it('parses stack frame with new and class name', () => { - const frame = ' at new MyClass (/Users/dev/app/src/my-class.ts:15:3)'; + it("parses stack frame with new and class name", () => { + const frame = " at new MyClass (/Users/dev/app/src/my-class.ts:15:3)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/my-class.ts', + sourceFile: "/Users/dev/app/src/my-class.ts", lineNumber: 15, }); }); }); - describe('SpiderMonkey/JSC format (Firefox/Safari)', () => { - it('parses stack frame with function name', () => { - const frame = 'myFunction@/Users/dev/app/src/index.ts:42:15'; + describe("SpiderMonkey/JSC format (Firefox/Safari)", () => { + it("parses stack frame with function name", () => { + const frame = "myFunction@/Users/dev/app/src/index.ts:42:15"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/index.ts', + sourceFile: "/Users/dev/app/src/index.ts", lineNumber: 42, }); }); - it('parses stack frame with anonymous function', () => { - const frame = '@/Users/dev/app/src/index.ts:42:15'; + it("parses stack frame with anonymous function", () => { + const frame = "@/Users/dev/app/src/index.ts:42:15"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '/Users/dev/app/src/index.ts', + sourceFile: "/Users/dev/app/src/index.ts", lineNumber: 42, }); }); }); - describe('Windows paths', () => { - it('parses Windows path with drive letter', () => { - const frame = ' at myFunction (C:\\Users\\dev\\app\\src\\index.ts:42:15)'; + describe("Windows paths", () => { + it("parses Windows path with drive letter", () => { + const frame = " at myFunction (C:\\Users\\dev\\app\\src\\index.ts:42:15)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: 'C:\\Users\\dev\\app\\src\\index.ts', + sourceFile: "C:\\Users\\dev\\app\\src\\index.ts", lineNumber: 42, }); }); - it('parses Windows UNC path', () => { - const frame = ' at myFunction (\\\\server\\share\\src\\index.ts:42:15)'; + it("parses Windows UNC path", () => { + const frame = " at myFunction (\\\\server\\share\\src\\index.ts:42:15)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: '\\\\server\\share\\src\\index.ts', + sourceFile: "\\\\server\\share\\src\\index.ts", lineNumber: 42, }); }); }); - describe('bundler paths', () => { - it('parses webpack bundled path', () => { - const frame = ' at myFunction (webpack:///src/index.ts:42:15)'; + describe("bundler paths", () => { + it("parses webpack bundled path", () => { + const frame = " at myFunction (webpack:///src/index.ts:42:15)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: 'webpack:///src/index.ts', + sourceFile: "webpack:///src/index.ts", lineNumber: 42, }); }); - it('parses file:// protocol path', () => { - const frame = ' at myFunction (file:///Users/dev/app/src/index.ts:42:15)'; + it("parses file:// protocol path", () => { + const frame = " at myFunction (file:///Users/dev/app/src/index.ts:42:15)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: 'file:///Users/dev/app/src/index.ts', + sourceFile: "file:///Users/dev/app/src/index.ts", lineNumber: 42, }); }); - it('parses http/https paths', () => { - const frame = ' at myFunction (https://cdn.example.com/bundle.js:1:2345)'; + it("parses http/https paths", () => { + const frame = " at myFunction (https://cdn.example.com/bundle.js:1:2345)"; const result = parseStackFrame(frame); expect(result).toEqual({ - sourceFile: 'https://cdn.example.com/bundle.js', + sourceFile: "https://cdn.example.com/bundle.js", lineNumber: 1, }); }); }); - describe('invalid input', () => { - it('returns undefined for empty string', () => { - expect(parseStackFrame('')).toBeUndefined(); + describe("invalid input", () => { + it("returns undefined for empty string", () => { + expect(parseStackFrame("")).toBeUndefined(); }); - it('returns undefined for random garbage', () => { - expect(parseStackFrame('random garbage')).toBeUndefined(); + it("returns undefined for random garbage", () => { + expect(parseStackFrame("random garbage")).toBeUndefined(); }); - it('returns undefined for Error message line', () => { - expect(parseStackFrame('Error: something went wrong')).toBeUndefined(); + it("returns undefined for Error message line", () => { + expect(parseStackFrame("Error: something went wrong")).toBeUndefined(); }); - it('returns undefined for stack frame without line number', () => { - expect(parseStackFrame(' at myFunction (/path/to/file.ts)')).toBeUndefined(); + it("returns undefined for stack frame without line number", () => { + expect(parseStackFrame(" at myFunction (/path/to/file.ts)")).toBeUndefined(); }); }); }); -describe('captureSourceLocation', () => { - it('captures source location from current call site', () => { +describe("captureSourceLocation", () => { + it("captures source location from current call site", () => { const result = captureSourceLocation(0); expect(result).toBeDefined(); - expect(result?.sourceFile).toContain('source-location.unit.test.ts'); - expect(typeof result?.lineNumber).toBe('number'); + expect(result?.sourceFile).toContain("source-location.unit.test.ts"); + expect(typeof result?.lineNumber).toBe("number"); expect(result?.lineNumber).toBeGreaterThan(0); }); - it('skips correct number of frames', () => { + it("skips correct number of frames", () => { function wrapper() { return captureSourceLocation(1); // Skip wrapper, get caller } const result = wrapper(); expect(result).toBeDefined(); - expect(result?.sourceFile).toContain('source-location.unit.test.ts'); + expect(result?.sourceFile).toContain("source-location.unit.test.ts"); }); - it('skips multiple frames correctly', () => { + it("skips multiple frames correctly", () => { function innerWrapper() { return captureSourceLocation(2); // Skip innerWrapper and outerWrapper } @@ -182,10 +182,10 @@ describe('captureSourceLocation', () => { } const result = outerWrapper(); expect(result).toBeDefined(); - expect(result?.sourceFile).toContain('source-location.unit.test.ts'); + expect(result?.sourceFile).toContain("source-location.unit.test.ts"); }); - it('returns undefined when skipFrames exceeds stack depth', () => { + it("returns undefined when skipFrames exceeds stack depth", () => { const result = captureSourceLocation(1000); expect(result).toBeUndefined(); }); diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts index 76ad59e..b069f61 100644 --- a/sdks/typescript/tsup.config.ts +++ b/sdks/typescript/tsup.config.ts @@ -1,20 +1,20 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from "tsup"; export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm', 'cjs'], + entry: ["src/index.ts"], + format: ["esm", "cjs"], dts: true, sourcemap: true, clean: true, minify: true, treeshake: true, splitting: false, - target: 'es2022', - outDir: 'dist', + target: "es2022", + outDir: "dist", cjsInterop: true, shims: false, external: [], esbuildOptions(options) { - options.drop = ['debugger']; + options.drop = ["debugger"]; }, }); diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts index 3145d12..b21306c 100644 --- a/sdks/typescript/vitest.config.ts +++ b/sdks/typescript/vitest.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vite-plus"; export default defineConfig({ test: { globals: true, - environment: 'node', - include: ['tests/**/*.test.ts'], - setupFiles: ['./tests/setup.ts'], + environment: "node", + include: ["tests/**/*.test.ts"], + setupFiles: ["./tests/setup.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], - include: ['src/**/*.ts'], - exclude: ['src/**/*.d.ts', 'src/index.ts'], + provider: "v8", + reporter: ["text", "json", "html", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts", "src/index.ts"], thresholds: { lines: 90, branches: 85, diff --git a/src/app.d.ts b/src/app.d.ts index 3daac44..b7cfb08 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,10 +4,10 @@ declare global { namespace App { // interface Error {} interface Locals { - user?: import('./lib/server/auth').User; - session?: import('./lib/server/auth').Session; + user?: import("./lib/server/auth").User; + session?: import("./lib/server/auth").Session; // Optional db client for testing (dependency injection) - db?: import('./lib/server/db/db').DatabaseClient; + db?: import("./lib/server/db/db").DatabaseClient; } // interface PageData {} // interface PageState {} diff --git a/src/app.html b/src/app.html index 3e117eb..fd51a93 100644 --- a/src/app.html +++ b/src/app.html @@ -1,34 +1,43 @@ - - - + + + - - - + + + - - - + + + - - + + - - - - - + + + + + - - - - + + + + - %sveltekit.head% - - -
%sveltekit.body%
- + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index 0d223ca..d520031 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -1,12 +1,12 @@ -import type { RequestEvent } from '@sveltejs/kit'; -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { createAuth } from './lib/server/auth'; -import type * as schema from './lib/server/db/schema'; -import { setupTestDatabase } from './lib/server/db/test-db'; -import { getSession } from './lib/server/session'; - -describe('Server Hooks - Authentication', () => { +import type { RequestEvent } from "@sveltejs/kit"; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { createAuth } from "./lib/server/auth"; +import type * as schema from "./lib/server/db/schema"; +import { setupTestDatabase } from "./lib/server/db/test-db"; +import { getSession } from "./lib/server/session"; + +describe("Server Hooks - Authentication", () => { let db: PgliteDatabase; let auth: ReturnType; @@ -16,12 +16,12 @@ describe('Server Hooks - Authentication', () => { auth = createAuth(db); }); - describe('Session handling', () => { - it('should populate event.locals.user for valid session', async () => { + describe("Session handling", () => { + it("should populate event.locals.user for valid session", async () => { // Create test user and get session - const email = 'hook-test@example.com'; - const password = 'SecureP@ssw0rd123'; - const name = 'Hook Test User'; + const email = "hook-test@example.com"; + const password = "SecureP@ssw0rd123"; + const name = "Hook Test User"; const signUpResult = await auth.api.signUpEmail({ body: { @@ -32,7 +32,7 @@ describe('Server Hooks - Authentication', () => { }); // Simulate SvelteKit request event with session cookie - const mockRequest = new Request('http://localhost:5173/test', { + const mockRequest = new Request("http://localhost:5173/test", { headers: { cookie: `better-auth.session_token=${signUpResult.token}`, }, @@ -41,9 +41,9 @@ describe('Server Hooks - Authentication', () => { const mockEvent = { request: mockRequest, locals: {}, - url: new URL('http://localhost:5173/test'), + url: new URL("http://localhost:5173/test"), params: {}, - route: { id: '/test' }, + route: { id: "/test" }, } as unknown as RequestEvent; // Test the session logic using getSession helper @@ -62,20 +62,20 @@ describe('Server Hooks - Authentication', () => { expect(mockEvent.locals.session?.userId).toBe(signUpResult.user.id); }); - it('should set locals to undefined for invalid session', async () => { + it("should set locals to undefined for invalid session", async () => { // Simulate request with invalid/expired token - const mockRequest = new Request('http://localhost:5173/test', { + const mockRequest = new Request("http://localhost:5173/test", { headers: { - cookie: 'better-auth.session_token=invalid-token-12345', + cookie: "better-auth.session_token=invalid-token-12345", }, }); const mockEvent = { request: mockRequest, locals: {}, - url: new URL('http://localhost:5173/test'), + url: new URL("http://localhost:5173/test"), params: {}, - route: { id: '/test' }, + route: { id: "/test" }, } as unknown as RequestEvent; // Test session logic @@ -91,16 +91,16 @@ describe('Server Hooks - Authentication', () => { expect(mockEvent.locals.session).toBeUndefined(); }); - it('should set locals to undefined for missing session', async () => { + it("should set locals to undefined for missing session", async () => { // Simulate request without session cookie - const mockRequest = new Request('http://localhost:5173/test'); + const mockRequest = new Request("http://localhost:5173/test"); const mockEvent = { request: mockRequest, locals: {}, - url: new URL('http://localhost:5173/test'), + url: new URL("http://localhost:5173/test"), params: {}, - route: { id: '/test' }, + route: { id: "/test" }, } as unknown as RequestEvent; // Test session logic @@ -117,26 +117,26 @@ describe('Server Hooks - Authentication', () => { }); }); - describe('Better-auth API route handling', () => { - it('should allow better-auth routes to pass through', async () => { + describe("Better-auth API route handling", () => { + it("should allow better-auth routes to pass through", async () => { // Simulate request to better-auth API route - const mockRequest = new Request('http://localhost:5173/api/auth/sign-in', { - method: 'POST', + const mockRequest = new Request("http://localhost:5173/api/auth/sign-in", { + method: "POST", headers: { - 'content-type': 'application/json', + "content-type": "application/json", }, }); const mockEvent = { request: mockRequest, locals: {}, - url: new URL('http://localhost:5173/api/auth/sign-in'), + url: new URL("http://localhost:5173/api/auth/sign-in"), params: {}, - route: { id: '/api/auth/[...all]' }, + route: { id: "/api/auth/[...all]" }, } as unknown as RequestEvent; const mockResolve = async (_event: RequestEvent) => { - return new Response('AUTH_RESPONSE'); + return new Response("AUTH_RESPONSE"); }; // Simulate the handle hook logic @@ -151,16 +151,16 @@ describe('Server Hooks - Authentication', () => { // Verify the response was returned (not intercepted) expect(response).toBeDefined(); - expect(await response.text()).toBe('AUTH_RESPONSE'); + expect(await response.text()).toBe("AUTH_RESPONSE"); }); }); - describe('Session persistence across requests', () => { - it('should maintain session data across multiple requests', async () => { + describe("Session persistence across requests", () => { + it("should maintain session data across multiple requests", async () => { // Create user and session - const email = 'persistent@example.com'; - const password = 'SecureP@ssw0rd123'; - const name = 'Persistent User'; + const email = "persistent@example.com"; + const password = "SecureP@ssw0rd123"; + const name = "Persistent User"; const signUpResult = await auth.api.signUpEmail({ body: { @@ -171,7 +171,7 @@ describe('Server Hooks - Authentication', () => { }); // First request - const mockRequest1 = new Request('http://localhost:5173/page1', { + const mockRequest1 = new Request("http://localhost:5173/page1", { headers: { cookie: `better-auth.session_token=${signUpResult.token}`, }, @@ -180,9 +180,9 @@ describe('Server Hooks - Authentication', () => { const mockEvent1 = { request: mockRequest1, locals: {}, - url: new URL('http://localhost:5173/page1'), + url: new URL("http://localhost:5173/page1"), params: {}, - route: { id: '/page1' }, + route: { id: "/page1" }, } as unknown as RequestEvent; const sessionData1 = await getSession(mockRequest1.headers, db); @@ -193,7 +193,7 @@ describe('Server Hooks - Authentication', () => { } // Second request with same session token - const mockRequest2 = new Request('http://localhost:5173/page2', { + const mockRequest2 = new Request("http://localhost:5173/page2", { headers: { cookie: `better-auth.session_token=${signUpResult.token}`, }, @@ -202,9 +202,9 @@ describe('Server Hooks - Authentication', () => { const mockEvent2 = { request: mockRequest2, locals: {}, - url: new URL('http://localhost:5173/page2'), + url: new URL("http://localhost:5173/page2"), params: {}, - route: { id: '/page2' }, + route: { id: "/page2" }, } as unknown as RequestEvent; const sessionData2 = await getSession(mockRequest2.headers, db); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 22a88c5..bd7004c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,10 @@ -import type { Handle, HandleServerError } from '@sveltejs/kit'; -import { svelteKitHandler } from 'better-auth/svelte-kit'; -import { building } from '$app/environment'; -import { auth, initAuth } from '$lib/server/auth'; -import { db } from '$lib/server/db'; -import { createErrorHandler } from '$lib/server/error-handler'; -import { startCleanupScheduler, stopCleanupScheduler } from '$lib/server/jobs/cleanup-scheduler'; +import type { Handle, HandleServerError } from "@sveltejs/kit"; +import { svelteKitHandler } from "better-auth/svelte-kit"; +import { building } from "$app/environment"; +import { auth, initAuth } from "$lib/server/auth"; +import { db } from "$lib/server/db"; +import { createErrorHandler } from "$lib/server/error-handler"; +import { startCleanupScheduler, stopCleanupScheduler } from "$lib/server/jobs/cleanup-scheduler"; // Initialize on server startup let initialized = false; @@ -32,8 +32,8 @@ function gracefulShutdown(signal: string) { // Give in-flight requests ~5s then exit setTimeout(() => process.exit(0), 5000); } -process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); -process.once('SIGINT', () => gracefulShutdown('SIGINT')); +process.once("SIGTERM", () => gracefulShutdown("SIGTERM")); +process.once("SIGINT", () => gracefulShutdown("SIGINT")); /** * Combined SvelteKit handle hook for better-auth @@ -55,9 +55,9 @@ export const handle: Handle = async ({ event, resolve }) => { // Skip session lookup for paths that never need auth const pathname = event.url.pathname; if ( - pathname.startsWith('/v1/') || - pathname === '/api/health' || - pathname.startsWith('/static/') + pathname.startsWith("/v1/") || + pathname === "/api/health" || + pathname.startsWith("/static/") ) { return resolve(event); } @@ -90,7 +90,7 @@ export const handleError: HandleServerError = ({ error, event, status, message } error, url: event.url.href, method: event.request.method, - route: event.route?.id ?? 'unknown', + route: event.route?.id ?? "unknown", status, message, }); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index fe780db..cd2db82 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,5 +1,5 @@ -import { usernameClient } from 'better-auth/client/plugins'; -import { createAuthClient } from 'better-auth/svelte'; +import { usernameClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/svelte"; /** * Client-side auth helper for better-auth diff --git a/src/lib/components/__tests__/accessibility.component.test.ts b/src/lib/components/__tests__/accessibility.component.test.ts index 2ed110f..739034c 100644 --- a/src/lib/components/__tests__/accessibility.component.test.ts +++ b/src/lib/components/__tests__/accessibility.component.test.ts @@ -1,8 +1,8 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Log } from '$lib/server/db/schema'; -import CreateProjectModal from '../create-project-modal.svelte'; -import LogDetailModal from '../log-detail-modal.svelte'; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Log } from "$lib/server/db/schema"; +import CreateProjectModal from "../create-project-modal.svelte"; +import LogDetailModal from "../log-detail-modal.svelte"; // Mock clipboard API const mockClipboard = { @@ -11,28 +11,28 @@ const mockClipboard = { Object.assign(navigator, { clipboard: mockClipboard }); // Mock toast functions -vi.mock('$lib/utils/toast', () => ({ +vi.mock("$lib/utils/toast", () => ({ toastSuccess: vi.fn(), toastError: vi.fn(), })); // Mock formatFullDate utility -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatFullDate: vi.fn((date: Date) => { - return date.toISOString().replace('T', ' ').replace('Z', ' UTC'); + return date.toISOString().replace("T", " ").replace("Z", " UTC"); }), })); -describe('Accessibility: Modal Focus Management', () => { +describe("Accessibility: Modal Focus Management", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'Test log message', - metadata: { test: 'data' }, + level: "info", + message: "Test log message", + metadata: { test: "data" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -50,13 +50,13 @@ describe('Accessibility: Modal Focus Management', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'test.ts', + sourceFile: "test.ts", lineNumber: 10, - requestId: 'req_abc', - userId: 'user_123', - ipAddress: '127.0.0.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_123", + ipAddress: "127.0.0.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; beforeEach(() => { @@ -68,11 +68,11 @@ describe('Accessibility: Modal Focus Management', () => { vi.clearAllMocks(); }); - describe('LogDetailModal Focus Management', () => { - it('traps focus within the modal when open', async () => { + describe("LogDetailModal Focus Management", () => { + it("traps focus within the modal when open", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const modal = screen.getByRole('dialog'); + const modal = screen.getByRole("dialog"); const focusableElements = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); @@ -85,17 +85,17 @@ describe('Accessibility: Modal Focus Management', () => { // Focus the last element and press Tab - should wrap to first lastFocusable.focus(); - await fireEvent.keyDown(modal, { key: 'Tab' }); + await fireEvent.keyDown(modal, { key: "Tab" }); // The focus trap should cycle - verify the trap is active by checking elements exist expect(firstFocusable).toBeInTheDocument(); expect(lastFocusable).toBeInTheDocument(); }); - it('traps focus when shift+tabbing from first element', async () => { + it("traps focus when shift+tabbing from first element", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const modal = screen.getByRole('dialog'); + const modal = screen.getByRole("dialog"); const focusableElements = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); @@ -105,30 +105,30 @@ describe('Accessibility: Modal Focus Management', () => { // Focus the first element and shift+tab - should wrap to last firstFocusable.focus(); - await fireEvent.keyDown(modal, { key: 'Tab', shiftKey: true }); + await fireEvent.keyDown(modal, { key: "Tab", shiftKey: true }); // The focus trap should cycle - verify the trap is active expect(firstFocusable).toBeInTheDocument(); expect(lastFocusable).toBeInTheDocument(); }); - it('close button is focusable when modal opens', async () => { + it("close button is focusable when modal opens", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); // Verify the close button is present and can be focused expect(closeButton).toBeInTheDocument(); - expect(closeButton).toHaveAttribute('aria-label', 'Close log details'); + expect(closeButton).toHaveAttribute("aria-label", "Close log details"); // Manually focus and verify it works closeButton.focus(); expect(document.activeElement).toBe(closeButton); }); - it('restores focus to trigger element when modal closes', async () => { + it("restores focus to trigger element when modal closes", async () => { // Create a trigger button - const triggerButton = document.createElement('button'); - triggerButton.id = 'trigger'; - triggerButton.textContent = 'Open Modal'; + const triggerButton = document.createElement("button"); + triggerButton.id = "trigger"; + triggerButton.textContent = "Open Modal"; document.body.appendChild(triggerButton); triggerButton.focus(); @@ -154,11 +154,11 @@ describe('Accessibility: Modal Focus Management', () => { }); }); - describe('CreateProjectModal Focus Management', () => { - it('traps focus within the modal when open', async () => { + describe("CreateProjectModal Focus Management", () => { + it("traps focus within the modal when open", async () => { render(CreateProjectModal, { props: { open: true } }); - const modal = screen.getByRole('dialog'); + const modal = screen.getByRole("dialog"); const focusableElements = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); @@ -169,28 +169,28 @@ describe('Accessibility: Modal Focus Management', () => { const firstFocusable = focusableElements[0] as HTMLElement; lastFocusable.focus(); - await fireEvent.keyDown(modal, { key: 'Tab' }); + await fireEvent.keyDown(modal, { key: "Tab" }); // Verify focus trap is set up expect(firstFocusable).toBeInTheDocument(); expect(lastFocusable).toBeInTheDocument(); }); - it('name input is focusable when modal opens', async () => { + it("name input is focusable when modal opens", async () => { render(CreateProjectModal, { props: { open: true } }); const nameInput = screen.getByLabelText(/name/i); // Verify the input is present and can be focused expect(nameInput).toBeInTheDocument(); - expect(nameInput).toHaveAttribute('id', 'project-name'); + expect(nameInput).toHaveAttribute("id", "project-name"); // Manually focus and verify it works nameInput.focus(); expect(document.activeElement).toBe(nameInput); }); - it('restores focus to trigger element when modal closes', async () => { - const triggerButton = document.createElement('button'); - triggerButton.id = 'trigger'; + it("restores focus to trigger element when modal closes", async () => { + const triggerButton = document.createElement("button"); + triggerButton.id = "trigger"; document.body.appendChild(triggerButton); triggerButton.focus(); @@ -215,16 +215,16 @@ describe('Accessibility: Modal Focus Management', () => { }); }); -describe('Accessibility: ARIA Labels', () => { +describe("Accessibility: ARIA Labels", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'Test log message', - metadata: { test: 'data' }, + level: "info", + message: "Test log message", + metadata: { test: "data" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -242,83 +242,83 @@ describe('Accessibility: ARIA Labels', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'test.ts', + sourceFile: "test.ts", lineNumber: 10, - requestId: 'req_abc', - userId: 'user_123', - ipAddress: '127.0.0.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_123", + ipAddress: "127.0.0.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; afterEach(() => { cleanup(); }); - describe('LogDetailModal ARIA Labels', () => { - it('copy ID button has descriptive aria-label', () => { + describe("LogDetailModal ARIA Labels", () => { + it("copy ID button has descriptive aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyIdButton = screen.getByTestId('copy-id-button'); - expect(copyIdButton).toHaveAttribute('aria-label', 'Copy log ID to clipboard'); + const copyIdButton = screen.getByTestId("copy-id-button"); + expect(copyIdButton).toHaveAttribute("aria-label", "Copy log ID to clipboard"); }); - it('copy message button has descriptive aria-label', () => { + it("copy message button has descriptive aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyMessageButton = screen.getByTestId('copy-message-button'); - expect(copyMessageButton).toHaveAttribute('aria-label', 'Copy message to clipboard'); + const copyMessageButton = screen.getByTestId("copy-message-button"); + expect(copyMessageButton).toHaveAttribute("aria-label", "Copy message to clipboard"); }); - it('copy metadata button has descriptive aria-label', () => { + it("copy metadata button has descriptive aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyMetadataButton = screen.getByTestId('copy-metadata-button'); - expect(copyMetadataButton).toHaveAttribute('aria-label', 'Copy metadata to clipboard'); + const copyMetadataButton = screen.getByTestId("copy-metadata-button"); + expect(copyMetadataButton).toHaveAttribute("aria-label", "Copy metadata to clipboard"); }); - it('copy request ID button has descriptive aria-label', () => { + it("copy request ID button has descriptive aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyRequestIdButton = screen.getByTestId('copy-request-id-button'); - expect(copyRequestIdButton).toHaveAttribute('aria-label', 'Copy request ID to clipboard'); + const copyRequestIdButton = screen.getByTestId("copy-request-id-button"); + expect(copyRequestIdButton).toHaveAttribute("aria-label", "Copy request ID to clipboard"); }); - it('close button has descriptive aria-label', () => { + it("close button has descriptive aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const closeButton = screen.getByTestId('close-button'); - expect(closeButton).toHaveAttribute('aria-label', 'Close log details'); + const closeButton = screen.getByTestId("close-button"); + expect(closeButton).toHaveAttribute("aria-label", "Close log details"); }); - it('metadata section has aria-label', () => { + it("metadata section has aria-label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const metadataSection = screen.getByTestId('metadata-section'); - expect(metadataSection).toHaveAttribute('aria-label', 'Log metadata'); + const metadataSection = screen.getByTestId("metadata-section"); + expect(metadataSection).toHaveAttribute("aria-label", "Log metadata"); }); }); - describe('CreateProjectModal ARIA Labels', () => { - it('close button has descriptive aria-label', () => { + describe("CreateProjectModal ARIA Labels", () => { + it("close button has descriptive aria-label", () => { render(CreateProjectModal, { props: { open: true } }); - const closeButton = screen.getByTestId('close-button'); - expect(closeButton).toHaveAttribute('aria-label', 'Close create project dialog'); + const closeButton = screen.getByTestId("close-button"); + expect(closeButton).toHaveAttribute("aria-label", "Close create project dialog"); }); }); }); -describe('Accessibility: Keyboard Navigation', () => { +describe("Accessibility: Keyboard Navigation", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'Test log message', - metadata: { test: 'data' }, + level: "info", + message: "Test log message", + metadata: { test: "data" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -336,78 +336,78 @@ describe('Accessibility: Keyboard Navigation', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'test.ts', + sourceFile: "test.ts", lineNumber: 10, - requestId: 'req_abc', - userId: 'user_123', - ipAddress: '127.0.0.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_123", + ipAddress: "127.0.0.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; afterEach(() => { cleanup(); }); - describe('Modal keyboard interactions', () => { - it('all interactive elements are reachable via Tab', async () => { + describe("Modal keyboard interactions", () => { + it("all interactive elements are reachable via Tab", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const modal = screen.getByRole('dialog'); - const buttons = modal.querySelectorAll('button'); + const modal = screen.getByRole("dialog"); + const buttons = modal.querySelectorAll("button"); // Verify all buttons are focusable expect(buttons.length).toBeGreaterThan(0); buttons.forEach((button) => { - expect(button).not.toHaveAttribute('tabindex', '-1'); + expect(button).not.toHaveAttribute("tabindex", "-1"); }); }); - it('copy buttons are properly labeled for activation', () => { + it("copy buttons are properly labeled for activation", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyIdButton = screen.getByTestId('copy-id-button'); + const copyIdButton = screen.getByTestId("copy-id-button"); // Verify button is properly accessible - expect(copyIdButton).toHaveAttribute('type', 'button'); - expect(copyIdButton).toHaveAttribute('aria-label'); - expect(copyIdButton.getAttribute('aria-label')).toContain('clipboard'); + expect(copyIdButton).toHaveAttribute("type", "button"); + expect(copyIdButton).toHaveAttribute("aria-label"); + expect(copyIdButton.getAttribute("aria-label")).toContain("clipboard"); }); - it('close button has visible focus indicator', () => { + it("close button has visible focus indicator", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); // Focus-visible classes indicate proper focus styling expect(closeButton.className).toMatch(/focus:ring|focus:outline|focus-visible/); }); }); }); -describe('Accessibility: Live Regions', () => { +describe("Accessibility: Live Regions", () => { afterEach(() => { cleanup(); // Clean up any live regions created - const liveRegion = document.getElementById('sr-announcer'); + const liveRegion = document.getElementById("sr-announcer"); if (liveRegion) { liveRegion.remove(); } }); - it('announceToScreenReader creates accessible live region', async () => { + it("announceToScreenReader creates accessible live region", async () => { // Import the function directly to test it - const { announceToScreenReader } = await import('$lib/utils/focus-trap'); + const { announceToScreenReader } = await import("$lib/utils/focus-trap"); // Call the announce function - announceToScreenReader('Test announcement'); + announceToScreenReader("Test announcement"); // Wait for the live region to be created await waitFor( () => { - const liveRegion = document.getElementById('sr-announcer'); + const liveRegion = document.getElementById("sr-announcer"); expect(liveRegion).toBeInTheDocument(); - expect(liveRegion).toHaveAttribute('aria-live'); - expect(liveRegion).toHaveAttribute('aria-atomic', 'true'); - expect(liveRegion).toHaveClass('sr-only'); + expect(liveRegion).toHaveAttribute("aria-live"); + expect(liveRegion).toHaveAttribute("aria-atomic", "true"); + expect(liveRegion).toHaveClass("sr-only"); }, { timeout: 100 }, ); diff --git a/src/lib/components/__tests__/active-filter-chips.component.test.ts b/src/lib/components/__tests__/active-filter-chips.component.test.ts index 1efd817..99d4905 100644 --- a/src/lib/components/__tests__/active-filter-chips.component.test.ts +++ b/src/lib/components/__tests__/active-filter-chips.component.test.ts @@ -1,69 +1,69 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import ActiveFilterChips from '../active-filter-chips.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import ActiveFilterChips from "../active-filter-chips.svelte"; -describe('ActiveFilterChips', () => { +describe("ActiveFilterChips", () => { afterEach(() => { cleanup(); }); - it('renders nothing when no filters are active', () => { - render(ActiveFilterChips, { props: { levels: [], search: '', range: '1h' } }); - expect(screen.queryByTestId('active-filter-chips')).not.toBeInTheDocument(); + it("renders nothing when no filters are active", () => { + render(ActiveFilterChips, { props: { levels: [], search: "", range: "1h" } }); + expect(screen.queryByTestId("active-filter-chips")).not.toBeInTheDocument(); }); - it('renders level chips for selected levels', () => { - render(ActiveFilterChips, { props: { levels: ['error', 'warn'], search: '', range: '1h' } }); - expect(screen.getByTestId('filter-chip-level-error')).toBeInTheDocument(); - expect(screen.getByTestId('filter-chip-level-warn')).toBeInTheDocument(); + it("renders level chips for selected levels", () => { + render(ActiveFilterChips, { props: { levels: ["error", "warn"], search: "", range: "1h" } }); + expect(screen.getByTestId("filter-chip-level-error")).toBeInTheDocument(); + expect(screen.getByTestId("filter-chip-level-warn")).toBeInTheDocument(); }); - it('renders search chip when search is set', () => { - render(ActiveFilterChips, { props: { levels: [], search: 'test query', range: '1h' } }); - expect(screen.getByTestId('filter-chip-search')).toBeInTheDocument(); + it("renders search chip when search is set", () => { + render(ActiveFilterChips, { props: { levels: [], search: "test query", range: "1h" } }); + expect(screen.getByTestId("filter-chip-search")).toBeInTheDocument(); expect(screen.getByText(/"test query"/)).toBeInTheDocument(); }); - it('renders range chip when range differs from default', () => { - render(ActiveFilterChips, { props: { levels: [], search: '', range: '24h' } }); - expect(screen.getByTestId('filter-chip-range')).toBeInTheDocument(); - expect(screen.getByText('24h')).toBeInTheDocument(); + it("renders range chip when range differs from default", () => { + render(ActiveFilterChips, { props: { levels: [], search: "", range: "24h" } }); + expect(screen.getByTestId("filter-chip-range")).toBeInTheDocument(); + expect(screen.getByText("24h")).toBeInTheDocument(); }); - it('does not render range chip when range equals default', () => { + it("does not render range chip when range equals default", () => { render(ActiveFilterChips, { - props: { levels: [], search: '', range: '1h', defaultRange: '1h' }, + props: { levels: [], search: "", range: "1h", defaultRange: "1h" }, }); - expect(screen.queryByTestId('filter-chip-range')).not.toBeInTheDocument(); + expect(screen.queryByTestId("filter-chip-range")).not.toBeInTheDocument(); }); - it('calls onRemoveLevel when level chip is clicked', async () => { + it("calls onRemoveLevel when level chip is clicked", async () => { const onRemoveLevel = vi.fn(); render(ActiveFilterChips, { - props: { levels: ['error'], search: '', range: '1h', onRemoveLevel }, + props: { levels: ["error"], search: "", range: "1h", onRemoveLevel }, }); - await screen.getByTestId('filter-chip-level-error').click(); - expect(onRemoveLevel).toHaveBeenCalledWith('error'); + await screen.getByTestId("filter-chip-level-error").click(); + expect(onRemoveLevel).toHaveBeenCalledWith("error"); }); - it('calls onRemoveSearch when search chip is clicked', async () => { + it("calls onRemoveSearch when search chip is clicked", async () => { const onRemoveSearch = vi.fn(); render(ActiveFilterChips, { - props: { levels: [], search: 'test', range: '1h', onRemoveSearch }, + props: { levels: [], search: "test", range: "1h", onRemoveSearch }, }); - await screen.getByTestId('filter-chip-search').click(); + await screen.getByTestId("filter-chip-search").click(); expect(onRemoveSearch).toHaveBeenCalled(); }); - it('calls onRemoveRange when range chip is clicked', async () => { + it("calls onRemoveRange when range chip is clicked", async () => { const onRemoveRange = vi.fn(); - render(ActiveFilterChips, { props: { levels: [], search: '', range: '24h', onRemoveRange } }); - await screen.getByTestId('filter-chip-range').click(); + render(ActiveFilterChips, { props: { levels: [], search: "", range: "24h", onRemoveRange } }); + await screen.getByTestId("filter-chip-range").click(); expect(onRemoveRange).toHaveBeenCalled(); }); - it('has accessible labels on all chips', () => { - render(ActiveFilterChips, { props: { levels: ['error'], search: 'test', range: '24h' } }); + it("has accessible labels on all chips", () => { + render(ActiveFilterChips, { props: { levels: ["error"], search: "test", range: "24h" } }); expect(screen.getByLabelText(/remove error filter/i)).toBeInTheDocument(); expect(screen.getByLabelText(/remove search filter/i)).toBeInTheDocument(); expect(screen.getByLabelText(/remove time range filter/i)).toBeInTheDocument(); diff --git a/src/lib/components/__tests__/clear-filters-button.component.test.ts b/src/lib/components/__tests__/clear-filters-button.component.test.ts index 909529f..d912be9 100644 --- a/src/lib/components/__tests__/clear-filters-button.component.test.ts +++ b/src/lib/components/__tests__/clear-filters-button.component.test.ts @@ -1,87 +1,87 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import ClearFiltersButton from '../clear-filters-button.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import ClearFiltersButton from "../clear-filters-button.svelte"; -describe('ClearFiltersButton', () => { +describe("ClearFiltersButton", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - describe('visibility', () => { - it('renders when visible is true', () => { + describe("visibility", () => { + it("renders when visible is true", () => { render(ClearFiltersButton, { props: { visible: true } }); - expect(screen.getByTestId('clear-filters-button')).toBeInTheDocument(); + expect(screen.getByTestId("clear-filters-button")).toBeInTheDocument(); }); - it('does not render when visible is false', () => { + it("does not render when visible is false", () => { render(ClearFiltersButton, { props: { visible: false } }); - expect(screen.queryByTestId('clear-filters-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId("clear-filters-button")).not.toBeInTheDocument(); }); - it('shows Clear text when visible', () => { + it("shows Clear text when visible", () => { render(ClearFiltersButton, { props: { visible: true } }); - expect(screen.getByText('Clear')).toBeInTheDocument(); + expect(screen.getByText("Clear")).toBeInTheDocument(); }); }); - describe('interaction', () => { - it('calls onclick handler when clicked', async () => { + describe("interaction", () => { + it("calls onclick handler when clicked", async () => { const user = userEvent.setup(); const onclick = vi.fn(); render(ClearFiltersButton, { props: { visible: true, onclick } }); - const button = screen.getByTestId('clear-filters-button'); + const button = screen.getByTestId("clear-filters-button"); await user.click(button); expect(onclick).toHaveBeenCalledTimes(1); }); - it('does not call onclick when not provided', async () => { + it("does not call onclick when not provided", async () => { const user = userEvent.setup(); render(ClearFiltersButton, { props: { visible: true } }); - const button = screen.getByTestId('clear-filters-button'); + const button = screen.getByTestId("clear-filters-button"); // Should not throw error await user.click(button); }); }); - describe('accessibility', () => { - it('has accessible label', () => { + describe("accessibility", () => { + it("has accessible label", () => { render(ClearFiltersButton, { props: { visible: true } }); - const button = screen.getByTestId('clear-filters-button'); - expect(button).toHaveAttribute('aria-label', 'Clear all filters'); + const button = screen.getByTestId("clear-filters-button"); + expect(button).toHaveAttribute("aria-label", "Clear all filters"); }); - it('is a button element', () => { + it("is a button element", () => { render(ClearFiltersButton, { props: { visible: true } }); - const button = screen.getByTestId('clear-filters-button'); - expect(button.tagName).toBe('BUTTON'); + const button = screen.getByTestId("clear-filters-button"); + expect(button.tagName).toBe("BUTTON"); }); }); - describe('styling', () => { - it('renders with ghost variant', () => { + describe("styling", () => { + it("renders with ghost variant", () => { render(ClearFiltersButton, { props: { visible: true } }); - const button = screen.getByTestId('clear-filters-button'); + const button = screen.getByTestId("clear-filters-button"); expect(button).toBeInTheDocument(); // Button should have ghost variant classes (we check it's rendered, actual class check is implementation detail) }); - it('renders with small size', () => { + it("renders with small size", () => { render(ClearFiltersButton, { props: { visible: true } }); - const button = screen.getByTestId('clear-filters-button'); + const button = screen.getByTestId("clear-filters-button"); expect(button).toBeInTheDocument(); // Button should have small size classes (we check it's rendered) }); diff --git a/src/lib/components/__tests__/connection-status.component.test.ts b/src/lib/components/__tests__/connection-status.component.test.ts index bd6485d..973f3da 100644 --- a/src/lib/components/__tests__/connection-status.component.test.ts +++ b/src/lib/components/__tests__/connection-status.component.test.ts @@ -1,96 +1,96 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import ConnectionStatus from '../connection-status.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import ConnectionStatus from "../connection-status.svelte"; -describe('ConnectionStatus', () => { +describe("ConnectionStatus", () => { afterEach(() => { cleanup(); }); - describe('connecting state', () => { - it('displays connecting message when isConnecting is true', () => { + describe("connecting state", () => { + it("displays connecting message when isConnecting is true", () => { render(ConnectionStatus, { props: { isConnecting: true, error: null } }); - const element = screen.getByTestId('connection-connecting'); + const element = screen.getByTestId("connection-connecting"); expect(element).toBeInTheDocument(); - expect(element).toHaveTextContent('Connecting...'); + expect(element).toHaveTextContent("Connecting..."); }); - it('applies correct styling for connecting state', () => { + it("applies correct styling for connecting state", () => { render(ConnectionStatus, { props: { isConnecting: true, error: null } }); - const element = screen.getByTestId('connection-connecting'); - expect(element).toHaveClass('text-muted-foreground'); + const element = screen.getByTestId("connection-connecting"); + expect(element).toHaveClass("text-muted-foreground"); }); - it('does not display error when connecting', () => { - render(ConnectionStatus, { props: { isConnecting: true, error: new Error('Test') } }); + it("does not display error when connecting", () => { + render(ConnectionStatus, { props: { isConnecting: true, error: new Error("Test") } }); - expect(screen.queryByTestId('connection-error')).not.toBeInTheDocument(); - expect(screen.getByTestId('connection-connecting')).toBeInTheDocument(); + expect(screen.queryByTestId("connection-error")).not.toBeInTheDocument(); + expect(screen.getByTestId("connection-connecting")).toBeInTheDocument(); }); }); - describe('error state', () => { - it('displays error message when error exists and not connecting', () => { - const error = new Error('Connection failed'); + describe("error state", () => { + it("displays error message when error exists and not connecting", () => { + const error = new Error("Connection failed"); render(ConnectionStatus, { props: { isConnecting: false, error } }); - const element = screen.getByTestId('connection-error'); + const element = screen.getByTestId("connection-error"); expect(element).toBeInTheDocument(); - expect(element).toHaveTextContent('Connection error'); + expect(element).toHaveTextContent("Connection error"); }); - it('shows error details in title attribute', () => { - const error = new Error('Network timeout'); + it("shows error details in title attribute", () => { + const error = new Error("Network timeout"); render(ConnectionStatus, { props: { isConnecting: false, error } }); - const element = screen.getByTestId('connection-error'); - expect(element).toHaveAttribute('title', 'Network timeout'); + const element = screen.getByTestId("connection-error"); + expect(element).toHaveAttribute("title", "Network timeout"); }); - it('applies correct styling for error state', () => { - const error = new Error('Failed'); + it("applies correct styling for error state", () => { + const error = new Error("Failed"); render(ConnectionStatus, { props: { isConnecting: false, error } }); - const element = screen.getByTestId('connection-error'); - expect(element).toHaveClass('text-destructive'); + const element = screen.getByTestId("connection-error"); + expect(element).toHaveClass("text-destructive"); }); }); - describe('connected state', () => { - it('renders nothing when connected successfully', () => { + describe("connected state", () => { + it("renders nothing when connected successfully", () => { render(ConnectionStatus, { props: { isConnecting: false, error: null } }); - expect(screen.queryByTestId('connection-connecting')).not.toBeInTheDocument(); - expect(screen.queryByTestId('connection-error')).not.toBeInTheDocument(); + expect(screen.queryByTestId("connection-connecting")).not.toBeInTheDocument(); + expect(screen.queryByTestId("connection-error")).not.toBeInTheDocument(); }); }); - describe('state transitions', () => { - it('transitions from connecting to connected', () => { + describe("state transitions", () => { + it("transitions from connecting to connected", () => { const { rerender } = render(ConnectionStatus, { props: { isConnecting: true, error: null }, }); - expect(screen.getByTestId('connection-connecting')).toBeInTheDocument(); + expect(screen.getByTestId("connection-connecting")).toBeInTheDocument(); rerender({ isConnecting: false, error: null }); - expect(screen.queryByTestId('connection-connecting')).not.toBeInTheDocument(); + expect(screen.queryByTestId("connection-connecting")).not.toBeInTheDocument(); }); - it('transitions from connecting to error', () => { + it("transitions from connecting to error", () => { const { rerender } = render(ConnectionStatus, { props: { isConnecting: true, error: null }, }); - expect(screen.getByTestId('connection-connecting')).toBeInTheDocument(); + expect(screen.getByTestId("connection-connecting")).toBeInTheDocument(); - rerender({ isConnecting: false, error: new Error('Failed') }); + rerender({ isConnecting: false, error: new Error("Failed") }); - expect(screen.queryByTestId('connection-connecting')).not.toBeInTheDocument(); - expect(screen.getByTestId('connection-error')).toBeInTheDocument(); + expect(screen.queryByTestId("connection-connecting")).not.toBeInTheDocument(); + expect(screen.getByTestId("connection-error")).toBeInTheDocument(); }); }); }); diff --git a/src/lib/components/__tests__/dashboard-skeleton.component.test.ts b/src/lib/components/__tests__/dashboard-skeleton.component.test.ts index 9178a0a..78d6daa 100644 --- a/src/lib/components/__tests__/dashboard-skeleton.component.test.ts +++ b/src/lib/components/__tests__/dashboard-skeleton.component.test.ts @@ -1,100 +1,100 @@ -import { cleanup, render, screen, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import DashboardSkeleton from '../dashboard-skeleton.svelte'; +import { cleanup, render, screen, within } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import DashboardSkeleton from "../dashboard-skeleton.svelte"; -describe('DashboardSkeleton', () => { +describe("DashboardSkeleton", () => { afterEach(() => { cleanup(); }); - describe('structure and layout', () => { - it('renders skeleton container', () => { + describe("structure and layout", () => { + it("renders skeleton container", () => { render(DashboardSkeleton); - expect(screen.getByTestId('dashboard-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-skeleton")).toBeInTheDocument(); }); - it('renders header skeleton with title placeholder', () => { + it("renders header skeleton with title placeholder", () => { render(DashboardSkeleton); - expect(screen.getByTestId('dashboard-skeleton-header')).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-skeleton-header")).toBeInTheDocument(); }); - it('renders button skeleton in header', () => { + it("renders button skeleton in header", () => { render(DashboardSkeleton); - const header = screen.getByTestId('dashboard-skeleton-header'); - expect(within(header).getByTestId('skeleton-button')).toBeInTheDocument(); + const header = screen.getByTestId("dashboard-skeleton-header"); + expect(within(header).getByTestId("skeleton-button")).toBeInTheDocument(); }); }); - describe('project card skeletons', () => { - it('renders skeleton cards grid', () => { + describe("project card skeletons", () => { + it("renders skeleton cards grid", () => { render(DashboardSkeleton); - expect(screen.getByTestId('dashboard-skeleton-grid')).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-skeleton-grid")).toBeInTheDocument(); }); - it('renders multiple project card skeletons', () => { + it("renders multiple project card skeletons", () => { render(DashboardSkeleton); - const cards = screen.getAllByTestId('project-card-skeleton'); + const cards = screen.getAllByTestId("project-card-skeleton"); expect(cards.length).toBeGreaterThanOrEqual(4); }); - it('card skeleton has title placeholder', () => { + it("card skeleton has title placeholder", () => { render(DashboardSkeleton); - const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]!).getByTestId('skeleton-title')).toBeInTheDocument(); + const cards = screen.getAllByTestId("project-card-skeleton"); + expect(within(cards[0]!).getByTestId("skeleton-title")).toBeInTheDocument(); }); - it('card skeleton has content placeholders', () => { + it("card skeleton has content placeholders", () => { render(DashboardSkeleton); - const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]!).getByTestId('skeleton-content')).toBeInTheDocument(); + const cards = screen.getAllByTestId("project-card-skeleton"); + expect(within(cards[0]!).getByTestId("skeleton-content")).toBeInTheDocument(); }); - it('card skeleton has button placeholder', () => { + it("card skeleton has button placeholder", () => { render(DashboardSkeleton); - const cards = screen.getAllByTestId('project-card-skeleton'); - expect(within(cards[0]!).getByTestId('skeleton-card-button')).toBeInTheDocument(); + const cards = screen.getAllByTestId("project-card-skeleton"); + expect(within(cards[0]!).getByTestId("skeleton-card-button")).toBeInTheDocument(); }); }); - describe('accessibility and animation', () => { - it('has pulse animation on skeleton elements', () => { + describe("accessibility and animation", () => { + it("has pulse animation on skeleton elements", () => { render(DashboardSkeleton); - const skeletonTitle = screen.getAllByTestId('skeleton-title')[0]!; - expect(skeletonTitle).toHaveClass('animate-pulse'); + const skeletonTitle = screen.getAllByTestId("skeleton-title")[0]!; + expect(skeletonTitle).toHaveClass("animate-pulse"); }); - it('renders skeleton with proper grid layout', () => { + it("renders skeleton with proper grid layout", () => { render(DashboardSkeleton); - const grid = screen.getByTestId('dashboard-skeleton-grid'); - expect(grid).toHaveClass('grid'); + const grid = screen.getByTestId("dashboard-skeleton-grid"); + expect(grid).toHaveClass("grid"); }); }); - describe('consistent dimensions', () => { - it('skeleton matches real component layout with same container classes', () => { + describe("consistent dimensions", () => { + it("skeleton matches real component layout with same container classes", () => { render(DashboardSkeleton); - const container = screen.getByTestId('dashboard-skeleton'); - expect(container).toHaveClass('space-y-6'); + const container = screen.getByTestId("dashboard-skeleton"); + expect(container).toHaveClass("space-y-6"); }); - it('grid uses responsive columns matching dashboard', () => { + it("grid uses responsive columns matching dashboard", () => { render(DashboardSkeleton); - const grid = screen.getByTestId('dashboard-skeleton-grid'); - expect(grid).toHaveClass('sm:grid-cols-2'); - expect(grid).toHaveClass('lg:grid-cols-3'); - expect(grid).toHaveClass('xl:grid-cols-4'); + const grid = screen.getByTestId("dashboard-skeleton-grid"); + expect(grid).toHaveClass("sm:grid-cols-2"); + expect(grid).toHaveClass("lg:grid-cols-3"); + expect(grid).toHaveClass("xl:grid-cols-4"); }); }); }); diff --git a/src/lib/components/__tests__/incident-status-badge.component.test.ts b/src/lib/components/__tests__/incident-status-badge.component.test.ts index e5e3ae2..c484e76 100644 --- a/src/lib/components/__tests__/incident-status-badge.component.test.ts +++ b/src/lib/components/__tests__/incident-status-badge.component.test.ts @@ -1,15 +1,15 @@ -import { render, screen } from '@testing-library/svelte'; -import { describe, expect, it } from 'vitest'; -import IncidentStatusBadge from '../incident-status-badge.svelte'; +import { render, screen } from "@testing-library/svelte"; +import { describe, expect, it } from "vite-plus/test"; +import IncidentStatusBadge from "../incident-status-badge.svelte"; -describe('IncidentStatusBadge', () => { - it('renders open label', () => { - render(IncidentStatusBadge, { props: { status: 'open' } }); - expect(screen.getByText('OPEN')).toBeInTheDocument(); +describe("IncidentStatusBadge", () => { + it("renders open label", () => { + render(IncidentStatusBadge, { props: { status: "open" } }); + expect(screen.getByText("OPEN")).toBeInTheDocument(); }); - it('renders resolved label', () => { - render(IncidentStatusBadge, { props: { status: 'resolved' } }); - expect(screen.getByText('RESOLVED')).toBeInTheDocument(); + it("renders resolved label", () => { + render(IncidentStatusBadge, { props: { status: "resolved" } }); + expect(screen.getByText("RESOLVED")).toBeInTheDocument(); }); }); diff --git a/src/lib/components/__tests__/incident-table.component.test.ts b/src/lib/components/__tests__/incident-table.component.test.ts index 73e17e2..cbf7376 100644 --- a/src/lib/components/__tests__/incident-table.component.test.ts +++ b/src/lib/components/__tests__/incident-table.component.test.ts @@ -1,40 +1,40 @@ -import { fireEvent, render, screen } from '@testing-library/svelte'; -import { describe, expect, it, vi } from 'vitest'; -import type { IncidentListItem } from '$lib/shared/types'; -import IncidentTable from '../incident-table.svelte'; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { IncidentListItem } from "$lib/shared/types"; +import IncidentTable from "../incident-table.svelte"; function sampleIncident(overrides: Partial = {}): IncidentListItem { return { - id: 'inc-1', - projectId: 'proj-1', - fingerprint: 'fp-1', - title: 'Database timeout after 1000ms', - normalizedMessage: 'database timeout after {num}ms', - serviceName: 'api', - sourceFile: 'src/db.ts', + id: "inc-1", + projectId: "proj-1", + fingerprint: "fp-1", + title: "Database timeout after 1000ms", + normalizedMessage: "database timeout after {num}ms", + serviceName: "api", + sourceFile: "src/db.ts", lineNumber: 42, - highestLevel: 'error', + highestLevel: "error", firstSeen: new Date().toISOString(), lastSeen: new Date().toISOString(), totalEvents: 3, - status: 'open', + status: "open", ...overrides, }; } -describe('IncidentTable', () => { - it('renders incident rows', () => { +describe("IncidentTable", () => { + it("renders incident rows", () => { render(IncidentTable, { props: { incidents: [sampleIncident()] } }); expect(screen.getAllByText(/database timeout/i).length).toBeGreaterThan(0); }); - it('calls onSelect when row/card clicked', async () => { + it("calls onSelect when row/card clicked", async () => { const onSelect = vi.fn(); render(IncidentTable, { props: { incidents: [sampleIncident()], onSelect } }); - const interactive = screen.queryByTestId('incident-row') ?? screen.getByTestId('incident-card'); + const interactive = screen.queryByTestId("incident-row") ?? screen.getByTestId("incident-card"); await fireEvent.click(interactive); - expect(onSelect).toHaveBeenCalledWith('inc-1'); + expect(onSelect).toHaveBeenCalledWith("inc-1"); }); }); diff --git a/src/lib/components/__tests__/incident-timeline-panel.component.test.ts b/src/lib/components/__tests__/incident-timeline-panel.component.test.ts index 3a944e2..3537145 100644 --- a/src/lib/components/__tests__/incident-timeline-panel.component.test.ts +++ b/src/lib/components/__tests__/incident-timeline-panel.component.test.ts @@ -1,32 +1,32 @@ -import { render, screen } from '@testing-library/svelte'; -import { describe, expect, it } from 'vitest'; -import type { IncidentDetail, IncidentTimelineResponse } from '$lib/shared/types'; -import IncidentTimelinePanel from '../incident-timeline-panel.svelte'; +import { render, screen } from "@testing-library/svelte"; +import { describe, expect, it } from "vite-plus/test"; +import type { IncidentDetail, IncidentTimelineResponse } from "$lib/shared/types"; +import IncidentTimelinePanel from "../incident-timeline-panel.svelte"; const detail: IncidentDetail = { - id: 'inc-1', - projectId: 'proj-1', - fingerprint: 'fp-1', - title: 'Database timeout', - normalizedMessage: 'database timeout {num}', - serviceName: 'api', - sourceFile: 'src/db.ts', + id: "inc-1", + projectId: "proj-1", + fingerprint: "fp-1", + title: "Database timeout", + normalizedMessage: "database timeout {num}", + serviceName: "api", + sourceFile: "src/db.ts", lineNumber: 42, - highestLevel: 'error', + highestLevel: "error", firstSeen: new Date().toISOString(), lastSeen: new Date().toISOString(), totalEvents: 5, - status: 'open', - rootCauseCandidates: [{ sourceFile: 'src/db.ts', lineNumber: 42, count: 5 }], + status: "open", + rootCauseCandidates: [{ sourceFile: "src/db.ts", lineNumber: 42, count: 5 }], correlations: { - topRequestIds: [{ requestId: 'req-1', count: 2 }], - topTraceIds: [{ traceId: 'trace-1', count: 2 }], + topRequestIds: [{ requestId: "req-1", count: 2 }], + topTraceIds: [{ traceId: "trace-1", count: 2 }], }, }; const timeline: IncidentTimelineResponse = { - incidentId: 'inc-1', - range: '1h', + incidentId: "inc-1", + range: "1h", buckets: [ { timestamp: new Date().toISOString(), count: 3 }, { timestamp: new Date().toISOString(), count: 1 }, @@ -34,17 +34,17 @@ const timeline: IncidentTimelineResponse = { peakBucket: { timestamp: new Date().toISOString(), count: 3 }, }; -describe('IncidentTimelinePanel', () => { - it('shows empty state when no detail selected', () => { +describe("IncidentTimelinePanel", () => { + it("shows empty state when no detail selected", () => { render(IncidentTimelinePanel, { props: { detail: null, timeline: null } }); expect(screen.getByText(/select an incident/i)).toBeInTheDocument(); }); - it('renders incident detail and correlation sections', () => { + it("renders incident detail and correlation sections", () => { render(IncidentTimelinePanel, { props: { detail, timeline } }); - expect(screen.getByText('Incident Timeline')).toBeInTheDocument(); + expect(screen.getByText("Incident Timeline")).toBeInTheDocument(); expect(screen.queryByText(/Reopens:/i)).not.toBeInTheDocument(); - expect(screen.getByText('Root-Cause Candidates')).toBeInTheDocument(); - expect(screen.getByText('Correlated Request IDs')).toBeInTheDocument(); + expect(screen.getByText("Root-Cause Candidates")).toBeInTheDocument(); + expect(screen.getByText("Correlated Request IDs")).toBeInTheDocument(); }); }); diff --git a/src/lib/components/__tests__/keyboard-help-modal.component.test.ts b/src/lib/components/__tests__/keyboard-help-modal.component.test.ts index 7b44614..474de44 100644 --- a/src/lib/components/__tests__/keyboard-help-modal.component.test.ts +++ b/src/lib/components/__tests__/keyboard-help-modal.component.test.ts @@ -1,36 +1,36 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SHORTCUTS } from '$lib/utils/keyboard'; -import KeyboardHelpModal from '../keyboard-help-modal.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { SHORTCUTS } from "$lib/utils/keyboard"; +import KeyboardHelpModal from "../keyboard-help-modal.svelte"; -describe('KeyboardHelpModal', () => { +describe("KeyboardHelpModal", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - describe('rendering', () => { - it('renders when open=true', () => { + describe("rendering", () => { + it("renders when open=true", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByTestId('keyboard-help-modal')).toBeInTheDocument(); + expect(screen.getByTestId("keyboard-help-modal")).toBeInTheDocument(); }); - it('does not render when open=false', () => { + it("does not render when open=false", () => { render(KeyboardHelpModal, { props: { open: false } }); - expect(screen.queryByTestId('keyboard-help-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId("keyboard-help-modal")).not.toBeInTheDocument(); }); - it('renders modal title', () => { + it("renders modal title", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument(); + expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument(); }); }); - describe('displays all shortcuts from SHORTCUTS array', () => { - it('displays all shortcut keys', () => { + describe("displays all shortcuts from SHORTCUTS array", () => { + it("displays all shortcut keys", () => { render(KeyboardHelpModal, { props: { open: true } }); for (const shortcut of SHORTCUTS) { @@ -39,7 +39,7 @@ describe('KeyboardHelpModal', () => { } }); - it('displays all shortcut descriptions', () => { + it("displays all shortcut descriptions", () => { render(KeyboardHelpModal, { props: { open: true } }); for (const shortcut of SHORTCUTS) { @@ -47,130 +47,130 @@ describe('KeyboardHelpModal', () => { } }); - it('displays navigation group header', () => { + it("displays navigation group header", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText("Navigation")).toBeInTheDocument(); }); - it('displays search group header', () => { + it("displays search group header", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByText('Search & Filters')).toBeInTheDocument(); + expect(screen.getByText("Search & Filters")).toBeInTheDocument(); }); - it('displays other group header', () => { + it("displays other group header", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByText('Other')).toBeInTheDocument(); + expect(screen.getByText("Other")).toBeInTheDocument(); }); }); - describe('calls onClose when Escape pressed', () => { - it('calls onClose when Escape key is pressed', async () => { + describe("calls onClose when Escape pressed", () => { + it("calls onClose when Escape key is pressed", async () => { const onClose = vi.fn(); render(KeyboardHelpModal, { props: { open: true, onClose } }); - await fireEvent.keyDown(document, { key: 'Escape' }); + await fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not call onClose for other keys', async () => { + it("does not call onClose for other keys", async () => { const onClose = vi.fn(); render(KeyboardHelpModal, { props: { open: true, onClose } }); - await fireEvent.keyDown(document, { key: 'Enter' }); - await fireEvent.keyDown(document, { key: 'a' }); - await fireEvent.keyDown(document, { key: 'Tab' }); + await fireEvent.keyDown(document, { key: "Enter" }); + await fireEvent.keyDown(document, { key: "a" }); + await fireEvent.keyDown(document, { key: "Tab" }); expect(onClose).not.toHaveBeenCalled(); }); - it('does not throw when onClose is not provided', async () => { + it("does not throw when onClose is not provided", async () => { render(KeyboardHelpModal, { props: { open: true } }); // Should not throw - await expect(fireEvent.keyDown(document, { key: 'Escape' })).resolves.not.toThrow(); + await expect(fireEvent.keyDown(document, { key: "Escape" })).resolves.not.toThrow(); }); }); - describe('calls onClose when backdrop clicked', () => { - it('calls onClose when backdrop overlay is clicked', async () => { + describe("calls onClose when backdrop clicked", () => { + it("calls onClose when backdrop overlay is clicked", async () => { const onClose = vi.fn(); render(KeyboardHelpModal, { props: { open: true, onClose } }); - const overlay = screen.getByTestId('modal-overlay'); + const overlay = screen.getByTestId("modal-overlay"); await fireEvent.click(overlay); expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not call onClose when modal content is clicked', async () => { + it("does not call onClose when modal content is clicked", async () => { const onClose = vi.fn(); render(KeyboardHelpModal, { props: { open: true, onClose } }); - const modal = screen.getByTestId('keyboard-help-modal'); + const modal = screen.getByTestId("keyboard-help-modal"); await fireEvent.click(modal); expect(onClose).not.toHaveBeenCalled(); }); }); - describe('close button', () => { - it('renders close button', () => { + describe("close button", () => { + it("renders close button", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByTestId('close-button')).toBeInTheDocument(); + expect(screen.getByTestId("close-button")).toBeInTheDocument(); }); - it('calls onClose when close button is clicked', async () => { + it("calls onClose when close button is clicked", async () => { const onClose = vi.fn(); render(KeyboardHelpModal, { props: { open: true, onClose } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); await fireEvent.click(closeButton); expect(onClose).toHaveBeenCalledTimes(1); }); }); - describe('accessibility', () => { - it('has appropriate dialog role', () => { + describe("accessibility", () => { + it("has appropriate dialog role", () => { render(KeyboardHelpModal, { props: { open: true } }); - expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); }); - it('has aria-modal attribute', () => { + it("has aria-modal attribute", () => { render(KeyboardHelpModal, { props: { open: true } }); - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveAttribute('aria-modal', 'true'); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); }); - it('has aria-labelledby pointing to title', () => { + it("has aria-labelledby pointing to title", () => { render(KeyboardHelpModal, { props: { open: true } }); - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveAttribute('aria-labelledby', 'keyboard-help-title'); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-labelledby", "keyboard-help-title"); }); - it('close button has accessible label', () => { + it("close button has accessible label", () => { render(KeyboardHelpModal, { props: { open: true } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); expect(closeButton).toHaveAccessibleName(/close/i); }); - it('shortcut keys have aria-labels', () => { + it("shortcut keys have aria-labels", () => { render(KeyboardHelpModal, { props: { open: true } }); // Query directly for kbd elements since they don't have a standard ARIA role - const allKbds = document.querySelectorAll('kbd'); + const allKbds = document.querySelectorAll("kbd"); expect(allKbds.length).toBe(SHORTCUTS.length); for (const kbd of allKbds) { - expect(kbd).toHaveAttribute('aria-label'); + expect(kbd).toHaveAttribute("aria-label"); } }); }); diff --git a/src/lib/components/__tests__/level-badge.component.test.ts b/src/lib/components/__tests__/level-badge.component.test.ts index 95bf5d6..73601e5 100644 --- a/src/lib/components/__tests__/level-badge.component.test.ts +++ b/src/lib/components/__tests__/level-badge.component.test.ts @@ -1,19 +1,19 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import LevelBadge from '../level-badge.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import LevelBadge from "../level-badge.svelte"; -describe('LevelBadge', () => { +describe("LevelBadge", () => { afterEach(() => { cleanup(); }); it.each([ - ['debug', 'DEBUG', 'bg-slate'], - ['info', 'INFO', 'bg-blue'], - ['warn', 'WARN', 'bg-amber'], - ['error', 'ERROR', 'bg-red'], - ['fatal', 'FATAL', 'bg-purple'], - ] as const)('renders %s level as %s with %s background', (level, text, bgClass) => { + ["debug", "DEBUG", "bg-slate"], + ["info", "INFO", "bg-blue"], + ["warn", "WARN", "bg-amber"], + ["error", "ERROR", "bg-red"], + ["fatal", "FATAL", "bg-purple"], + ] as const)("renders %s level as %s with %s background", (level, text, bgClass) => { render(LevelBadge, { props: { level } }); const badge = screen.getByText(text); expect(badge).toBeInTheDocument(); diff --git a/src/lib/components/__tests__/level-chart.component.test.ts b/src/lib/components/__tests__/level-chart.component.test.ts index 72b609f..c426e10 100644 --- a/src/lib/components/__tests__/level-chart.component.test.ts +++ b/src/lib/components/__tests__/level-chart.component.test.ts @@ -1,8 +1,8 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import LevelChart from '../level-chart.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import LevelChart from "../level-chart.svelte"; -describe('LevelChart', () => { +describe("LevelChart", () => { afterEach(() => { cleanup(); }); @@ -24,27 +24,27 @@ describe('LevelChart', () => { }, }; - describe('renders donut chart with correct segments', () => { - it('renders SVG donut chart container', () => { + describe("renders donut chart with correct segments", () => { + it("renders SVG donut chart container", () => { render(LevelChart, { props: { data: mockData } }); - const svg = screen.getByTestId('level-chart-svg'); + const svg = screen.getByTestId("level-chart-svg"); expect(svg).toBeInTheDocument(); - expect(svg.tagName.toLowerCase()).toBe('svg'); + expect(svg.tagName.toLowerCase()).toBe("svg"); }); - it('renders a segment for each level with data', () => { + it("renders a segment for each level with data", () => { render(LevelChart, { props: { data: mockData } }); // Each level should have a path element - expect(screen.getByTestId('chart-segment-debug')).toBeInTheDocument(); - expect(screen.getByTestId('chart-segment-info')).toBeInTheDocument(); - expect(screen.getByTestId('chart-segment-warn')).toBeInTheDocument(); - expect(screen.getByTestId('chart-segment-error')).toBeInTheDocument(); - expect(screen.getByTestId('chart-segment-fatal')).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-debug")).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-info")).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-warn")).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-error")).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-fatal")).toBeInTheDocument(); }); - it('does not render segments for levels with zero count', () => { + it("does not render segments for levels with zero count", () => { const dataWithZeros = { levelCounts: { debug: 0, @@ -64,14 +64,14 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: dataWithZeros } }); - expect(screen.queryByTestId('chart-segment-debug')).not.toBeInTheDocument(); - expect(screen.getByTestId('chart-segment-info')).toBeInTheDocument(); - expect(screen.queryByTestId('chart-segment-warn')).not.toBeInTheDocument(); - expect(screen.queryByTestId('chart-segment-error')).not.toBeInTheDocument(); - expect(screen.queryByTestId('chart-segment-fatal')).not.toBeInTheDocument(); + expect(screen.queryByTestId("chart-segment-debug")).not.toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-info")).toBeInTheDocument(); + expect(screen.queryByTestId("chart-segment-warn")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chart-segment-error")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chart-segment-fatal")).not.toBeInTheDocument(); }); - it('renders empty state when all counts are zero', () => { + it("renders empty state when all counts are zero", () => { const emptyData = { levelCounts: {}, levelPercentages: {}, @@ -79,61 +79,61 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: emptyData } }); - expect(screen.getByTestId('level-chart-empty')).toBeInTheDocument(); - expect(screen.getByText('No data')).toBeInTheDocument(); + expect(screen.getByTestId("level-chart-empty")).toBeInTheDocument(); + expect(screen.getByText("No data")).toBeInTheDocument(); }); - it('renders center text with total count', () => { + it("renders center text with total count", () => { render(LevelChart, { props: { data: mockData } }); // Total is 400 (100 + 200 + 50 + 30 + 20) - expect(screen.getByTestId('chart-total')).toBeInTheDocument(); - expect(screen.getByText('400')).toBeInTheDocument(); + expect(screen.getByTestId("chart-total")).toBeInTheDocument(); + expect(screen.getByText("400")).toBeInTheDocument(); }); }); - describe('displays legend with percentages', () => { - it('renders legend container', () => { + describe("displays legend with percentages", () => { + it("renders legend container", () => { render(LevelChart, { props: { data: mockData } }); - expect(screen.getByTestId('level-chart-legend')).toBeInTheDocument(); + expect(screen.getByTestId("level-chart-legend")).toBeInTheDocument(); }); - it('displays each level with its count and percentage', () => { + it("displays each level with its count and percentage", () => { render(LevelChart, { props: { data: mockData } }); // Check debug entry - expect(screen.getByTestId('legend-item-debug')).toBeInTheDocument(); - expect(screen.getByText('DEBUG')).toBeInTheDocument(); - expect(screen.getByText('100')).toBeInTheDocument(); - expect(screen.getByText('25%')).toBeInTheDocument(); + expect(screen.getByTestId("legend-item-debug")).toBeInTheDocument(); + expect(screen.getByText("DEBUG")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); // Check info entry - expect(screen.getByTestId('legend-item-info')).toBeInTheDocument(); - expect(screen.getByText('INFO')).toBeInTheDocument(); - expect(screen.getByText('200')).toBeInTheDocument(); - expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByTestId("legend-item-info")).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); + expect(screen.getByText("200")).toBeInTheDocument(); + expect(screen.getByText("50%")).toBeInTheDocument(); // Check warn entry - expect(screen.getByTestId('legend-item-warn')).toBeInTheDocument(); - expect(screen.getByText('WARN')).toBeInTheDocument(); - expect(screen.getByText('50')).toBeInTheDocument(); - expect(screen.getByText('12.5%')).toBeInTheDocument(); + expect(screen.getByTestId("legend-item-warn")).toBeInTheDocument(); + expect(screen.getByText("WARN")).toBeInTheDocument(); + expect(screen.getByText("50")).toBeInTheDocument(); + expect(screen.getByText("12.5%")).toBeInTheDocument(); // Check error entry - expect(screen.getByTestId('legend-item-error')).toBeInTheDocument(); - expect(screen.getByText('ERROR')).toBeInTheDocument(); - expect(screen.getByText('30')).toBeInTheDocument(); - expect(screen.getByText('7.5%')).toBeInTheDocument(); + expect(screen.getByTestId("legend-item-error")).toBeInTheDocument(); + expect(screen.getByText("ERROR")).toBeInTheDocument(); + expect(screen.getByText("30")).toBeInTheDocument(); + expect(screen.getByText("7.5%")).toBeInTheDocument(); // Check fatal entry - expect(screen.getByTestId('legend-item-fatal')).toBeInTheDocument(); - expect(screen.getByText('FATAL')).toBeInTheDocument(); - expect(screen.getByText('20')).toBeInTheDocument(); - expect(screen.getByText('5%')).toBeInTheDocument(); + expect(screen.getByTestId("legend-item-fatal")).toBeInTheDocument(); + expect(screen.getByText("FATAL")).toBeInTheDocument(); + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("5%")).toBeInTheDocument(); }); - it('does not show legend entries for levels with zero count', () => { + it("does not show legend entries for levels with zero count", () => { const partialData = { levelCounts: { info: 80, @@ -147,14 +147,14 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: partialData } }); - expect(screen.queryByTestId('legend-item-debug')).not.toBeInTheDocument(); - expect(screen.getByTestId('legend-item-info')).toBeInTheDocument(); - expect(screen.queryByTestId('legend-item-warn')).not.toBeInTheDocument(); - expect(screen.getByTestId('legend-item-error')).toBeInTheDocument(); - expect(screen.queryByTestId('legend-item-fatal')).not.toBeInTheDocument(); + expect(screen.queryByTestId("legend-item-debug")).not.toBeInTheDocument(); + expect(screen.getByTestId("legend-item-info")).toBeInTheDocument(); + expect(screen.queryByTestId("legend-item-warn")).not.toBeInTheDocument(); + expect(screen.getByTestId("legend-item-error")).toBeInTheDocument(); + expect(screen.queryByTestId("legend-item-fatal")).not.toBeInTheDocument(); }); - it('formats percentages with one decimal place', () => { + it("formats percentages with one decimal place", () => { const precisionData = { levelCounts: { info: 333, @@ -168,19 +168,19 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: precisionData } }); - expect(screen.getByText('33.3%')).toBeInTheDocument(); - expect(screen.getByText('66.7%')).toBeInTheDocument(); + expect(screen.getByText("33.3%")).toBeInTheDocument(); + expect(screen.getByText("66.7%")).toBeInTheDocument(); }); - it('displays legend color indicators matching segment colors', () => { + it("displays legend color indicators matching segment colors", () => { render(LevelChart, { props: { data: mockData } }); // Each legend item should have a color indicator - const debugIndicator = screen.getByTestId('legend-color-debug'); - const infoIndicator = screen.getByTestId('legend-color-info'); - const warnIndicator = screen.getByTestId('legend-color-warn'); - const errorIndicator = screen.getByTestId('legend-color-error'); - const fatalIndicator = screen.getByTestId('legend-color-fatal'); + const debugIndicator = screen.getByTestId("legend-color-debug"); + const infoIndicator = screen.getByTestId("legend-color-info"); + const warnIndicator = screen.getByTestId("legend-color-warn"); + const errorIndicator = screen.getByTestId("legend-color-error"); + const fatalIndicator = screen.getByTestId("legend-color-fatal"); expect(debugIndicator).toBeInTheDocument(); expect(infoIndicator).toBeInTheDocument(); @@ -190,62 +190,62 @@ describe('LevelChart', () => { }); }); - describe('uses correct colors for each level', () => { - it('uses slate color for debug segment', () => { + describe("uses correct colors for each level", () => { + it("uses slate color for debug segment", () => { render(LevelChart, { props: { data: mockData } }); - const segment = screen.getByTestId('chart-segment-debug'); - expect(segment.getAttribute('fill')).toBe('hsl(215, 15%, 50%)'); + const segment = screen.getByTestId("chart-segment-debug"); + expect(segment.getAttribute("fill")).toBe("hsl(215, 15%, 50%)"); }); - it('uses blue color for info segment', () => { + it("uses blue color for info segment", () => { render(LevelChart, { props: { data: mockData } }); - const segment = screen.getByTestId('chart-segment-info'); - expect(segment.getAttribute('fill')).toBe('hsl(210, 100%, 50%)'); + const segment = screen.getByTestId("chart-segment-info"); + expect(segment.getAttribute("fill")).toBe("hsl(210, 100%, 50%)"); }); - it('uses amber color for warn segment', () => { + it("uses amber color for warn segment", () => { render(LevelChart, { props: { data: mockData } }); - const segment = screen.getByTestId('chart-segment-warn'); - expect(segment.getAttribute('fill')).toBe('hsl(45, 100%, 50%)'); + const segment = screen.getByTestId("chart-segment-warn"); + expect(segment.getAttribute("fill")).toBe("hsl(45, 100%, 50%)"); }); - it('uses red color for error segment', () => { + it("uses red color for error segment", () => { render(LevelChart, { props: { data: mockData } }); - const segment = screen.getByTestId('chart-segment-error'); - expect(segment.getAttribute('fill')).toBe('hsl(0, 85%, 55%)'); + const segment = screen.getByTestId("chart-segment-error"); + expect(segment.getAttribute("fill")).toBe("hsl(0, 85%, 55%)"); }); - it('uses purple color for fatal segment', () => { + it("uses purple color for fatal segment", () => { render(LevelChart, { props: { data: mockData } }); - const segment = screen.getByTestId('chart-segment-fatal'); - expect(segment.getAttribute('fill')).toBe('hsl(270, 70%, 55%)'); + const segment = screen.getByTestId("chart-segment-fatal"); + expect(segment.getAttribute("fill")).toBe("hsl(270, 70%, 55%)"); }); - it('uses matching colors for legend indicators', () => { + it("uses matching colors for legend indicators", () => { render(LevelChart, { props: { data: mockData } }); - const debugIndicator = screen.getByTestId('legend-color-debug'); - const infoIndicator = screen.getByTestId('legend-color-info'); - const warnIndicator = screen.getByTestId('legend-color-warn'); - const errorIndicator = screen.getByTestId('legend-color-error'); - const fatalIndicator = screen.getByTestId('legend-color-fatal'); + const debugIndicator = screen.getByTestId("legend-color-debug"); + const infoIndicator = screen.getByTestId("legend-color-info"); + const warnIndicator = screen.getByTestId("legend-color-warn"); + const errorIndicator = screen.getByTestId("legend-color-error"); + const fatalIndicator = screen.getByTestId("legend-color-fatal"); // Verify legend indicators have background-color style set (JSDOM converts HSL to RGB) - expect(debugIndicator.getAttribute('style')).toContain('background-color'); - expect(infoIndicator.getAttribute('style')).toContain('background-color'); - expect(warnIndicator.getAttribute('style')).toContain('background-color'); - expect(errorIndicator.getAttribute('style')).toContain('background-color'); - expect(fatalIndicator.getAttribute('style')).toContain('background-color'); + expect(debugIndicator.getAttribute("style")).toContain("background-color"); + expect(infoIndicator.getAttribute("style")).toContain("background-color"); + expect(warnIndicator.getAttribute("style")).toContain("background-color"); + expect(errorIndicator.getAttribute("style")).toContain("background-color"); + expect(fatalIndicator.getAttribute("style")).toContain("background-color"); }); }); - describe('edge cases', () => { - it('handles single level data', () => { + describe("edge cases", () => { + it("handles single level data", () => { const singleData = { levelCounts: { error: 100, @@ -257,13 +257,13 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: singleData } }); - expect(screen.getByTestId('chart-segment-error')).toBeInTheDocument(); + expect(screen.getByTestId("chart-segment-error")).toBeInTheDocument(); // Use testids to avoid ambiguity between total count and legend count - expect(screen.getByTestId('chart-total')).toHaveTextContent('100'); - expect(screen.getByTestId('legend-item-error')).toHaveTextContent('100%'); + expect(screen.getByTestId("chart-total")).toHaveTextContent("100"); + expect(screen.getByTestId("legend-item-error")).toHaveTextContent("100%"); }); - it('handles very small percentages', () => { + it("handles very small percentages", () => { const smallData = { levelCounts: { info: 999, @@ -277,15 +277,15 @@ describe('LevelChart', () => { render(LevelChart, { props: { data: smallData } }); - expect(screen.getByText('99.9%')).toBeInTheDocument(); - expect(screen.getByText('0.1%')).toBeInTheDocument(); + expect(screen.getByText("99.9%")).toBeInTheDocument(); + expect(screen.getByText("0.1%")).toBeInTheDocument(); }); - it('renders with custom class', () => { - render(LevelChart, { props: { data: mockData, class: 'custom-class' } }); + it("renders with custom class", () => { + render(LevelChart, { props: { data: mockData, class: "custom-class" } }); - const container = screen.getByTestId('level-chart-container'); - expect(container.className).toContain('custom-class'); + const container = screen.getByTestId("level-chart-container"); + expect(container.className).toContain("custom-class"); }); }); }); diff --git a/src/lib/components/__tests__/live-toggle.component.test.ts b/src/lib/components/__tests__/live-toggle.component.test.ts index b22a8fb..9cbd92b 100644 --- a/src/lib/components/__tests__/live-toggle.component.test.ts +++ b/src/lib/components/__tests__/live-toggle.component.test.ts @@ -1,126 +1,126 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import LiveToggle from '../live-toggle.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import LiveToggle from "../live-toggle.svelte"; -describe('LiveToggle', () => { +describe("LiveToggle", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - describe('shows green pulse when enabled', () => { - it('shows pulse indicator when enabled is true', () => { + describe("shows green pulse when enabled", () => { + it("shows pulse indicator when enabled is true", () => { render(LiveToggle, { props: { enabled: true } }); - const pulse = screen.getByTestId('live-pulse'); + const pulse = screen.getByTestId("live-pulse"); expect(pulse).toBeInTheDocument(); - expect(pulse).toHaveClass('bg-green-500'); + expect(pulse).toHaveClass("bg-green-500"); }); - it('shows animate-pulse class when enabled', () => { + it("shows animate-pulse class when enabled", () => { render(LiveToggle, { props: { enabled: true } }); - const pulse = screen.getByTestId('live-pulse'); - expect(pulse).toHaveClass('animate-pulse'); + const pulse = screen.getByTestId("live-pulse"); + expect(pulse).toHaveClass("animate-pulse"); }); - it('does not show pulse animation when disabled', () => { + it("does not show pulse animation when disabled", () => { render(LiveToggle, { props: { enabled: false } }); - const pulse = screen.getByTestId('live-pulse'); - expect(pulse).not.toHaveClass('animate-pulse'); - expect(pulse).not.toHaveClass('bg-green-500'); + const pulse = screen.getByTestId("live-pulse"); + expect(pulse).not.toHaveClass("animate-pulse"); + expect(pulse).not.toHaveClass("bg-green-500"); }); - it('shows muted color when disabled', () => { + it("shows muted color when disabled", () => { render(LiveToggle, { props: { enabled: false } }); - const pulse = screen.getByTestId('live-pulse'); - expect(pulse).toHaveClass('bg-muted-foreground'); + const pulse = screen.getByTestId("live-pulse"); + expect(pulse).toHaveClass("bg-muted-foreground"); }); }); - describe('toggles state on click', () => { - it('renders a switch element', () => { + describe("toggles state on click", () => { + it("renders a switch element", () => { render(LiveToggle); - const toggle = screen.getByRole('switch'); + const toggle = screen.getByRole("switch"); expect(toggle).toBeInTheDocument(); }); - it('switch is checked when enabled is true', () => { + it("switch is checked when enabled is true", () => { render(LiveToggle, { props: { enabled: true } }); - const toggle = screen.getByRole('switch'); - expect(toggle).toHaveAttribute('data-state', 'checked'); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("data-state", "checked"); }); - it('switch is unchecked when enabled is false', () => { + it("switch is unchecked when enabled is false", () => { render(LiveToggle, { props: { enabled: false } }); - const toggle = screen.getByRole('switch'); - expect(toggle).toHaveAttribute('data-state', 'unchecked'); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("data-state", "unchecked"); }); - it('defaults to enabled when no prop provided', () => { + it("defaults to enabled when no prop provided", () => { render(LiveToggle); - const toggle = screen.getByRole('switch'); - expect(toggle).toHaveAttribute('data-state', 'checked'); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("data-state", "checked"); }); }); - describe('emits change event', () => { - it('calls onchange with false when toggling off', async () => { + describe("emits change event", () => { + it("calls onchange with false when toggling off", async () => { const onchange = vi.fn(); render(LiveToggle, { props: { enabled: true, onchange } }); - const toggle = screen.getByRole('switch'); + const toggle = screen.getByRole("switch"); await fireEvent.click(toggle); expect(onchange).toHaveBeenCalledTimes(1); expect(onchange).toHaveBeenCalledWith(false); }); - it('calls onchange with true when toggling on', async () => { + it("calls onchange with true when toggling on", async () => { const onchange = vi.fn(); render(LiveToggle, { props: { enabled: false, onchange } }); - const toggle = screen.getByRole('switch'); + const toggle = screen.getByRole("switch"); await fireEvent.click(toggle); expect(onchange).toHaveBeenCalledTimes(1); expect(onchange).toHaveBeenCalledWith(true); }); - it('does not throw when onchange is not provided', async () => { + it("does not throw when onchange is not provided", async () => { render(LiveToggle, { props: { enabled: true } }); - const toggle = screen.getByRole('switch'); + const toggle = screen.getByRole("switch"); await expect(fireEvent.click(toggle)).resolves.not.toThrow(); }); }); - describe('displays label', () => { + describe("displays label", () => { it('shows "Live" label', () => { render(LiveToggle); - expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.getByText("Live")).toBeInTheDocument(); }); - it('has accessible label for the switch', () => { + it("has accessible label for the switch", () => { render(LiveToggle); - const toggle = screen.getByRole('switch'); - expect(toggle).toHaveAttribute('aria-label', 'Toggle live streaming'); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("aria-label", "Toggle live streaming"); }); }); - it('can be disabled', () => { + it("can be disabled", () => { render(LiveToggle, { props: { disabled: true } }); - const toggle = screen.getByRole('switch'); + const toggle = screen.getByRole("switch"); expect(toggle).toBeDisabled(); }); }); diff --git a/src/lib/components/__tests__/log-card.component.test.ts b/src/lib/components/__tests__/log-card.component.test.ts index 241ca8c..6a9f1f8 100644 --- a/src/lib/components/__tests__/log-card.component.test.ts +++ b/src/lib/components/__tests__/log-card.component.test.ts @@ -1,29 +1,29 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Log } from '$lib/server/db/schema'; -import LogCard from '../log-card.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Log } from "$lib/server/db/schema"; +import LogCard from "../log-card.svelte"; // Mock formatTimestamp to have deterministic output -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatTimestamp: vi.fn((date: Date) => { - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); - const seconds = date.getUTCSeconds().toString().padStart(2, '0'); - const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0'); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); + const seconds = date.getUTCSeconds().toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); return `${hours}:${minutes}:${seconds}.${milliseconds}`; }), })); -describe('LogCard', () => { +describe("LogCard", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'User logged in successfully', - metadata: { userId: 'user_789' }, + level: "info", + message: "User logged in successfully", + metadata: { userId: "user_789" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -41,13 +41,13 @@ describe('LogCard', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'auth.ts', + sourceFile: "auth.ts", lineNumber: 42, - requestId: 'req_abc', - userId: 'user_789', - ipAddress: '192.168.1.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_789", + ipAddress: "192.168.1.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; afterEach(() => { @@ -55,102 +55,102 @@ describe('LogCard', () => { vi.clearAllMocks(); }); - describe('highlight new logs', () => { - it('applies log-new class when isNew is true', () => { + describe("highlight new logs", () => { + it("applies log-new class when isNew is true", () => { render(LogCard, { props: { log: baseLog, isNew: true } }); - const card = screen.getByTestId('log-card'); - expect(card).toHaveClass('log-new'); + const card = screen.getByTestId("log-card"); + expect(card).toHaveClass("log-new"); }); - it('does not apply log-new class when isNew is false', () => { + it("does not apply log-new class when isNew is false", () => { render(LogCard, { props: { log: baseLog, isNew: false } }); - const card = screen.getByTestId('log-card'); - expect(card).not.toHaveClass('log-new'); + const card = screen.getByTestId("log-card"); + expect(card).not.toHaveClass("log-new"); }); - it('does not apply log-new class when isNew is undefined', () => { + it("does not apply log-new class when isNew is undefined", () => { render(LogCard, { props: { log: baseLog } }); - const card = screen.getByTestId('log-card'); - expect(card).not.toHaveClass('log-new'); + const card = screen.getByTestId("log-card"); + expect(card).not.toHaveClass("log-new"); }); }); - describe('renders log information', () => { - it('displays log message', () => { + describe("renders log information", () => { + it("displays log message", () => { render(LogCard, { props: { log: baseLog } }); - expect(screen.getByText('User logged in successfully')).toBeInTheDocument(); + expect(screen.getByText("User logged in successfully")).toBeInTheDocument(); }); - it('displays level badge', () => { + it("displays level badge", () => { render(LogCard, { props: { log: baseLog } }); - expect(screen.getByText('INFO')).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); }); - it('displays timestamp', () => { + it("displays timestamp", () => { render(LogCard, { props: { log: baseLog } }); - expect(screen.getByText('14:30:45.123')).toBeInTheDocument(); + expect(screen.getByText("14:30:45.123")).toBeInTheDocument(); }); }); - describe('onclick handler', () => { - it('calls onclick when card is clicked', async () => { + describe("onclick handler", () => { + it("calls onclick when card is clicked", async () => { const onclick = vi.fn(); render(LogCard, { props: { log: baseLog, onclick } }); - const card = screen.getByTestId('log-card'); + const card = screen.getByTestId("log-card"); await fireEvent.click(card); expect(onclick).toHaveBeenCalledTimes(1); expect(onclick).toHaveBeenCalledWith(baseLog); }); - it('does not throw when onclick is not provided', async () => { + it("does not throw when onclick is not provided", async () => { render(LogCard, { props: { log: baseLog } }); - const card = screen.getByTestId('log-card'); + const card = screen.getByTestId("log-card"); await expect(fireEvent.click(card)).resolves.not.toThrow(); }); }); - describe('isSelected prop for keyboard navigation', () => { - it('renders without isSelected prop (default false)', () => { + describe("isSelected prop for keyboard navigation", () => { + it("renders without isSelected prop (default false)", () => { render(LogCard, { props: { log: baseLog } }); - const card = screen.getByTestId('log-card'); - expect(card).toHaveAttribute('data-selected', 'false'); - expect(card).not.toHaveAttribute('aria-current'); - expect(card).not.toHaveClass('bg-primary/10'); - expect(card).not.toHaveClass('ring-1'); + const card = screen.getByTestId("log-card"); + expect(card).toHaveAttribute("data-selected", "false"); + expect(card).not.toHaveAttribute("aria-current"); + expect(card).not.toHaveClass("bg-primary/10"); + expect(card).not.toHaveClass("ring-1"); }); - it('applies selected class when isSelected=true', () => { + it("applies selected class when isSelected=true", () => { render(LogCard, { props: { log: baseLog, isSelected: true } }); - const card = screen.getByTestId('log-card'); - expect(card).toHaveClass('bg-primary/10'); - expect(card).toHaveClass('ring-1'); - expect(card).toHaveClass('ring-primary/50'); + const card = screen.getByTestId("log-card"); + expect(card).toHaveClass("bg-primary/10"); + expect(card).toHaveClass("ring-1"); + expect(card).toHaveClass("ring-primary/50"); }); it('has data-selected="true" when selected', () => { render(LogCard, { props: { log: baseLog, isSelected: true } }); - const card = screen.getByTestId('log-card'); - expect(card).toHaveAttribute('data-selected', 'true'); + const card = screen.getByTestId("log-card"); + expect(card).toHaveAttribute("data-selected", "true"); }); it('has aria-current="true" when selected', () => { render(LogCard, { props: { log: baseLog, isSelected: true } }); - const card = screen.getByTestId('log-card'); - expect(card).toHaveAttribute('aria-current', 'true'); + const card = screen.getByTestId("log-card"); + expect(card).toHaveAttribute("aria-current", "true"); }); }); }); diff --git a/src/lib/components/__tests__/log-detail-modal.component.test.ts b/src/lib/components/__tests__/log-detail-modal.component.test.ts index 649be87..e0ea3db 100644 --- a/src/lib/components/__tests__/log-detail-modal.component.test.ts +++ b/src/lib/components/__tests__/log-detail-modal.component.test.ts @@ -1,7 +1,7 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Log } from '$lib/server/db/schema'; -import LogDetailModal from '../log-detail-modal.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Log } from "$lib/server/db/schema"; +import LogDetailModal from "../log-detail-modal.svelte"; // Mock clipboard API const mockClipboard = { @@ -10,22 +10,22 @@ const mockClipboard = { Object.assign(navigator, { clipboard: mockClipboard }); // Mock formatFullDate utility -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatFullDate: vi.fn((date: Date) => { - return date.toISOString().replace('T', ' ').replace('Z', ' UTC'); + return date.toISOString().replace("T", " ").replace("Z", " UTC"); }), })); -describe('LogDetailModal', () => { +describe("LogDetailModal", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'User logged in successfully', - metadata: { userId: 'user_789', action: 'login', details: { ip: '192.168.1.1' } }, + level: "info", + message: "User logged in successfully", + metadata: { userId: "user_789", action: "login", details: { ip: "192.168.1.1" } }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -43,13 +43,13 @@ describe('LogDetailModal', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'auth.ts', + sourceFile: "auth.ts", lineNumber: 42, - requestId: 'req_abc', - userId: 'user_789', - ipAddress: '192.168.1.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_789", + ipAddress: "192.168.1.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; beforeEach(() => { @@ -61,50 +61,50 @@ describe('LogDetailModal', () => { vi.clearAllMocks(); }); - describe('displays all log fields', () => { - it('displays log ID', () => { + describe("displays all log fields", () => { + it("displays log ID", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('log_123')).toBeInTheDocument(); + expect(screen.getByText("log_123")).toBeInTheDocument(); }); - it('displays log level with badge', () => { + it("displays log level with badge", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('INFO')).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); }); - it('displays log message', () => { + it("displays log message", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('User logged in successfully')).toBeInTheDocument(); + expect(screen.getByText("User logged in successfully")).toBeInTheDocument(); }); - it('displays source file and line number', () => { + it("displays source file and line number", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('auth.ts:42')).toBeInTheDocument(); + expect(screen.getByText("auth.ts:42")).toBeInTheDocument(); }); - it('displays request ID', () => { + it("displays request ID", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('req_abc')).toBeInTheDocument(); + expect(screen.getByText("req_abc")).toBeInTheDocument(); }); - it('displays user ID', () => { + it("displays user ID", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('user_789')).toBeInTheDocument(); + expect(screen.getByText("user_789")).toBeInTheDocument(); }); - it('displays IP address', () => { + it("displays IP address", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('192.168.1.1')).toBeInTheDocument(); + expect(screen.getByText("192.168.1.1")).toBeInTheDocument(); }); - it('shows N/A for missing optional fields', () => { + it("shows N/A for missing optional fields", () => { const logWithMissingFields: Log = { ...baseLog, sourceFile: null, @@ -115,98 +115,98 @@ describe('LogDetailModal', () => { }; render(LogDetailModal, { props: { log: logWithMissingFields, open: true } }); - const naElements = screen.getAllByText('N/A'); + const naElements = screen.getAllByText("N/A"); expect(naElements.length).toBeGreaterThanOrEqual(4); }); - it('does not render when open is false', () => { + it("does not render when open is false", () => { render(LogDetailModal, { props: { log: baseLog, open: false } }); - expect(screen.queryByText('log_123')).not.toBeInTheDocument(); + expect(screen.queryByText("log_123")).not.toBeInTheDocument(); }); }); - describe('formats timestamp as full date', () => { - it('displays timestamp in full date format', () => { + describe("formats timestamp as full date", () => { + it("displays timestamp in full date format", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); // The mocked formatFullDate returns ISO format expect(screen.getByText(/2024-01-15 14:30:45\.123 UTC/)).toBeInTheDocument(); }); - it('displays label for timestamp field', () => { + it("displays label for timestamp field", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByText('Timestamp')).toBeInTheDocument(); + expect(screen.getByText("Timestamp")).toBeInTheDocument(); }); - it('handles null timestamp gracefully', () => { + it("handles null timestamp gracefully", () => { const logWithNullTimestamp = { ...baseLog, timestamp: null as unknown as Date }; render(LogDetailModal, { props: { log: logWithNullTimestamp, open: true } }); - expect(screen.getByText('N/A')).toBeInTheDocument(); + expect(screen.getByText("N/A")).toBeInTheDocument(); }); }); - describe('pretty-prints metadata JSON', () => { - it('displays metadata in formatted JSON', () => { + describe("pretty-prints metadata JSON", () => { + it("displays metadata in formatted JSON", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); // Check for JSON structure with indentation - const metadataElement = screen.getByTestId('log-metadata'); + const metadataElement = screen.getByTestId("log-metadata"); expect(metadataElement).toBeInTheDocument(); expect(metadataElement.textContent).toContain('"userId"'); expect(metadataElement.textContent).toContain('"action"'); }); - it('formats nested JSON objects correctly', () => { + it("formats nested JSON objects correctly", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const metadataElement = screen.getByTestId('log-metadata'); + const metadataElement = screen.getByTestId("log-metadata"); expect(metadataElement.textContent).toContain('"details"'); expect(metadataElement.textContent).toContain('"ip"'); }); - it('shows N/A for null metadata', () => { + it("shows N/A for null metadata", () => { const logWithNullMetadata = { ...baseLog, metadata: null }; render(LogDetailModal, { props: { log: logWithNullMetadata, open: true } }); - const metadataSection = screen.getByTestId('metadata-section'); - expect(metadataSection).toHaveTextContent('N/A'); + const metadataSection = screen.getByTestId("metadata-section"); + expect(metadataSection).toHaveTextContent("N/A"); }); - it('handles empty object metadata', () => { + it("handles empty object metadata", () => { const logWithEmptyMetadata = { ...baseLog, metadata: {} }; render(LogDetailModal, { props: { log: logWithEmptyMetadata, open: true } }); - const metadataElement = screen.getByTestId('log-metadata'); - expect(metadataElement.textContent).toContain('{}'); + const metadataElement = screen.getByTestId("log-metadata"); + expect(metadataElement.textContent).toContain("{}"); }); }); - describe('copy buttons copy values to clipboard', () => { - it('copies log ID when copy button is clicked', async () => { + describe("copy buttons copy values to clipboard", () => { + it("copies log ID when copy button is clicked", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyIdButton = screen.getByTestId('copy-id-button'); + const copyIdButton = screen.getByTestId("copy-id-button"); await fireEvent.click(copyIdButton); - expect(mockClipboard.writeText).toHaveBeenCalledWith('log_123'); + expect(mockClipboard.writeText).toHaveBeenCalledWith("log_123"); }); - it('copies message when copy button is clicked', async () => { + it("copies message when copy button is clicked", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyMessageButton = screen.getByTestId('copy-message-button'); + const copyMessageButton = screen.getByTestId("copy-message-button"); await fireEvent.click(copyMessageButton); - expect(mockClipboard.writeText).toHaveBeenCalledWith('User logged in successfully'); + expect(mockClipboard.writeText).toHaveBeenCalledWith("User logged in successfully"); }); - it('copies metadata JSON when copy button is clicked', async () => { + it("copies metadata JSON when copy button is clicked", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyMetadataButton = screen.getByTestId('copy-metadata-button'); + const copyMetadataButton = screen.getByTestId("copy-metadata-button"); await fireEvent.click(copyMetadataButton); expect(mockClipboard.writeText).toHaveBeenCalledWith( @@ -214,101 +214,101 @@ describe('LogDetailModal', () => { ); }); - it('copies request ID when available', async () => { + it("copies request ID when available", async () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const copyRequestIdButton = screen.getByTestId('copy-request-id-button'); + const copyRequestIdButton = screen.getByTestId("copy-request-id-button"); await fireEvent.click(copyRequestIdButton); - expect(mockClipboard.writeText).toHaveBeenCalledWith('req_abc'); + expect(mockClipboard.writeText).toHaveBeenCalledWith("req_abc"); }); - it('does not show copy button for null metadata', () => { + it("does not show copy button for null metadata", () => { const logWithNullMetadata = { ...baseLog, metadata: null }; render(LogDetailModal, { props: { log: logWithNullMetadata, open: true } }); - expect(screen.queryByTestId('copy-metadata-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId("copy-metadata-button")).not.toBeInTheDocument(); }); }); - describe('closes on Escape key', () => { - it('calls onClose when Escape key is pressed', async () => { + describe("closes on Escape key", () => { + it("calls onClose when Escape key is pressed", async () => { const onClose = vi.fn(); render(LogDetailModal, { props: { log: baseLog, open: true, onClose } }); - await fireEvent.keyDown(document, { key: 'Escape' }); + await fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not call onClose for other keys', async () => { + it("does not call onClose for other keys", async () => { const onClose = vi.fn(); render(LogDetailModal, { props: { log: baseLog, open: true, onClose } }); - await fireEvent.keyDown(document, { key: 'Enter' }); + await fireEvent.keyDown(document, { key: "Enter" }); expect(onClose).not.toHaveBeenCalled(); }); }); - describe('closes on overlay click', () => { - it('calls onClose when overlay is clicked', async () => { + describe("closes on overlay click", () => { + it("calls onClose when overlay is clicked", async () => { const onClose = vi.fn(); render(LogDetailModal, { props: { log: baseLog, open: true, onClose } }); - const overlay = screen.getByTestId('modal-overlay'); + const overlay = screen.getByTestId("modal-overlay"); await fireEvent.click(overlay); expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not call onClose when content is clicked', async () => { + it("does not call onClose when content is clicked", async () => { const onClose = vi.fn(); render(LogDetailModal, { props: { log: baseLog, open: true, onClose } }); - const content = screen.getByTestId('modal-content'); + const content = screen.getByTestId("modal-content"); await fireEvent.click(content); expect(onClose).not.toHaveBeenCalled(); }); }); - describe('close button', () => { - it('renders close button', () => { + describe("close button", () => { + it("renders close button", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByTestId('close-button')).toBeInTheDocument(); + expect(screen.getByTestId("close-button")).toBeInTheDocument(); }); - it('calls onClose when close button is clicked', async () => { + it("calls onClose when close button is clicked", async () => { const onClose = vi.fn(); render(LogDetailModal, { props: { log: baseLog, open: true, onClose } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); await fireEvent.click(closeButton); expect(onClose).toHaveBeenCalledTimes(1); }); }); - describe('accessibility', () => { - it('has appropriate dialog role', () => { + describe("accessibility", () => { + it("has appropriate dialog role", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); }); - it('has appropriate aria-labelledby', () => { + it("has appropriate aria-labelledby", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveAttribute('aria-labelledby'); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-labelledby"); }); - it('close button has accessible label', () => { + it("close button has accessible label", () => { render(LogDetailModal, { props: { log: baseLog, open: true } }); - const closeButton = screen.getByTestId('close-button'); + const closeButton = screen.getByTestId("close-button"); expect(closeButton).toHaveAccessibleName(/close/i); }); }); diff --git a/src/lib/components/__tests__/log-row.component.test.ts b/src/lib/components/__tests__/log-row.component.test.ts index 982957d..4805432 100644 --- a/src/lib/components/__tests__/log-row.component.test.ts +++ b/src/lib/components/__tests__/log-row.component.test.ts @@ -1,29 +1,29 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Log } from '$lib/server/db/schema'; -import LogRow from '../log-row.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Log } from "$lib/server/db/schema"; +import LogRow from "../log-row.svelte"; // Mock formatTimestamp to have deterministic output -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatTimestamp: vi.fn((date: Date) => { - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); - const seconds = date.getUTCSeconds().toString().padStart(2, '0'); - const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0'); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); + const seconds = date.getUTCSeconds().toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); return `${hours}:${minutes}:${seconds}.${milliseconds}`; }), })); -describe('LogRow', () => { +describe("LogRow", () => { const baseLog: Log = { - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'User logged in successfully', - metadata: { userId: 'user_789' }, + level: "info", + message: "User logged in successfully", + metadata: { userId: "user_789" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -41,13 +41,13 @@ describe('LogRow', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'auth.ts', + sourceFile: "auth.ts", lineNumber: 42, - requestId: 'req_abc', - userId: 'user_789', - ipAddress: '192.168.1.1', - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + requestId: "req_abc", + userId: "user_789", + ipAddress: "192.168.1.1", + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", }; afterEach(() => { @@ -55,270 +55,270 @@ describe('LogRow', () => { vi.clearAllMocks(); }); - describe('displays timestamp in HH:mm:ss.SSS format', () => { - it('renders timestamp from log', () => { + describe("displays timestamp in HH:mm:ss.SSS format", () => { + it("renders timestamp from log", () => { render(LogRow, { props: { log: baseLog } }); - expect(screen.getByText('14:30:45.123')).toBeInTheDocument(); + expect(screen.getByText("14:30:45.123")).toBeInTheDocument(); }); - it('renders different timestamp correctly', () => { - const log = { ...baseLog, timestamp: new Date('2024-06-20T08:15:30.456Z') }; + it("renders different timestamp correctly", () => { + const log = { ...baseLog, timestamp: new Date("2024-06-20T08:15:30.456Z") }; render(LogRow, { props: { log } }); - expect(screen.getByText('08:15:30.456')).toBeInTheDocument(); + expect(screen.getByText("08:15:30.456")).toBeInTheDocument(); }); - it('handles midnight timestamp', () => { - const log = { ...baseLog, timestamp: new Date('2024-01-01T00:00:00.000Z') }; + it("handles midnight timestamp", () => { + const log = { ...baseLog, timestamp: new Date("2024-01-01T00:00:00.000Z") }; render(LogRow, { props: { log } }); - expect(screen.getByText('00:00:00.000')).toBeInTheDocument(); + expect(screen.getByText("00:00:00.000")).toBeInTheDocument(); }); - it('handles null timestamp gracefully', () => { + it("handles null timestamp gracefully", () => { const log = { ...baseLog, timestamp: null as unknown as Date }; render(LogRow, { props: { log } }); // Should show a placeholder or handle gracefully - expect(screen.getByTestId('log-timestamp-desktop')).toBeInTheDocument(); + expect(screen.getByTestId("log-timestamp-desktop")).toBeInTheDocument(); }); }); - describe('displays level badge', () => { - it('renders LevelBadge component with correct level', () => { + describe("displays level badge", () => { + it("renders LevelBadge component with correct level", () => { render(LogRow, { props: { log: baseLog } }); - expect(screen.getByText('INFO')).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); }); - it('renders debug level correctly', () => { - const log = { ...baseLog, level: 'debug' as const }; + it("renders debug level correctly", () => { + const log = { ...baseLog, level: "debug" as const }; render(LogRow, { props: { log } }); - expect(screen.getByText('DEBUG')).toBeInTheDocument(); + expect(screen.getByText("DEBUG")).toBeInTheDocument(); }); - it('renders warn level correctly', () => { - const log = { ...baseLog, level: 'warn' as const }; + it("renders warn level correctly", () => { + const log = { ...baseLog, level: "warn" as const }; render(LogRow, { props: { log } }); - expect(screen.getByText('WARN')).toBeInTheDocument(); + expect(screen.getByText("WARN")).toBeInTheDocument(); }); - it('renders error level correctly', () => { - const log = { ...baseLog, level: 'error' as const }; + it("renders error level correctly", () => { + const log = { ...baseLog, level: "error" as const }; render(LogRow, { props: { log } }); - expect(screen.getByText('ERROR')).toBeInTheDocument(); + expect(screen.getByText("ERROR")).toBeInTheDocument(); }); - it('renders fatal level correctly', () => { - const log = { ...baseLog, level: 'fatal' as const }; + it("renders fatal level correctly", () => { + const log = { ...baseLog, level: "fatal" as const }; render(LogRow, { props: { log } }); - expect(screen.getByText('FATAL')).toBeInTheDocument(); + expect(screen.getByText("FATAL")).toBeInTheDocument(); }); }); - describe('truncates long messages with ellipsis', () => { - it('displays short messages in full', () => { + describe("truncates long messages with ellipsis", () => { + it("displays short messages in full", () => { render(LogRow, { props: { log: baseLog } }); - expect(screen.getByText('User logged in successfully')).toBeInTheDocument(); + expect(screen.getByText("User logged in successfully")).toBeInTheDocument(); }); - it('truncates message exceeding max length', () => { + it("truncates message exceeding max length", () => { const longMessage = - 'This is a very long log message that should be truncated because it exceeds the maximum display length for a log row in the table view'; + "This is a very long log message that should be truncated because it exceeds the maximum display length for a log row in the table view"; const log = { ...baseLog, message: longMessage }; render(LogRow, { props: { log } }); - const messageElement = screen.getByTestId('log-message-desktop'); + const messageElement = screen.getByTestId("log-message-desktop"); expect(messageElement).toBeInTheDocument(); // Should have CSS truncation class - expect(messageElement).toHaveClass('truncate'); + expect(messageElement).toHaveClass("truncate"); }); - it('applies max-width constraint to message', () => { - const log = { ...baseLog, message: 'Some message' }; + it("applies max-width constraint to message", () => { + const log = { ...baseLog, message: "Some message" }; render(LogRow, { props: { log } }); - const messageElement = screen.getByTestId('log-message-desktop'); - expect(messageElement).toHaveClass('max-w-md'); + const messageElement = screen.getByTestId("log-message-desktop"); + expect(messageElement).toHaveClass("max-w-md"); }); }); - describe('emits click event for detail view', () => { - it('calls onclick when row is clicked', async () => { + describe("emits click event for detail view", () => { + it("calls onclick when row is clicked", async () => { const onclick = vi.fn(); render(LogRow, { props: { log: baseLog, onclick } }); - const row = screen.getByTestId('log-row'); + const row = screen.getByTestId("log-row"); await fireEvent.click(row); expect(onclick).toHaveBeenCalledTimes(1); expect(onclick).toHaveBeenCalledWith(baseLog); }); - it('passes the correct log object to onclick', async () => { + it("passes the correct log object to onclick", async () => { const onclick = vi.fn(); - const customLog = { ...baseLog, id: 'custom_log_id', message: 'Custom message' }; + const customLog = { ...baseLog, id: "custom_log_id", message: "Custom message" }; render(LogRow, { props: { log: customLog, onclick } }); - const row = screen.getByTestId('log-row'); + const row = screen.getByTestId("log-row"); await fireEvent.click(row); expect(onclick).toHaveBeenCalledWith(customLog); }); - it('does not throw when onclick is not provided', async () => { + it("does not throw when onclick is not provided", async () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); + const row = screen.getByTestId("log-row"); await expect(fireEvent.click(row)).resolves.not.toThrow(); }); - it('row has cursor-pointer for visual feedback', () => { + it("row has cursor-pointer for visual feedback", () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveClass('cursor-pointer'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveClass("cursor-pointer"); }); - it('row has hover state styling', () => { + it("row has hover state styling", () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); - expect(row.className).toContain('hover:'); + const row = screen.getByTestId("log-row"); + expect(row.className).toContain("hover:"); }); }); - describe('accessibility', () => { - it('row is focusable via keyboard', () => { + describe("accessibility", () => { + it("row is focusable via keyboard", () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveAttribute('tabindex', '0'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveAttribute("tabindex", "0"); }); - it('triggers onclick on Enter key press', async () => { + it("triggers onclick on Enter key press", async () => { const onclick = vi.fn(); render(LogRow, { props: { log: baseLog, onclick } }); - const row = screen.getByTestId('log-row'); - await fireEvent.keyDown(row, { key: 'Enter' }); + const row = screen.getByTestId("log-row"); + await fireEvent.keyDown(row, { key: "Enter" }); expect(onclick).toHaveBeenCalledTimes(1); expect(onclick).toHaveBeenCalledWith(baseLog); }); - it('triggers onclick on Space key press', async () => { + it("triggers onclick on Space key press", async () => { const onclick = vi.fn(); render(LogRow, { props: { log: baseLog, onclick } }); - const row = screen.getByTestId('log-row'); - await fireEvent.keyDown(row, { key: ' ' }); + const row = screen.getByTestId("log-row"); + await fireEvent.keyDown(row, { key: " " }); expect(onclick).toHaveBeenCalledTimes(1); }); }); - describe('displays source info when available', () => { - it('shows source file and line number', () => { + describe("displays source info when available", () => { + it("shows source file and line number", () => { render(LogRow, { props: { log: baseLog } }); - expect(screen.getByText('auth.ts:42')).toBeInTheDocument(); + expect(screen.getByText("auth.ts:42")).toBeInTheDocument(); }); - it('hides source info when sourceFile is null', () => { + it("hides source info when sourceFile is null", () => { const log = { ...baseLog, sourceFile: null, lineNumber: null }; render(LogRow, { props: { log } }); expect(screen.queryByText(/auth\.ts/)).not.toBeInTheDocument(); }); - it('shows source file without line number when lineNumber is null', () => { + it("shows source file without line number when lineNumber is null", () => { const log = { ...baseLog, lineNumber: null }; render(LogRow, { props: { log } }); - expect(screen.getByText('auth.ts')).toBeInTheDocument(); - expect(screen.queryByText('auth.ts:42')).not.toBeInTheDocument(); + expect(screen.getByText("auth.ts")).toBeInTheDocument(); + expect(screen.queryByText("auth.ts:42")).not.toBeInTheDocument(); }); }); - describe('highlight new logs', () => { - it('applies log-new class when isNew is true', () => { + describe("highlight new logs", () => { + it("applies log-new class when isNew is true", () => { render(LogRow, { props: { log: baseLog, isNew: true } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveClass('log-new'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveClass("log-new"); }); - it('does not apply log-new class when isNew is false', () => { + it("does not apply log-new class when isNew is false", () => { render(LogRow, { props: { log: baseLog, isNew: false } }); - const row = screen.getByTestId('log-row'); - expect(row).not.toHaveClass('log-new'); + const row = screen.getByTestId("log-row"); + expect(row).not.toHaveClass("log-new"); }); - it('does not apply log-new class when isNew is undefined', () => { + it("does not apply log-new class when isNew is undefined", () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); - expect(row).not.toHaveClass('log-new'); + const row = screen.getByTestId("log-row"); + expect(row).not.toHaveClass("log-new"); }); }); - describe('isSelected prop for keyboard navigation', () => { - it('renders without isSelected prop (default false)', () => { + describe("isSelected prop for keyboard navigation", () => { + it("renders without isSelected prop (default false)", () => { render(LogRow, { props: { log: baseLog } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveAttribute('data-selected', 'false'); - expect(row).not.toHaveAttribute('aria-current'); - expect(row).not.toHaveClass('bg-primary/10'); - expect(row).not.toHaveClass('ring-1'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveAttribute("data-selected", "false"); + expect(row).not.toHaveAttribute("aria-current"); + expect(row).not.toHaveClass("bg-primary/10"); + expect(row).not.toHaveClass("ring-1"); }); - it('applies selected class when isSelected=true', () => { + it("applies selected class when isSelected=true", () => { render(LogRow, { props: { log: baseLog, isSelected: true } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveClass('bg-primary/10'); - expect(row).toHaveClass('ring-1'); - expect(row).toHaveClass('ring-primary/50'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveClass("bg-primary/10"); + expect(row).toHaveClass("ring-1"); + expect(row).toHaveClass("ring-primary/50"); }); - it('does not apply selected class when isSelected=false', () => { + it("does not apply selected class when isSelected=false", () => { render(LogRow, { props: { log: baseLog, isSelected: false } }); - const row = screen.getByTestId('log-row'); - expect(row).not.toHaveClass('bg-primary/10'); - expect(row).not.toHaveClass('ring-1'); - expect(row).not.toHaveClass('ring-primary/50'); + const row = screen.getByTestId("log-row"); + expect(row).not.toHaveClass("bg-primary/10"); + expect(row).not.toHaveClass("ring-1"); + expect(row).not.toHaveClass("ring-primary/50"); }); it('has data-selected="true" when selected', () => { render(LogRow, { props: { log: baseLog, isSelected: true } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveAttribute('data-selected', 'true'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveAttribute("data-selected", "true"); }); it('has aria-current="true" when selected', () => { render(LogRow, { props: { log: baseLog, isSelected: true } }); - const row = screen.getByTestId('log-row'); - expect(row).toHaveAttribute('aria-current', 'true'); + const row = screen.getByTestId("log-row"); + expect(row).toHaveAttribute("aria-current", "true"); }); - it('does not have aria-current when not selected', () => { + it("does not have aria-current when not selected", () => { render(LogRow, { props: { log: baseLog, isSelected: false } }); - const row = screen.getByTestId('log-row'); - expect(row).not.toHaveAttribute('aria-current'); + const row = screen.getByTestId("log-row"); + expect(row).not.toHaveAttribute("aria-current"); }); }); }); diff --git a/src/lib/components/__tests__/log-stream-skeleton.component.test.ts b/src/lib/components/__tests__/log-stream-skeleton.component.test.ts index ea69157..645c52d 100644 --- a/src/lib/components/__tests__/log-stream-skeleton.component.test.ts +++ b/src/lib/components/__tests__/log-stream-skeleton.component.test.ts @@ -1,53 +1,53 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import LogStreamSkeleton from '../log-stream-skeleton.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import LogStreamSkeleton from "../log-stream-skeleton.svelte"; -describe('LogStreamSkeleton', () => { +describe("LogStreamSkeleton", () => { afterEach(() => { cleanup(); }); - describe('structure and layout', () => { - it('renders skeleton container', () => { + describe("structure and layout", () => { + it("renders skeleton container", () => { render(LogStreamSkeleton); - expect(screen.getByTestId('log-stream-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId("log-stream-skeleton")).toBeInTheDocument(); }); - it('renders header skeleton', () => { + it("renders header skeleton", () => { render(LogStreamSkeleton); - expect(screen.getByTestId('log-stream-skeleton-header')).toBeInTheDocument(); + expect(screen.getByTestId("log-stream-skeleton-header")).toBeInTheDocument(); }); - it('renders filters bar skeleton', () => { + it("renders filters bar skeleton", () => { render(LogStreamSkeleton); - expect(screen.getByTestId('log-stream-skeleton-filters')).toBeInTheDocument(); + expect(screen.getByTestId("log-stream-skeleton-filters")).toBeInTheDocument(); }); - it('renders table skeleton', () => { + it("renders table skeleton", () => { render(LogStreamSkeleton); - expect(screen.getByTestId('log-stream-skeleton-table')).toBeInTheDocument(); + expect(screen.getByTestId("log-stream-skeleton-table")).toBeInTheDocument(); }); }); - describe('table skeleton rows', () => { - it('renders multiple skeleton rows', () => { + describe("table skeleton rows", () => { + it("renders multiple skeleton rows", () => { render(LogStreamSkeleton); - const rows = screen.getAllByTestId('log-stream-skeleton-row'); + const rows = screen.getAllByTestId("log-stream-skeleton-row"); expect(rows.length).toBeGreaterThanOrEqual(8); }); }); - describe('accessibility and animation', () => { - it('container has proper spacing', () => { + describe("accessibility and animation", () => { + it("container has proper spacing", () => { render(LogStreamSkeleton); - const container = screen.getByTestId('log-stream-skeleton'); - expect(container).toHaveClass('space-y-6'); + const container = screen.getByTestId("log-stream-skeleton"); + expect(container).toHaveClass("space-y-6"); }); }); }); diff --git a/src/lib/components/__tests__/log-table.component.test.ts b/src/lib/components/__tests__/log-table.component.test.ts index 5b6baa4..b0eca94 100644 --- a/src/lib/components/__tests__/log-table.component.test.ts +++ b/src/lib/components/__tests__/log-table.component.test.ts @@ -1,28 +1,28 @@ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Log } from '$lib/server/db/schema'; -import LogTable from '../log-table.svelte'; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Log } from "$lib/server/db/schema"; +import LogTable from "../log-table.svelte"; // Mock formatTimestamp to have deterministic output -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatTimestamp: vi.fn((date: Date) => { - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); - const seconds = date.getUTCSeconds().toString().padStart(2, '0'); - const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0'); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); + const seconds = date.getUTCSeconds().toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); return `${hours}:${minutes}:${seconds}.${milliseconds}`; }), })); -describe('LogTable', () => { +describe("LogTable", () => { const createLog = (overrides: Partial = {}): Log => ({ - id: 'log_123', - projectId: 'proj_456', + id: "log_123", + projectId: "proj_456", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'Test log message', + level: "info", + message: "Test log message", metadata: null, timeUnixNano: null, observedTimeUnixNano: null, @@ -46,24 +46,24 @@ describe('LogTable', () => { requestId: null, userId: null, ipAddress: null, - timestamp: new Date('2024-01-15T14:30:45.123Z'), - search: '', + timestamp: new Date("2024-01-15T14:30:45.123Z"), + search: "", ...overrides, }); const sampleLogs: Log[] = [ - createLog({ id: 'log_1', message: 'First log message', level: 'info' }), + createLog({ id: "log_1", message: "First log message", level: "info" }), createLog({ - id: 'log_2', - message: 'Second log message', - level: 'error', - timestamp: new Date('2024-01-15T14:31:00.000Z'), + id: "log_2", + message: "Second log message", + level: "error", + timestamp: new Date("2024-01-15T14:31:00.000Z"), }), createLog({ - id: 'log_3', - message: 'Third log message', - level: 'debug', - timestamp: new Date('2024-01-15T14:32:00.000Z'), + id: "log_3", + message: "Third log message", + level: "debug", + timestamp: new Date("2024-01-15T14:32:00.000Z"), }), ]; @@ -72,415 +72,415 @@ describe('LogTable', () => { vi.clearAllMocks(); }); - describe('renders header row', () => { - it('displays table header with correct columns', () => { + describe("renders header row", () => { + it("displays table header with correct columns", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const header = screen.getByTestId('log-table-header'); + const header = screen.getByTestId("log-table-header"); expect(header).toBeInTheDocument(); }); - it('shows Time column header', () => { + it("shows Time column header", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText("Time")).toBeInTheDocument(); }); - it('shows Level column header', () => { + it("shows Level column header", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - expect(screen.getByText('Level')).toBeInTheDocument(); + expect(screen.getByText("Level")).toBeInTheDocument(); }); - it('shows Message column header', () => { + it("shows Message column header", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - expect(screen.getByText('Message')).toBeInTheDocument(); + expect(screen.getByText("Message")).toBeInTheDocument(); }); - it('renders header even when logs are empty', () => { + it("renders header even when logs are empty", () => { render(LogTable, { props: { logs: [], loading: false } }); - expect(screen.getByTestId('log-table-header')).toBeInTheDocument(); + expect(screen.getByTestId("log-table-header")).toBeInTheDocument(); }); }); - describe('renders log rows', () => { - it('displays all provided logs', () => { + describe("renders log rows", () => { + it("displays all provided logs", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const rows = screen.getAllByTestId('log-row'); + const rows = screen.getAllByTestId("log-row"); expect(rows).toHaveLength(3); }); - it('renders LogRow component for each log', () => { + it("renders LogRow component for each log", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const table = screen.getByRole('table'); - expect(within(table).getByText('First log message')).toBeInTheDocument(); - expect(within(table).getByText('Second log message')).toBeInTheDocument(); - expect(within(table).getByText('Third log message')).toBeInTheDocument(); + const table = screen.getByRole("table"); + expect(within(table).getByText("First log message")).toBeInTheDocument(); + expect(within(table).getByText("Second log message")).toBeInTheDocument(); + expect(within(table).getByText("Third log message")).toBeInTheDocument(); }); - it('renders log levels correctly', () => { + it("renders log levels correctly", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const table = screen.getByRole('table'); - expect(within(table).getByText('INFO')).toBeInTheDocument(); - expect(within(table).getByText('ERROR')).toBeInTheDocument(); - expect(within(table).getByText('DEBUG')).toBeInTheDocument(); + const table = screen.getByRole("table"); + expect(within(table).getByText("INFO")).toBeInTheDocument(); + expect(within(table).getByText("ERROR")).toBeInTheDocument(); + expect(within(table).getByText("DEBUG")).toBeInTheDocument(); }); - it('renders timestamps correctly', () => { + it("renders timestamps correctly", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const table = screen.getByRole('table'); - expect(within(table).getByText('14:30:45.123')).toBeInTheDocument(); - expect(within(table).getByText('14:31:00.000')).toBeInTheDocument(); - expect(within(table).getByText('14:32:00.000')).toBeInTheDocument(); + const table = screen.getByRole("table"); + expect(within(table).getByText("14:30:45.123")).toBeInTheDocument(); + expect(within(table).getByText("14:31:00.000")).toBeInTheDocument(); + expect(within(table).getByText("14:32:00.000")).toBeInTheDocument(); }); - it('propagates onLogClick callback to log rows', async () => { + it("propagates onLogClick callback to log rows", async () => { const onLogClick = vi.fn(); render(LogTable, { props: { logs: sampleLogs, loading: false, onLogClick } }); - const rows = screen.getAllByTestId('log-row'); + const rows = screen.getAllByTestId("log-row"); await rows[0]!.click(); expect(onLogClick).toHaveBeenCalledTimes(1); expect(onLogClick).toHaveBeenCalledWith(sampleLogs[0]); }); - it('renders single log correctly', () => { - const singleLog = [createLog({ id: 'single_log', message: 'Only one log' })]; + it("renders single log correctly", () => { + const singleLog = [createLog({ id: "single_log", message: "Only one log" })]; render(LogTable, { props: { logs: singleLog, loading: false } }); - const table = screen.getByRole('table'); - const rows = screen.getAllByTestId('log-row'); + const table = screen.getByRole("table"); + const rows = screen.getAllByTestId("log-row"); expect(rows).toHaveLength(1); - expect(within(table).getByText('Only one log')).toBeInTheDocument(); + expect(within(table).getByText("Only one log")).toBeInTheDocument(); }); }); - describe('shows skeleton during loading', () => { - it('displays skeleton rows when loading is true', () => { + describe("shows skeleton during loading", () => { + it("displays skeleton rows when loading is true", () => { render(LogTable, { props: { logs: [], loading: true } }); - const skeletons = screen.getAllByTestId('log-table-skeleton-row'); + const skeletons = screen.getAllByTestId("log-table-skeleton-row"); expect(skeletons.length).toBeGreaterThan(0); }); - it('renders multiple skeleton rows for loading state', () => { + it("renders multiple skeleton rows for loading state", () => { render(LogTable, { props: { logs: [], loading: true } }); - const skeletons = screen.getAllByTestId('log-table-skeleton-row'); + const skeletons = screen.getAllByTestId("log-table-skeleton-row"); expect(skeletons.length).toBeGreaterThanOrEqual(5); }); - it('hides log rows when loading', () => { + it("hides log rows when loading", () => { render(LogTable, { props: { logs: sampleLogs, loading: true } }); - expect(screen.queryAllByTestId('log-row')).toHaveLength(0); + expect(screen.queryAllByTestId("log-row")).toHaveLength(0); }); - it('shows header even when loading', () => { + it("shows header even when loading", () => { render(LogTable, { props: { logs: [], loading: true } }); - expect(screen.getByTestId('log-table-header')).toBeInTheDocument(); + expect(screen.getByTestId("log-table-header")).toBeInTheDocument(); }); - it('skeleton rows have animated pulse effect', () => { + it("skeleton rows have animated pulse effect", () => { render(LogTable, { props: { logs: [], loading: true } }); - const skeleton = screen.getAllByTestId('log-table-skeleton-row')[0]!; + const skeleton = screen.getAllByTestId("log-table-skeleton-row")[0]!; // Check that skeleton children have animation class - const skeletonElements = within(skeleton).getAllByRole('presentation', { hidden: true }); + const skeletonElements = within(skeleton).getAllByRole("presentation", { hidden: true }); expect(skeletonElements.length).toBeGreaterThan(0); }); }); - describe('shows empty state when no logs', () => { - it('displays empty state message when logs array is empty', () => { + describe("shows empty state when no logs", () => { + it("displays empty state message when logs array is empty", () => { render(LogTable, { props: { logs: [], loading: false } }); - const emptyStates = screen.getAllByTestId('log-table-empty'); + const emptyStates = screen.getAllByTestId("log-table-empty"); expect(emptyStates.length).toBeGreaterThan(0); }); it('shows "No logs" text in empty state', () => { render(LogTable, { props: { logs: [], loading: false } }); - const table = screen.getByRole('table'); + const table = screen.getByRole("table"); expect(within(table).getByText(/no logs/i)).toBeInTheDocument(); }); - it('hides empty state when logs are present', () => { + it("hides empty state when logs are present", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - expect(screen.queryByTestId('log-table-empty')).not.toBeInTheDocument(); + expect(screen.queryByTestId("log-table-empty")).not.toBeInTheDocument(); }); - it('hides empty state when loading', () => { + it("hides empty state when loading", () => { render(LogTable, { props: { logs: [], loading: true } }); - expect(screen.queryByTestId('log-table-empty')).not.toBeInTheDocument(); + expect(screen.queryByTestId("log-table-empty")).not.toBeInTheDocument(); }); - it('shows header even with empty state', () => { + it("shows header even with empty state", () => { render(LogTable, { props: { logs: [], loading: false } }); - expect(screen.getByTestId('log-table-header')).toBeInTheDocument(); + expect(screen.getByTestId("log-table-header")).toBeInTheDocument(); }); - it('empty state has appropriate styling', () => { + it("empty state has appropriate styling", () => { render(LogTable, { props: { logs: [], loading: false } }); - const emptyStates = screen.getAllByTestId('log-table-empty'); + const emptyStates = screen.getAllByTestId("log-table-empty"); // Check that at least one has the styling (they all should) const hasCorrectStyling = emptyStates.some((state) => - state.classList.contains('text-muted-foreground'), + state.classList.contains("text-muted-foreground"), ); expect(hasCorrectStyling).toBe(true); }); }); - describe('distinguishes empty state from no filter results', () => { + describe("distinguishes empty state from no filter results", () => { it('shows "No logs yet" message when hasFilters is false', () => { render(LogTable, { props: { logs: [], loading: false, hasFilters: false } }); - const table = screen.getByRole('table'); + const table = screen.getByRole("table"); expect(within(table).getByText(/no logs yet/i)).toBeInTheDocument(); }); it('shows "No logs match your filters" message when hasFilters is true', () => { render(LogTable, { props: { logs: [], loading: false, hasFilters: true } }); - const table = screen.getByRole('table'); + const table = screen.getByRole("table"); expect(within(table).getByText(/no logs match your filters/i)).toBeInTheDocument(); }); it('uses data-testid="log-table-empty" for empty project state', () => { render(LogTable, { props: { logs: [], loading: false, hasFilters: false } }); - const emptyStates = screen.getAllByTestId('log-table-empty'); + const emptyStates = screen.getAllByTestId("log-table-empty"); expect(emptyStates.length).toBeGreaterThan(0); }); it('uses data-testid="log-table-no-results" for filtered no-results state', () => { render(LogTable, { props: { logs: [], loading: false, hasFilters: true } }); - const noResultsStates = screen.getAllByTestId('log-table-no-results'); + const noResultsStates = screen.getAllByTestId("log-table-no-results"); expect(noResultsStates.length).toBeGreaterThan(0); }); - it('defaults to empty state message when hasFilters is not provided', () => { + it("defaults to empty state message when hasFilters is not provided", () => { render(LogTable, { props: { logs: [], loading: false } }); - const table = screen.getByRole('table'); + const table = screen.getByRole("table"); expect(within(table).getByText(/no logs yet/i)).toBeInTheDocument(); }); }); - describe('table structure and accessibility', () => { - it('renders as a proper table element', () => { + describe("table structure and accessibility", () => { + it("renders as a proper table element", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); }); - it('has proper table semantics', () => { + it("has proper table semantics", () => { render(LogTable, { props: { logs: sampleLogs, loading: false } }); - const table = screen.getByRole('table'); + const table = screen.getByRole("table"); expect(table).toBeInTheDocument(); }); - it('applies custom className when provided', () => { - render(LogTable, { props: { logs: sampleLogs, loading: false, class: 'custom-class' } }); + it("applies custom className when provided", () => { + render(LogTable, { props: { logs: sampleLogs, loading: false, class: "custom-class" } }); - const tableContainer = screen.getByTestId('log-table'); - expect(tableContainer).toHaveClass('custom-class'); + const tableContainer = screen.getByTestId("log-table"); + expect(tableContainer).toHaveClass("custom-class"); }); }); - describe('column sorting', () => { + describe("column sorting", () => { // Logs with distinct timestamps and levels for predictable sorting tests const sortableLogs: Log[] = [ createLog({ - id: 'log_a', - message: 'Alpha message', - level: 'error', - timestamp: new Date('2024-01-15T14:30:00.000Z'), + id: "log_a", + message: "Alpha message", + level: "error", + timestamp: new Date("2024-01-15T14:30:00.000Z"), }), createLog({ - id: 'log_b', - message: 'Beta message', - level: 'debug', - timestamp: new Date('2024-01-15T14:32:00.000Z'), + id: "log_b", + message: "Beta message", + level: "debug", + timestamp: new Date("2024-01-15T14:32:00.000Z"), }), createLog({ - id: 'log_c', - message: 'Charlie message', - level: 'warn', - timestamp: new Date('2024-01-15T14:31:00.000Z'), + id: "log_c", + message: "Charlie message", + level: "warn", + timestamp: new Date("2024-01-15T14:31:00.000Z"), }), ]; - it('renders sortable column headers as buttons', () => { + it("renders sortable column headers as buttons", () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const header = screen.getByTestId('log-table-header'); - expect(within(header).getByRole('button', { name: /sort by time/i })).toBeInTheDocument(); - expect(within(header).getByRole('button', { name: /sort by level/i })).toBeInTheDocument(); - expect(within(header).getByRole('button', { name: /sort by message/i })).toBeInTheDocument(); + const header = screen.getByTestId("log-table-header"); + expect(within(header).getByRole("button", { name: /sort by time/i })).toBeInTheDocument(); + expect(within(header).getByRole("button", { name: /sort by level/i })).toBeInTheDocument(); + expect(within(header).getByRole("button", { name: /sort by message/i })).toBeInTheDocument(); }); - it('sorts logs by timestamp ascending when Time header clicked', async () => { + it("sorts logs by timestamp ascending when Time header clicked", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // After ascending sort by time: Alpha (14:30) -> Charlie (14:31) -> Beta (14:32) - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('Beta message')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("Alpha message")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("Charlie message")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("Beta message")).toBeInTheDocument(); }); - it('sorts logs by timestamp descending on second click', async () => { + it("sorts logs by timestamp descending on second click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // asc await fireEvent.click(timeButton); // desc // After descending sort by time: Beta (14:32) -> Charlie (14:31) -> Alpha (14:30) - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('Alpha message')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("Beta message")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("Charlie message")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("Alpha message")).toBeInTheDocument(); }); - it('resets sort on third click', async () => { + it("resets sort on third click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // asc await fireEvent.click(timeButton); // desc await fireEvent.click(timeButton); // reset // Original order restored: Alpha -> Beta -> Charlie - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('Charlie message')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("Alpha message")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("Beta message")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("Charlie message")).toBeInTheDocument(); }); - it('sorts logs by level severity ascending', async () => { + it("sorts logs by level severity ascending", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const levelButton = screen.getByRole('button', { name: /sort by level/i }); + const levelButton = screen.getByRole("button", { name: /sort by level/i }); await fireEvent.click(levelButton); // Level priority: debug (1) < warn (3) < error (4) // Ascending: debug -> warn -> error - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('DEBUG')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('ERROR')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("DEBUG")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("WARN")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("ERROR")).toBeInTheDocument(); }); - it('sorts logs by level severity descending on second click', async () => { + it("sorts logs by level severity descending on second click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const levelButton = screen.getByRole('button', { name: /sort by level/i }); + const levelButton = screen.getByRole("button", { name: /sort by level/i }); await fireEvent.click(levelButton); // asc await fireEvent.click(levelButton); // desc // Descending: error -> warn -> debug - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('ERROR')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('DEBUG')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("ERROR")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("WARN")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("DEBUG")).toBeInTheDocument(); }); - it('sorts logs by message alphabetically ascending', async () => { + it("sorts logs by message alphabetically ascending", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const messageButton = screen.getByRole('button', { name: /sort by message/i }); + const messageButton = screen.getByRole("button", { name: /sort by message/i }); await fireEvent.click(messageButton); // Alphabetically: Alpha -> Beta -> Charlie - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('Alpha message')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('Charlie message')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("Alpha message")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("Beta message")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("Charlie message")).toBeInTheDocument(); }); - it('sorts logs by message alphabetically descending on second click', async () => { + it("sorts logs by message alphabetically descending on second click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const messageButton = screen.getByRole('button', { name: /sort by message/i }); + const messageButton = screen.getByRole("button", { name: /sort by message/i }); await fireEvent.click(messageButton); // asc await fireEvent.click(messageButton); // desc // Descending: Charlie -> Beta -> Alpha - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('Charlie message')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('Beta message')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('Alpha message')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("Charlie message")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("Beta message")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("Alpha message")).toBeInTheDocument(); }); - it('switching sort columns resets to ascending', async () => { + it("switching sort columns resets to ascending", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); - const levelButton = screen.getByRole('button', { name: /sort by level/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); + const levelButton = screen.getByRole("button", { name: /sort by level/i }); await fireEvent.click(timeButton); // time asc await fireEvent.click(timeButton); // time desc await fireEvent.click(levelButton); // level asc (new column) // After switching to level, should be ascending: debug -> warn -> error - const rows = screen.getAllByTestId('log-row'); - expect(within(rows[0]!).getByText('DEBUG')).toBeInTheDocument(); - expect(within(rows[1]!).getByText('WARN')).toBeInTheDocument(); - expect(within(rows[2]!).getByText('ERROR')).toBeInTheDocument(); + const rows = screen.getAllByTestId("log-row"); + expect(within(rows[0]!).getByText("DEBUG")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("WARN")).toBeInTheDocument(); + expect(within(rows[2]!).getByText("ERROR")).toBeInTheDocument(); }); - it('displays sort direction indicator on active column', async () => { + it("displays sort direction indicator on active column", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // aria-sort should be on the parent element - const timeHeader = timeButton.closest('th'); - expect(timeHeader).toHaveAttribute('aria-sort', 'ascending'); + const timeHeader = timeButton.closest("th"); + expect(timeHeader).toHaveAttribute("aria-sort", "ascending"); }); - it('displays descending indicator on second click', async () => { + it("displays descending indicator on second click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // asc await fireEvent.click(timeButton); // desc - const timeHeader = timeButton.closest('th'); - expect(timeHeader).toHaveAttribute('aria-sort', 'descending'); + const timeHeader = timeButton.closest("th"); + expect(timeHeader).toHaveAttribute("aria-sort", "descending"); }); - it('removes sort indicator after third click', async () => { + it("removes sort indicator after third click", async () => { render(LogTable, { props: { logs: sortableLogs, loading: false } }); - const timeButton = screen.getByRole('button', { name: /sort by time/i }); + const timeButton = screen.getByRole("button", { name: /sort by time/i }); await fireEvent.click(timeButton); // asc await fireEvent.click(timeButton); // desc await fireEvent.click(timeButton); // reset - const timeHeader = timeButton.closest('th'); - expect(timeHeader).toHaveAttribute('aria-sort', 'none'); + const timeHeader = timeButton.closest("th"); + expect(timeHeader).toHaveAttribute("aria-sort", "none"); }); }); }); diff --git a/src/lib/components/__tests__/project-card.component.test.ts b/src/lib/components/__tests__/project-card.component.test.ts index d0b09ab..227560c 100644 --- a/src/lib/components/__tests__/project-card.component.test.ts +++ b/src/lib/components/__tests__/project-card.component.test.ts @@ -1,28 +1,28 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import ProjectCard from '../project-card.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import ProjectCard from "../project-card.svelte"; // Mock formatRelativeTime to have deterministic output -vi.mock('$lib/utils/format', () => ({ +vi.mock("$lib/utils/format", () => ({ formatRelativeTime: vi.fn((date: Date) => { const diffMs = Date.now() - date.getTime(); const diffMinutes = Math.floor(diffMs / 60000); - if (diffMinutes < 1) return 'just now'; - if (diffMinutes === 1) return '1 minute ago'; + if (diffMinutes < 1) return "just now"; + if (diffMinutes === 1) return "1 minute ago"; if (diffMinutes < 60) return `${diffMinutes} minutes ago`; const diffHours = Math.floor(diffMinutes / 60); - if (diffHours === 1) return '1 hour ago'; + if (diffHours === 1) return "1 hour ago"; if (diffHours < 24) return `${diffHours} hours ago`; const diffDays = Math.floor(diffHours / 24); - if (diffDays === 1) return '1 day ago'; + if (diffDays === 1) return "1 day ago"; return `${diffDays} days ago`; }), })); -describe('ProjectCard', () => { +describe("ProjectCard", () => { const baseProject = { - id: 'proj_123', - name: 'my-backend', + id: "proj_123", + name: "my-backend", logCount: 15420, lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago }; @@ -32,33 +32,33 @@ describe('ProjectCard', () => { vi.clearAllMocks(); }); - it('displays project name', () => { + it("displays project name", () => { render(ProjectCard, { props: { project: baseProject } }); - expect(screen.getByText('my-backend')).toBeInTheDocument(); + expect(screen.getByText("my-backend")).toBeInTheDocument(); }); - it('displays log count formatted with commas', () => { + it("displays log count formatted with commas", () => { render(ProjectCard, { props: { project: baseProject } }); - expect(screen.getByText('15,420 logs')).toBeInTheDocument(); + expect(screen.getByText("15,420 logs")).toBeInTheDocument(); }); - it('displays log count singular for 1 log', () => { + it("displays log count singular for 1 log", () => { const project = { ...baseProject, logCount: 1 }; render(ProjectCard, { props: { project } }); - expect(screen.getByText('1 log')).toBeInTheDocument(); + expect(screen.getByText("1 log")).toBeInTheDocument(); }); - it('displays zero logs correctly', () => { + it("displays zero logs correctly", () => { const project = { ...baseProject, logCount: 0 }; render(ProjectCard, { props: { project } }); - expect(screen.getByText('0 logs')).toBeInTheDocument(); + expect(screen.getByText("0 logs")).toBeInTheDocument(); }); - it('displays relative last activity', () => { + it("displays relative last activity", () => { render(ProjectCard, { props: { project: baseProject } }); expect(screen.getByText(/Last log:/)).toBeInTheDocument(); @@ -69,21 +69,21 @@ describe('ProjectCard', () => { const project = { ...baseProject, lastActivity: null }; render(ProjectCard, { props: { project } }); - expect(screen.getByText('No logs yet')).toBeInTheDocument(); + expect(screen.getByText("No logs yet")).toBeInTheDocument(); }); - it('View Logs button is rendered', () => { + it("View Logs button is rendered", () => { render(ProjectCard, { props: { project: baseProject } }); // Button is now a regular button - navigation is handled by parent anchor wrapper - const button = screen.getByRole('button', { name: /view logs/i }); + const button = screen.getByRole("button", { name: /view logs/i }); expect(button).toBeInTheDocument(); }); - it('displays large log counts with proper formatting', () => { + it("displays large log counts with proper formatting", () => { const project = { ...baseProject, logCount: 1234567 }; render(ProjectCard, { props: { project } }); - expect(screen.getByText('1,234,567 logs')).toBeInTheDocument(); + expect(screen.getByText("1,234,567 logs")).toBeInTheDocument(); }); }); diff --git a/src/lib/components/__tests__/search-input.component.test.ts b/src/lib/components/__tests__/search-input.component.test.ts index 3b4b2ff..7e46dcf 100644 --- a/src/lib/components/__tests__/search-input.component.test.ts +++ b/src/lib/components/__tests__/search-input.component.test.ts @@ -1,8 +1,8 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import SearchInput from '../search-input.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import SearchInput from "../search-input.svelte"; -describe('SearchInput', () => { +describe("SearchInput", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -13,7 +13,7 @@ describe('SearchInput', () => { vi.clearAllMocks(); }); - it('renders search icon', () => { + it("renders search icon", () => { render(SearchInput); // Search icon should be rendered (lucide Search icon) @@ -21,58 +21,58 @@ describe('SearchInput', () => { expect(searchIcon).toBeInTheDocument(); }); - it('renders input with placeholder', () => { - render(SearchInput, { props: { placeholder: 'Search logs...' } }); + it("renders input with placeholder", () => { + render(SearchInput, { props: { placeholder: "Search logs..." } }); - const input = screen.getByPlaceholderText('Search logs...'); + const input = screen.getByPlaceholderText("Search logs..."); expect(input).toBeInTheDocument(); }); - it('renders with default placeholder when none provided', () => { + it("renders with default placeholder when none provided", () => { render(SearchInput); - const input = screen.getByPlaceholderText('Search...'); + const input = screen.getByPlaceholderText("Search..."); expect(input).toBeInTheDocument(); }); - describe('debounces input by 300ms', () => { - it('does not emit immediately on input', async () => { + describe("debounces input by 300ms", () => { + it("does not emit immediately on input", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); - await fireEvent.input(input, { target: { value: 'error' } }); + const input = screen.getByRole("textbox"); + await fireEvent.input(input, { target: { value: "error" } }); // Should not emit immediately expect(onSearch).not.toHaveBeenCalled(); }); - it('emits after 300ms debounce', async () => { + it("emits after 300ms debounce", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); - await fireEvent.input(input, { target: { value: 'error' } }); + const input = screen.getByRole("textbox"); + await fireEvent.input(input, { target: { value: "error" } }); // Advance timers by 300ms vi.advanceTimersByTime(300); expect(onSearch).toHaveBeenCalledTimes(1); - expect(onSearch).toHaveBeenCalledWith('error'); + expect(onSearch).toHaveBeenCalledWith("error"); }); - it('resets debounce timer on subsequent inputs', async () => { + it("resets debounce timer on subsequent inputs", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); // Type first value - await fireEvent.input(input, { target: { value: 'err' } }); + await fireEvent.input(input, { target: { value: "err" } }); vi.advanceTimersByTime(200); // 200ms passed // Type more before 300ms - await fireEvent.input(input, { target: { value: 'error' } }); + await fireEvent.input(input, { target: { value: "error" } }); vi.advanceTimersByTime(200); // 400ms total, but only 200ms since last input // Should not have emitted yet @@ -82,24 +82,24 @@ describe('SearchInput', () => { vi.advanceTimersByTime(100); // 300ms since last input expect(onSearch).toHaveBeenCalledTimes(1); - expect(onSearch).toHaveBeenCalledWith('error'); + expect(onSearch).toHaveBeenCalledWith("error"); }); - it('only emits final value after multiple rapid inputs', async () => { + it("only emits final value after multiple rapid inputs", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); - await fireEvent.input(input, { target: { value: 'e' } }); + await fireEvent.input(input, { target: { value: "e" } }); vi.advanceTimersByTime(50); - await fireEvent.input(input, { target: { value: 'er' } }); + await fireEvent.input(input, { target: { value: "er" } }); vi.advanceTimersByTime(50); - await fireEvent.input(input, { target: { value: 'err' } }); + await fireEvent.input(input, { target: { value: "err" } }); vi.advanceTimersByTime(50); - await fireEvent.input(input, { target: { value: 'erro' } }); + await fireEvent.input(input, { target: { value: "erro" } }); vi.advanceTimersByTime(50); - await fireEvent.input(input, { target: { value: 'error' } }); + await fireEvent.input(input, { target: { value: "error" } }); // Not yet expect(onSearch).not.toHaveBeenCalled(); @@ -107,121 +107,121 @@ describe('SearchInput', () => { vi.advanceTimersByTime(300); expect(onSearch).toHaveBeenCalledTimes(1); - expect(onSearch).toHaveBeenCalledWith('error'); + expect(onSearch).toHaveBeenCalledWith("error"); }); }); - describe('emits search event with value', () => { - it('emits empty string when input is cleared', async () => { + describe("emits search event with value", () => { + it("emits empty string when input is cleared", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); - await fireEvent.input(input, { target: { value: 'test' } }); + const input = screen.getByRole("textbox"); + await fireEvent.input(input, { target: { value: "test" } }); vi.advanceTimersByTime(300); - await fireEvent.input(input, { target: { value: '' } }); + await fireEvent.input(input, { target: { value: "" } }); vi.advanceTimersByTime(300); expect(onSearch).toHaveBeenCalledTimes(2); - expect(onSearch).toHaveBeenLastCalledWith(''); + expect(onSearch).toHaveBeenLastCalledWith(""); }); - it('trims whitespace from search value', async () => { + it("trims whitespace from search value", async () => { const onSearch = vi.fn(); render(SearchInput, { props: { onsearch: onSearch } }); - const input = screen.getByRole('textbox'); - await fireEvent.input(input, { target: { value: ' error ' } }); + const input = screen.getByRole("textbox"); + await fireEvent.input(input, { target: { value: " error " } }); vi.advanceTimersByTime(300); - expect(onSearch).toHaveBeenCalledWith('error'); + expect(onSearch).toHaveBeenCalledWith("error"); }); }); - it('accepts initial value prop', () => { - render(SearchInput, { props: { value: 'initial search' } }); + it("accepts initial value prop", () => { + render(SearchInput, { props: { value: "initial search" } }); - const input = screen.getByRole('textbox'); - expect(input).toHaveValue('initial search'); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("initial search"); }); - it('can be disabled', () => { + it("can be disabled", () => { render(SearchInput, { props: { disabled: true } }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); expect(input).toBeDisabled(); }); - describe('ref bindable prop', () => { - it('exposes input element via ref prop', () => { + describe("ref bindable prop", () => { + it("exposes input element via ref prop", () => { // We can verify the ref is bound by checking that the input element exists // and has the expected attributes when rendered render(SearchInput); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); expect(input).toBeInTheDocument(); - expect(input.tagName).toBe('INPUT'); + expect(input.tagName).toBe("INPUT"); }); - it('allows programmatic focus via ref', () => { + it("allows programmatic focus via ref", () => { render(SearchInput); - const input = screen.getByRole('textbox') as HTMLInputElement; + const input = screen.getByRole("textbox") as HTMLInputElement; input.focus(); expect(document.activeElement).toBe(input); }); }); - describe('onEscape callback', () => { - it('calls onEscape when Escape is pressed while focused', async () => { + describe("onEscape callback", () => { + it("calls onEscape when Escape is pressed while focused", async () => { const onEscape = vi.fn(); render(SearchInput, { props: { onEscape } }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); input.focus(); - await fireEvent.keyDown(input, { key: 'Escape' }); + await fireEvent.keyDown(input, { key: "Escape" }); expect(onEscape).toHaveBeenCalledTimes(1); }); - it('blurs input when Escape is pressed', async () => { + it("blurs input when Escape is pressed", async () => { render(SearchInput); - const input = screen.getByRole('textbox') as HTMLInputElement; + const input = screen.getByRole("textbox") as HTMLInputElement; input.focus(); expect(document.activeElement).toBe(input); - await fireEvent.keyDown(input, { key: 'Escape' }); + await fireEvent.keyDown(input, { key: "Escape" }); expect(document.activeElement).not.toBe(input); }); - it('does not call onEscape when other keys are pressed', async () => { + it("does not call onEscape when other keys are pressed", async () => { const onEscape = vi.fn(); render(SearchInput, { props: { onEscape } }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); input.focus(); - await fireEvent.keyDown(input, { key: 'Enter' }); - await fireEvent.keyDown(input, { key: 'a' }); - await fireEvent.keyDown(input, { key: 'Tab' }); - await fireEvent.keyDown(input, { key: 'ArrowDown' }); + await fireEvent.keyDown(input, { key: "Enter" }); + await fireEvent.keyDown(input, { key: "a" }); + await fireEvent.keyDown(input, { key: "Tab" }); + await fireEvent.keyDown(input, { key: "ArrowDown" }); expect(onEscape).not.toHaveBeenCalled(); }); - it('does not throw when onEscape is not provided', async () => { + it("does not throw when onEscape is not provided", async () => { render(SearchInput); - const input = screen.getByRole('textbox'); + const input = screen.getByRole("textbox"); input.focus(); // Should not throw - await expect(fireEvent.keyDown(input, { key: 'Escape' })).resolves.not.toThrow(); + await expect(fireEvent.keyDown(input, { key: "Escape" })).resolves.not.toThrow(); }); }); }); diff --git a/src/lib/components/__tests__/stats-skeleton.component.test.ts b/src/lib/components/__tests__/stats-skeleton.component.test.ts index 5fc9b67..8eadcc1 100644 --- a/src/lib/components/__tests__/stats-skeleton.component.test.ts +++ b/src/lib/components/__tests__/stats-skeleton.component.test.ts @@ -1,129 +1,129 @@ -import { cleanup, render, screen, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; -import StatsSkeleton from '../stats-skeleton.svelte'; +import { cleanup, render, screen, within } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import StatsSkeleton from "../stats-skeleton.svelte"; -describe('StatsSkeleton', () => { +describe("StatsSkeleton", () => { afterEach(() => { cleanup(); }); - describe('structure and layout', () => { - it('renders skeleton container', () => { + describe("structure and layout", () => { + it("renders skeleton container", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton")).toBeInTheDocument(); }); - it('renders header skeleton section', () => { + it("renders header skeleton section", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton-header')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton-header")).toBeInTheDocument(); }); - it('renders stats header section with title and description', () => { + it("renders stats header section with title and description", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton-subheader')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton-subheader")).toBeInTheDocument(); }); }); - describe('chart skeleton', () => { - it('renders chart skeleton section', () => { + describe("chart skeleton", () => { + it("renders chart skeleton section", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton-chart')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton-chart")).toBeInTheDocument(); }); - it('renders donut chart placeholder with circular shape', () => { + it("renders donut chart placeholder with circular shape", () => { render(StatsSkeleton); - const chartSection = screen.getByTestId('stats-skeleton-chart'); - const donut = within(chartSection).getByTestId('skeleton-donut'); - expect(donut).toHaveClass('rounded-full'); + const chartSection = screen.getByTestId("stats-skeleton-chart"); + const donut = within(chartSection).getByTestId("skeleton-donut"); + expect(donut).toHaveClass("rounded-full"); }); - it('donut skeleton has correct dimensions (200x200)', () => { + it("donut skeleton has correct dimensions (200x200)", () => { render(StatsSkeleton); - const donut = screen.getByTestId('skeleton-donut'); - expect(donut).toHaveClass('h-[200px]'); - expect(donut).toHaveClass('w-[200px]'); + const donut = screen.getByTestId("skeleton-donut"); + expect(donut).toHaveClass("h-[200px]"); + expect(donut).toHaveClass("w-[200px]"); }); }); - describe('legend skeleton', () => { - it('renders legend skeleton section', () => { + describe("legend skeleton", () => { + it("renders legend skeleton section", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton-legend')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton-legend")).toBeInTheDocument(); }); - it('renders multiple legend item skeletons', () => { + it("renders multiple legend item skeletons", () => { render(StatsSkeleton); - const legendItems = screen.getAllByTestId('skeleton-legend-item'); + const legendItems = screen.getAllByTestId("skeleton-legend-item"); expect(legendItems.length).toBeGreaterThanOrEqual(3); }); - it('legend item has color placeholder', () => { + it("legend item has color placeholder", () => { render(StatsSkeleton); - const legendItems = screen.getAllByTestId('skeleton-legend-item'); - expect(within(legendItems[0]!).getByTestId('skeleton-legend-color')).toBeInTheDocument(); + const legendItems = screen.getAllByTestId("skeleton-legend-item"); + expect(within(legendItems[0]!).getByTestId("skeleton-legend-color")).toBeInTheDocument(); }); - it('legend item has text placeholder', () => { + it("legend item has text placeholder", () => { render(StatsSkeleton); - const legendItems = screen.getAllByTestId('skeleton-legend-item'); - expect(within(legendItems[0]!).getByTestId('skeleton-legend-text')).toBeInTheDocument(); + const legendItems = screen.getAllByTestId("skeleton-legend-item"); + expect(within(legendItems[0]!).getByTestId("skeleton-legend-text")).toBeInTheDocument(); }); }); - describe('summary skeleton', () => { - it('renders summary skeleton section', () => { + describe("summary skeleton", () => { + it("renders summary skeleton section", () => { render(StatsSkeleton); - expect(screen.getByTestId('stats-skeleton-summary')).toBeInTheDocument(); + expect(screen.getByTestId("stats-skeleton-summary")).toBeInTheDocument(); }); - it('summary section is centered', () => { + it("summary section is centered", () => { render(StatsSkeleton); - const summary = screen.getByTestId('stats-skeleton-summary'); - expect(summary).toHaveClass('text-center'); + const summary = screen.getByTestId("stats-skeleton-summary"); + expect(summary).toHaveClass("text-center"); }); }); - describe('accessibility and animation', () => { - it('chart skeleton has pulse animation', () => { + describe("accessibility and animation", () => { + it("chart skeleton has pulse animation", () => { render(StatsSkeleton); - const donut = screen.getByTestId('skeleton-donut'); - expect(donut).toHaveClass('animate-pulse'); + const donut = screen.getByTestId("skeleton-donut"); + expect(donut).toHaveClass("animate-pulse"); }); - it('legend skeletons have pulse animation', () => { + it("legend skeletons have pulse animation", () => { render(StatsSkeleton); - const legendText = screen.getAllByTestId('skeleton-legend-text')[0]; - expect(legendText).toHaveClass('animate-pulse'); + const legendText = screen.getAllByTestId("skeleton-legend-text")[0]; + expect(legendText).toHaveClass("animate-pulse"); }); }); - describe('consistent dimensions', () => { - it('container has same spacing as real stats page', () => { + describe("consistent dimensions", () => { + it("container has same spacing as real stats page", () => { render(StatsSkeleton); - const container = screen.getByTestId('stats-skeleton'); - expect(container).toHaveClass('space-y-6'); + const container = screen.getByTestId("stats-skeleton"); + expect(container).toHaveClass("space-y-6"); }); - it('chart section has same padding as real page', () => { + it("chart section has same padding as real page", () => { render(StatsSkeleton); - const chartSection = screen.getByTestId('stats-skeleton-chart'); - expect(chartSection).toHaveClass('py-8'); + const chartSection = screen.getByTestId("stats-skeleton-chart"); + expect(chartSection).toHaveClass("py-8"); }); }); }); diff --git a/src/lib/components/__tests__/theme-toggle.component.test.ts b/src/lib/components/__tests__/theme-toggle.component.test.ts index ddabe69..63cf859 100644 --- a/src/lib/components/__tests__/theme-toggle.component.test.ts +++ b/src/lib/components/__tests__/theme-toggle.component.test.ts @@ -1,11 +1,11 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; // Mock mode-watcher module before importing component const mockToggleMode = vi.fn(); -let mockCurrentMode = 'light'; +let mockCurrentMode = "light"; -vi.mock('mode-watcher', () => ({ +vi.mock("mode-watcher", () => ({ mode: { get current() { return mockCurrentMode; @@ -15,20 +15,20 @@ vi.mock('mode-watcher', () => ({ })); // Import component after mock setup -import ThemeToggle from '../theme-toggle.svelte'; +import ThemeToggle from "../theme-toggle.svelte"; -describe('ThemeToggle', () => { +describe("ThemeToggle", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); - mockCurrentMode = 'light'; + mockCurrentMode = "light"; }); - it('renders sun icon in light mode', () => { - mockCurrentMode = 'light'; + it("renders sun icon in light mode", () => { + mockCurrentMode = "light"; render(ThemeToggle); - const button = screen.getByRole('button', { name: /toggle theme/i }); + const button = screen.getByRole("button", { name: /toggle theme/i }); expect(button).toBeInTheDocument(); // Sun icon should be visible in light mode @@ -36,11 +36,11 @@ describe('ThemeToggle', () => { expect(sunIcon).toBeInTheDocument(); }); - it('renders moon icon in dark mode', () => { - mockCurrentMode = 'dark'; + it("renders moon icon in dark mode", () => { + mockCurrentMode = "dark"; render(ThemeToggle); - const button = screen.getByRole('button', { name: /toggle theme/i }); + const button = screen.getByRole("button", { name: /toggle theme/i }); expect(button).toBeInTheDocument(); // Moon icon should be visible in dark mode @@ -48,11 +48,11 @@ describe('ThemeToggle', () => { expect(moonIcon).toBeInTheDocument(); }); - it('toggles theme on click', async () => { - mockCurrentMode = 'light'; + it("toggles theme on click", async () => { + mockCurrentMode = "light"; render(ThemeToggle); - const button = screen.getByRole('button', { name: /toggle theme/i }); + const button = screen.getByRole("button", { name: /toggle theme/i }); await button.click(); expect(mockToggleMode).toHaveBeenCalledTimes(1); diff --git a/src/lib/components/__tests__/time-range-picker.component.test.ts b/src/lib/components/__tests__/time-range-picker.component.test.ts index 76fa36c..b4621c8 100644 --- a/src/lib/components/__tests__/time-range-picker.component.test.ts +++ b/src/lib/components/__tests__/time-range-picker.component.test.ts @@ -1,105 +1,104 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import TimeRangePicker from '../time-range-picker.svelte'; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import TimeRangePicker from "../time-range-picker.svelte"; const TIME_RANGES = [ - { value: '15m', label: /15 minutes/i, initialValue: '1h' }, - { value: '1h', label: /last hour/i, initialValue: '15m' }, - { value: '24h', label: /24 hours/i, initialValue: '1h' }, - { value: '7d', label: /7 days/i, initialValue: '1h' }, + { value: "15m", label: /15 minutes/i, initialValue: "1h" }, + { value: "1h", label: /last hour/i, initialValue: "15m" }, + { value: "24h", label: /24 hours/i, initialValue: "1h" }, + { value: "7d", label: /7 days/i, initialValue: "1h" }, ] as const; -describe('TimeRangePicker', () => { +describe("TimeRangePicker", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - describe('renders 15m, 1h, 24h, 7d options', () => { - it.each(TIME_RANGES)('renders $value option', ({ label }) => { + describe("renders 15m, 1h, 24h, 7d options", () => { + it.each(TIME_RANGES)("renders $value option", ({ label }) => { render(TimeRangePicker); - expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); }); - it('renders all options in correct order', () => { + it("renders all options in correct order", () => { render(TimeRangePicker); - const buttons = screen.getAllByRole('button'); + const buttons = screen.getAllByRole("button"); expect(buttons).toHaveLength(4); - expect(buttons[0]).toHaveTextContent('15m'); - expect(buttons[1]).toHaveTextContent('1h'); - expect(buttons[2]).toHaveTextContent('24h'); - expect(buttons[3]).toHaveTextContent('7d'); + expect(buttons[0]).toHaveTextContent("15m"); + expect(buttons[1]).toHaveTextContent("1h"); + expect(buttons[2]).toHaveTextContent("24h"); + expect(buttons[3]).toHaveTextContent("7d"); }); }); - describe('highlights selected range', () => { - it.each(TIME_RANGES)('highlights $value when selected', ({ value, label }) => { + describe("highlights selected range", () => { + it.each(TIME_RANGES)("highlights $value when selected", ({ value, label }) => { render(TimeRangePicker, { props: { value } }); - const button = screen.getByRole('button', { name: label }); - expect(button).toHaveAttribute('data-selected', 'true'); - expect(button).toHaveAttribute('aria-pressed', 'true'); + const button = screen.getByRole("button", { name: label }); + expect(button).toHaveAttribute("data-selected", "true"); + expect(button).toHaveAttribute("aria-pressed", "true"); }); - it('does not highlight unselected options', () => { - render(TimeRangePicker, { props: { value: '1h' } }); + it("does not highlight unselected options", () => { + render(TimeRangePicker, { props: { value: "1h" } }); - expect(screen.getByRole('button', { name: /15 minutes/i })).not.toHaveAttribute( - 'data-selected', - 'true', + expect(screen.getByRole("button", { name: /15 minutes/i })).not.toHaveAttribute( + "data-selected", + "true", ); - expect(screen.getByRole('button', { name: /24 hours/i })).not.toHaveAttribute( - 'data-selected', - 'true', + expect(screen.getByRole("button", { name: /24 hours/i })).not.toHaveAttribute( + "data-selected", + "true", ); - expect(screen.getByRole('button', { name: /7 days/i })).not.toHaveAttribute( - 'data-selected', - 'true', + expect(screen.getByRole("button", { name: /7 days/i })).not.toHaveAttribute( + "data-selected", + "true", ); }); - it('defaults to 1h when no value provided', () => { + it("defaults to 1h when no value provided", () => { render(TimeRangePicker); - const button = screen.getByRole('button', { name: /last hour/i }); - expect(button).toHaveAttribute('data-selected', 'true'); - expect(button).toHaveAttribute('aria-pressed', 'true'); + const button = screen.getByRole("button", { name: /last hour/i }); + expect(button).toHaveAttribute("data-selected", "true"); + expect(button).toHaveAttribute("aria-pressed", "true"); }); }); - describe('emits change event with range value', () => { - it.each(TIME_RANGES)('emits change event when clicking $value', async ({ - value, - label, - initialValue, - }) => { - const onchange = vi.fn(); - render(TimeRangePicker, { props: { value: initialValue, onchange } }); + describe("emits change event with range value", () => { + it.each(TIME_RANGES)( + "emits change event when clicking $value", + async ({ value, label, initialValue }) => { + const onchange = vi.fn(); + render(TimeRangePicker, { props: { value: initialValue, onchange } }); - const button = screen.getByRole('button', { name: label }); - await fireEvent.click(button); + const button = screen.getByRole("button", { name: label }); + await fireEvent.click(button); - expect(onchange).toHaveBeenCalledTimes(1); - expect(onchange).toHaveBeenCalledWith(value); - }); + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenCalledWith(value); + }, + ); - it('does not emit change event when clicking already selected option', async () => { + it("does not emit change event when clicking already selected option", async () => { const onchange = vi.fn(); - render(TimeRangePicker, { props: { value: '1h', onchange } }); + render(TimeRangePicker, { props: { value: "1h", onchange } }); - const button = screen.getByRole('button', { name: /last hour/i }); + const button = screen.getByRole("button", { name: /last hour/i }); await fireEvent.click(button); expect(onchange).not.toHaveBeenCalled(); }); }); - it('can be disabled', () => { + it("can be disabled", () => { render(TimeRangePicker, { props: { disabled: true } }); - const buttons = screen.getAllByRole('button'); + const buttons = screen.getAllByRole("button"); for (const button of buttons) { expect(button).toBeDisabled(); } diff --git a/src/lib/components/__tests__/timeseries-chart.component.test.ts b/src/lib/components/__tests__/timeseries-chart.component.test.ts index 82898d6..0c2f345 100644 --- a/src/lib/components/__tests__/timeseries-chart.component.test.ts +++ b/src/lib/components/__tests__/timeseries-chart.component.test.ts @@ -1,189 +1,189 @@ -import { cleanup, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import TimeseriesChart from '../timeseries-chart.svelte'; +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import TimeseriesChart from "../timeseries-chart.svelte"; // Mock the browser environment check -vi.mock('$app/environment', () => ({ +vi.mock("$app/environment", () => ({ browser: false, // Set to false for testing non-browser states })); const mockData = [ - { timestamp: '2024-01-15T10:00:00.000Z', count: 10 }, - { timestamp: '2024-01-15T11:00:00.000Z', count: 25 }, - { timestamp: '2024-01-15T12:00:00.000Z', count: 15 }, + { timestamp: "2024-01-15T10:00:00.000Z", count: 10 }, + { timestamp: "2024-01-15T11:00:00.000Z", count: 25 }, + { timestamp: "2024-01-15T12:00:00.000Z", count: 15 }, ]; -describe('TimeseriesChart', () => { +describe("TimeseriesChart", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - describe('Container Rendering', () => { + describe("Container Rendering", () => { it('renders container with data-testid="timeseries-chart"', () => { - render(TimeseriesChart, { props: { data: mockData, range: '24h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + render(TimeseriesChart, { props: { data: mockData, range: "24h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('has accessible aria-label', () => { - render(TimeseriesChart, { props: { data: mockData, range: '24h' } }); - const chart = screen.getByTestId('timeseries-chart'); - expect(chart).toHaveAttribute('aria-label', 'Time series chart showing log volume over time'); + it("has accessible aria-label", () => { + render(TimeseriesChart, { props: { data: mockData, range: "24h" } }); + const chart = screen.getByTestId("timeseries-chart"); + expect(chart).toHaveAttribute("aria-label", "Time series chart showing log volume over time"); }); it('has role="figure"', () => { - render(TimeseriesChart, { props: { data: mockData, range: '24h' } }); - const chart = screen.getByTestId('timeseries-chart'); - expect(chart).toHaveAttribute('role', 'figure'); + render(TimeseriesChart, { props: { data: mockData, range: "24h" } }); + const chart = screen.getByTestId("timeseries-chart"); + expect(chart).toHaveAttribute("role", "figure"); }); - it('applies custom class when provided', () => { + it("applies custom class when provided", () => { render(TimeseriesChart, { - props: { data: mockData, range: '24h', class: 'custom-class' }, + props: { data: mockData, range: "24h", class: "custom-class" }, }); - expect(screen.getByTestId('timeseries-chart')).toHaveClass('custom-class'); + expect(screen.getByTestId("timeseries-chart")).toHaveClass("custom-class"); }); }); - describe('Loading State', () => { - it('shows skeleton placeholder when loading=true', () => { + describe("Loading State", () => { + it("shows skeleton placeholder when loading=true", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', loading: true }, + props: { data: [], range: "24h", loading: true }, }); - expect(screen.getByTestId('timeseries-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId("timeseries-skeleton")).toBeInTheDocument(); }); - it('skeleton has animate-pulse class', () => { + it("skeleton has animate-pulse class", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', loading: true }, + props: { data: [], range: "24h", loading: true }, }); - expect(screen.getByTestId('timeseries-skeleton')).toHaveClass('animate-pulse'); + expect(screen.getByTestId("timeseries-skeleton")).toHaveClass("animate-pulse"); }); - it('does not show empty state when loading', () => { + it("does not show empty state when loading", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', loading: true }, + props: { data: [], range: "24h", loading: true }, }); - expect(screen.queryByTestId('timeseries-empty')).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-empty")).not.toBeInTheDocument(); }); - it('does not show error state when loading', () => { + it("does not show error state when loading", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', loading: true, error: 'Some error' }, + props: { data: [], range: "24h", loading: true, error: "Some error" }, }); // Loading takes precedence - expect(screen.getByTestId('timeseries-skeleton')).toBeInTheDocument(); - expect(screen.queryByTestId('timeseries-error')).not.toBeInTheDocument(); + expect(screen.getByTestId("timeseries-skeleton")).toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-error")).not.toBeInTheDocument(); }); }); - describe('Error State', () => { - it('shows error message when error prop is provided', () => { + describe("Error State", () => { + it("shows error message when error prop is provided", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', error: 'Failed to load data' }, + props: { data: [], range: "24h", error: "Failed to load data" }, }); - expect(screen.getByTestId('timeseries-error')).toBeInTheDocument(); - expect(screen.getByText('Failed to load data')).toBeInTheDocument(); + expect(screen.getByTestId("timeseries-error")).toBeInTheDocument(); + expect(screen.getByText("Failed to load data")).toBeInTheDocument(); }); - it('does not show skeleton when error is present', () => { + it("does not show skeleton when error is present", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', error: 'Some error' }, + props: { data: [], range: "24h", error: "Some error" }, }); - expect(screen.queryByTestId('timeseries-skeleton')).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-skeleton")).not.toBeInTheDocument(); }); - it('does not show empty state when error is present', () => { + it("does not show empty state when error is present", () => { render(TimeseriesChart, { - props: { data: [], range: '24h', error: 'Some error' }, + props: { data: [], range: "24h", error: "Some error" }, }); - expect(screen.queryByTestId('timeseries-empty')).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-empty")).not.toBeInTheDocument(); }); }); - describe('Empty State', () => { - it('shows empty state message when data is empty array', () => { - render(TimeseriesChart, { props: { data: [], range: '24h' } }); - expect(screen.getByTestId('timeseries-empty')).toBeInTheDocument(); - expect(screen.getByText('No data available for this time range')).toBeInTheDocument(); + describe("Empty State", () => { + it("shows empty state message when data is empty array", () => { + render(TimeseriesChart, { props: { data: [], range: "24h" } }); + expect(screen.getByTestId("timeseries-empty")).toBeInTheDocument(); + expect(screen.getByText("No data available for this time range")).toBeInTheDocument(); }); - it('does not show skeleton when empty', () => { - render(TimeseriesChart, { props: { data: [], range: '24h' } }); - expect(screen.queryByTestId('timeseries-skeleton')).not.toBeInTheDocument(); + it("does not show skeleton when empty", () => { + render(TimeseriesChart, { props: { data: [], range: "24h" } }); + expect(screen.queryByTestId("timeseries-skeleton")).not.toBeInTheDocument(); }); - it('does not show error when empty', () => { - render(TimeseriesChart, { props: { data: [], range: '24h' } }); - expect(screen.queryByTestId('timeseries-error')).not.toBeInTheDocument(); + it("does not show error when empty", () => { + render(TimeseriesChart, { props: { data: [], range: "24h" } }); + expect(screen.queryByTestId("timeseries-error")).not.toBeInTheDocument(); }); }); - describe('Chart Rendering (non-browser)', () => { + describe("Chart Rendering (non-browser)", () => { // Since we mocked browser to false, the chart won't render // but the container should still be there without skeleton/empty/error - it('does not render chart in non-browser environment', () => { - render(TimeseriesChart, { props: { data: mockData, range: '24h' } }); + it("does not render chart in non-browser environment", () => { + render(TimeseriesChart, { props: { data: mockData, range: "24h" } }); // Container exists - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); // Chart is not rendered (browser check fails) - expect(screen.queryByTestId('timeseries-chart-rendered')).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-chart-rendered")).not.toBeInTheDocument(); // No error/loading/empty states shown either - expect(screen.queryByTestId('timeseries-skeleton')).not.toBeInTheDocument(); - expect(screen.queryByTestId('timeseries-empty')).not.toBeInTheDocument(); - expect(screen.queryByTestId('timeseries-error')).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-skeleton")).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-empty")).not.toBeInTheDocument(); + expect(screen.queryByTestId("timeseries-error")).not.toBeInTheDocument(); }); }); - describe('Different Time Ranges', () => { - it('accepts 15m range', () => { - render(TimeseriesChart, { props: { data: mockData, range: '15m' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + describe("Different Time Ranges", () => { + it("accepts 15m range", () => { + render(TimeseriesChart, { props: { data: mockData, range: "15m" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('accepts 1h range', () => { - render(TimeseriesChart, { props: { data: mockData, range: '1h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + it("accepts 1h range", () => { + render(TimeseriesChart, { props: { data: mockData, range: "1h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('accepts 24h range', () => { - render(TimeseriesChart, { props: { data: mockData, range: '24h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + it("accepts 24h range", () => { + render(TimeseriesChart, { props: { data: mockData, range: "24h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('accepts 7d range', () => { - render(TimeseriesChart, { props: { data: mockData, range: '7d' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + it("accepts 7d range", () => { + render(TimeseriesChart, { props: { data: mockData, range: "7d" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); }); - describe('Data Handling', () => { - it('handles large counts in data', () => { + describe("Data Handling", () => { + it("handles large counts in data", () => { const largeData = [ - { timestamp: '2024-01-15T10:00:00.000Z', count: 1000000 }, - { timestamp: '2024-01-15T11:00:00.000Z', count: 2500000 }, + { timestamp: "2024-01-15T10:00:00.000Z", count: 1000000 }, + { timestamp: "2024-01-15T11:00:00.000Z", count: 2500000 }, ]; - render(TimeseriesChart, { props: { data: largeData, range: '24h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + render(TimeseriesChart, { props: { data: largeData, range: "24h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('handles all zero counts', () => { + it("handles all zero counts", () => { const zeroData = [ - { timestamp: '2024-01-15T10:00:00.000Z', count: 0 }, - { timestamp: '2024-01-15T11:00:00.000Z', count: 0 }, - { timestamp: '2024-01-15T12:00:00.000Z', count: 0 }, + { timestamp: "2024-01-15T10:00:00.000Z", count: 0 }, + { timestamp: "2024-01-15T11:00:00.000Z", count: 0 }, + { timestamp: "2024-01-15T12:00:00.000Z", count: 0 }, ]; - render(TimeseriesChart, { props: { data: zeroData, range: '24h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + render(TimeseriesChart, { props: { data: zeroData, range: "24h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); - it('handles single data point', () => { - const singleData = [{ timestamp: '2024-01-15T10:00:00.000Z', count: 42 }]; - render(TimeseriesChart, { props: { data: singleData, range: '24h' } }); - expect(screen.getByTestId('timeseries-chart')).toBeInTheDocument(); + it("handles single data point", () => { + const singleData = [{ timestamp: "2024-01-15T10:00:00.000Z", count: 42 }]; + render(TimeseriesChart, { props: { data: singleData, range: "24h" } }); + expect(screen.getByTestId("timeseries-chart")).toBeInTheDocument(); }); }); }); diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts index bb05ec4..0261987 100644 --- a/src/lib/components/ui/button/index.ts +++ b/src/lib/components/ui/button/index.ts @@ -1,3 +1,3 @@ -import Root from './button.svelte'; +import Root from "./button.svelte"; export { Root as Button }; diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts index a7bb3e0..1652126 100644 --- a/src/lib/components/ui/card/index.ts +++ b/src/lib/components/ui/card/index.ts @@ -1,9 +1,9 @@ -import Root from './card.svelte'; -import Content from './card-content.svelte'; -import Description from './card-description.svelte'; -import Footer from './card-footer.svelte'; -import Header from './card-header.svelte'; -import Title from './card-title.svelte'; +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; export { Content as CardContent, diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts index 69a46c1..088744c 100644 --- a/src/lib/components/ui/dropdown-menu/index.ts +++ b/src/lib/components/ui/dropdown-menu/index.ts @@ -1,7 +1,7 @@ -import Root from './dropdown-menu.svelte'; -import Content from './dropdown-menu-content.svelte'; -import Item from './dropdown-menu-item.svelte'; -import Trigger from './dropdown-menu-trigger.svelte'; +import Root from "./dropdown-menu.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; export { Content, diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts index f643f8b..3bfedbe 100644 --- a/src/lib/components/ui/input/index.ts +++ b/src/lib/components/ui/input/index.ts @@ -1,3 +1,3 @@ -import Root from './input.svelte'; +import Root from "./input.svelte"; export { Root as Input }; diff --git a/src/lib/components/ui/select/index.ts b/src/lib/components/ui/select/index.ts index 907fc32..1dfbeca 100644 --- a/src/lib/components/ui/select/index.ts +++ b/src/lib/components/ui/select/index.ts @@ -1,14 +1,14 @@ -import Root from './select.svelte'; -import Content from './select-content.svelte'; -import Group from './select-group.svelte'; -import GroupHeading from './select-group-heading.svelte'; -import Item from './select-item.svelte'; -import Label from './select-label.svelte'; -import Portal from './select-portal.svelte'; -import ScrollDownButton from './select-scroll-down-button.svelte'; -import ScrollUpButton from './select-scroll-up-button.svelte'; -import Separator from './select-separator.svelte'; -import Trigger from './select-trigger.svelte'; +import Root from "./select.svelte"; +import Content from "./select-content.svelte"; +import Group from "./select-group.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Item from "./select-item.svelte"; +import Label from "./select-label.svelte"; +import Portal from "./select-portal.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import Separator from "./select-separator.svelte"; +import Trigger from "./select-trigger.svelte"; export { Content, diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts index 56b2767..d66644e 100644 --- a/src/lib/components/ui/separator/index.ts +++ b/src/lib/components/ui/separator/index.ts @@ -1,4 +1,4 @@ -import Root from './separator.svelte'; +import Root from "./separator.svelte"; export { Root, diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts index e897e44..f41a017 100644 --- a/src/lib/components/ui/skeleton/index.ts +++ b/src/lib/components/ui/skeleton/index.ts @@ -1,3 +1,3 @@ -import Root from './skeleton.svelte'; +import Root from "./skeleton.svelte"; export { Root as Skeleton }; diff --git a/src/lib/components/ui/sonner/index.ts b/src/lib/components/ui/sonner/index.ts index fcaf06b..1ad9f4a 100644 --- a/src/lib/components/ui/sonner/index.ts +++ b/src/lib/components/ui/sonner/index.ts @@ -1 +1 @@ -export { default as Toaster } from './sonner.svelte'; +export { default as Toaster } from "./sonner.svelte"; diff --git a/src/lib/components/ui/switch/index.ts b/src/lib/components/ui/switch/index.ts index 28f3142..489ae48 100644 --- a/src/lib/components/ui/switch/index.ts +++ b/src/lib/components/ui/switch/index.ts @@ -1,3 +1,3 @@ -import Root from './switch.svelte'; +import Root from "./switch.svelte"; export { Root as Switch }; diff --git a/src/lib/hooks/__tests__/use-log-stream.component.test.ts b/src/lib/hooks/__tests__/use-log-stream.component.test.ts index a5b6433..18ad0fc 100644 --- a/src/lib/hooks/__tests__/use-log-stream.component.test.ts +++ b/src/lib/hooks/__tests__/use-log-stream.component.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment jsdom */ -import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; -import type { ClientLog } from '$lib/stores/logs.svelte'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vite-plus/test"; +import type { ClientLog } from "$lib/stores/logs.svelte"; /** * Helper to create a mock SSE response with a readable stream @@ -26,9 +26,9 @@ function createMockSSEResponse(events: Array<{ event: string; data: string }>): return new Response(stream, { status: 200, headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", }, }); } @@ -60,9 +60,9 @@ function createDelayedMockSSEResponse( return new Response(stream, { status: 200, headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", }, }); } @@ -73,7 +73,7 @@ function createDelayedMockSSEResponse( function createErrorResponse(status: number, message: string): Response { return new Response(JSON.stringify({ error: message }), { status, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, }); } @@ -83,9 +83,9 @@ function createErrorResponse(status: number, message: string): Response { function createSampleLog(overrides: Partial = {}): ClientLog { return { id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`, - projectId: 'test-project', - level: 'info', - message: 'Test log message', + projectId: "test-project", + level: "info", + message: "Test log message", metadata: null, incidentId: null, fingerprint: null, @@ -100,16 +100,16 @@ function createSampleLog(overrides: Partial = {}): ClientLog { }; } -describe('useLogStream', () => { +describe("useLogStream", () => { let fetchMock: MockInstance; - let useLogStream: typeof import('../use-log-stream.svelte').useLogStream; + let useLogStream: typeof import("../use-log-stream.svelte").useLogStream; beforeEach(async () => { vi.resetModules(); - fetchMock = vi.spyOn(globalThis, 'fetch'); + fetchMock = vi.spyOn(globalThis, "fetch"); // Import fresh module for each test - const module = await import('../use-log-stream.svelte'); + const module = await import("../use-log-stream.svelte"); useLogStream = module.useLogStream; }); @@ -119,16 +119,16 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - describe('connection', () => { - it('connects to SSE endpoint when enabled', async () => { + describe("connection", () => { + it("connects to SSE endpoint when enabled", async () => { const mockResponse = createMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onLogs = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs, }); @@ -139,20 +139,20 @@ describe('useLogStream', () => { }); expect(fetchMock).toHaveBeenCalledWith( - '/api/projects/test-project/logs/stream', + "/api/projects/test-project/logs/stream", expect.objectContaining({ - method: 'POST', - credentials: 'same-origin', + method: "POST", + credentials: "same-origin", }), ); stream.disconnect(); }); - it('does not connect when enabled is false', async () => { + it("does not connect when enabled is false", async () => { const onLogs = vi.fn(); useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: false, onLogs, }); @@ -163,7 +163,7 @@ describe('useLogStream', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('reports connecting state during connection', async () => { + it("reports connecting state during connection", async () => { let resolveResponse!: (value: Response) => void; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; @@ -171,7 +171,7 @@ describe('useLogStream', () => { fetchMock.mockReturnValueOnce(responsePromise); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -183,7 +183,7 @@ describe('useLogStream', () => { // Resolve the fetch resolveResponse( - createMockSSEResponse([{ event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }]), + createMockSSEResponse([{ event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }]), ); await vi.waitFor(() => { @@ -193,15 +193,15 @@ describe('useLogStream', () => { stream.disconnect(); }); - it('reports connected state after successful connection', async () => { + it("reports connected state after successful connection", async () => { const mockResponse = createDelayedMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -214,16 +214,16 @@ describe('useLogStream', () => { }); }); - describe('parsing log batches', () => { - it('parses incoming log batches and calls onLogs', async () => { - const logs = [createSampleLog({ id: 'log-1' }), createSampleLog({ id: 'log-2' })]; + describe("parsing log batches", () => { + it("parses incoming log batches and calls onLogs", async () => { + const logs = [createSampleLog({ id: "log-1" }), createSampleLog({ id: "log-2" })]; - const mockResponse = createMockSSEResponse([{ event: 'logs', data: JSON.stringify(logs) }]); + const mockResponse = createMockSSEResponse([{ event: "logs", data: JSON.stringify(logs) }]); fetchMock.mockResolvedValueOnce(mockResponse); const onLogs = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs, }); @@ -234,27 +234,27 @@ describe('useLogStream', () => { expect(onLogs).toHaveBeenCalledWith( expect.arrayContaining([ - expect.objectContaining({ id: 'log-1' }), - expect.objectContaining({ id: 'log-2' }), + expect.objectContaining({ id: "log-1" }), + expect.objectContaining({ id: "log-2" }), ]), ); stream.disconnect(); }); - it('handles multiple log batches sequentially', async () => { - const batch1 = [createSampleLog({ id: 'batch1-log-1' })]; - const batch2 = [createSampleLog({ id: 'batch2-log-1' })]; + it("handles multiple log batches sequentially", async () => { + const batch1 = [createSampleLog({ id: "batch1-log-1" })]; + const batch2 = [createSampleLog({ id: "batch2-log-1" })]; const mockResponse = createDelayedMockSSEResponse([ - { event: 'logs', data: JSON.stringify(batch1), delayMs: 10 }, - { event: 'logs', data: JSON.stringify(batch2), delayMs: 10 }, + { event: "logs", data: JSON.stringify(batch1), delayMs: 10 }, + { event: "logs", data: JSON.stringify(batch2), delayMs: 10 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onLogs = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs, }); @@ -265,26 +265,26 @@ describe('useLogStream', () => { expect(onLogs).toHaveBeenNthCalledWith( 1, - expect.arrayContaining([expect.objectContaining({ id: 'batch1-log-1' })]), + expect.arrayContaining([expect.objectContaining({ id: "batch1-log-1" })]), ); expect(onLogs).toHaveBeenNthCalledWith( 2, - expect.arrayContaining([expect.objectContaining({ id: 'batch2-log-1' })]), + expect.arrayContaining([expect.objectContaining({ id: "batch2-log-1" })]), ); stream.disconnect(); }); - it('ignores heartbeat events', async () => { + it("ignores heartbeat events", async () => { const mockResponse = createMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onLogs = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs, }); @@ -297,12 +297,12 @@ describe('useLogStream', () => { stream.disconnect(); }); - it('handles malformed JSON gracefully', async () => { + it("handles malformed JSON gracefully", async () => { const mockResponse = createMockSSEResponse([ - { event: 'logs', data: 'not valid json' }, + { event: "logs", data: "not valid json" }, { - event: 'logs', - data: JSON.stringify([createSampleLog({ id: 'valid-log' })]), + event: "logs", + data: JSON.stringify([createSampleLog({ id: "valid-log" })]), }, ]); fetchMock.mockResolvedValueOnce(mockResponse); @@ -310,7 +310,7 @@ describe('useLogStream', () => { const onLogs = vi.fn(); const onError = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs, onError, @@ -322,29 +322,29 @@ describe('useLogStream', () => { // Should still process the valid batch expect(onLogs).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: 'valid-log' })]), + expect.arrayContaining([expect.objectContaining({ id: "valid-log" })]), ); stream.disconnect(); }); }); - describe('reconnection', () => { - it('attempts reconnection after connection error', async () => { + describe("reconnection", () => { + it("attempts reconnection after connection error", async () => { vi.useFakeTimers(); // First connection fails - fetchMock.mockRejectedValueOnce(new Error('Network error')); + fetchMock.mockRejectedValueOnce(new Error("Network error")); // Second connection succeeds const mockResponse = createMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onError = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onError, @@ -363,14 +363,14 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - it('uses exponential backoff for reconnection', async () => { + it("uses exponential backoff for reconnection", async () => { vi.useFakeTimers(); // All connections fail - fetchMock.mockRejectedValue(new Error('Network error')); + fetchMock.mockRejectedValue(new Error("Network error")); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -391,18 +391,18 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - it('resets reconnection attempts on successful connection', async () => { + it("resets reconnection attempts on successful connection", async () => { vi.useFakeTimers(); // First fails, second succeeds, third (after disconnect) succeeds fetchMock - .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce( - createMockSSEResponse([{ event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }]), + createMockSSEResponse([{ event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }]), ); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -419,13 +419,13 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - it('stops reconnection when disabled', async () => { + it("stops reconnection when disabled", async () => { vi.useFakeTimers(); - fetchMock.mockRejectedValue(new Error('Network error')); + fetchMock.mockRejectedValue(new Error("Network error")); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -446,14 +446,14 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - it('caps max reconnection attempts', async () => { + it("caps max reconnection attempts", async () => { vi.useFakeTimers(); - fetchMock.mockRejectedValue(new Error('Network error')); + fetchMock.mockRejectedValue(new Error("Network error")); const onError = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onError, @@ -485,17 +485,17 @@ describe('useLogStream', () => { }); }); - describe('cleanup', () => { - it('disconnects when calling disconnect()', async () => { - const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + describe("cleanup", () => { + it("disconnects when calling disconnect()", async () => { + const abortSpy = vi.spyOn(AbortController.prototype, "abort"); const mockResponse = createDelayedMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 1000 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 1000 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -509,14 +509,14 @@ describe('useLogStream', () => { expect(abortSpy).toHaveBeenCalled(); }); - it('cleans up pending reconnection timers on disconnect', async () => { + it("cleans up pending reconnection timers on disconnect", async () => { vi.useFakeTimers(); - const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); - fetchMock.mockRejectedValueOnce(new Error('Network error')); + fetchMock.mockRejectedValueOnce(new Error("Network error")); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -532,14 +532,14 @@ describe('useLogStream', () => { vi.useRealTimers(); }); - it('reports disconnected state after disconnect', async () => { + it("reports disconnected state after disconnect", async () => { const mockResponse = createDelayedMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -554,17 +554,17 @@ describe('useLogStream', () => { expect(stream.isConnecting).toBe(false); }); - it('can reconnect after manual disconnect', async () => { + it("can reconnect after manual disconnect", async () => { const mockResponse1 = createMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, ]); const mockResponse2 = createMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }) }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }) }, ]); fetchMock.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -585,13 +585,13 @@ describe('useLogStream', () => { }); }); - describe('error handling', () => { - it('calls onError when connection fails', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network error')); + describe("error handling", () => { + it("calls onError when connection fails", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network error")); const onError = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onError, @@ -606,12 +606,12 @@ describe('useLogStream', () => { stream.disconnect(); }); - it('calls onError when server returns error status', async () => { - fetchMock.mockResolvedValueOnce(createErrorResponse(401, 'Unauthorized')); + it("calls onError when server returns error status", async () => { + fetchMock.mockResolvedValueOnce(createErrorResponse(401, "Unauthorized")); const onError = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onError, @@ -624,11 +624,11 @@ describe('useLogStream', () => { stream.disconnect(); }); - it('exposes error state', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network error')); + it("exposes error state", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network error")); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), }); @@ -637,22 +637,22 @@ describe('useLogStream', () => { expect(stream.error).not.toBeNull(); }); - expect(stream.error?.message).toBe('Network error'); + expect(stream.error?.message).toBe("Network error"); stream.disconnect(); }); }); - describe('connection change callback', () => { - it('calls onConnectionChange when connected', async () => { + describe("connection change callback", () => { + it("calls onConnectionChange when connected", async () => { const mockResponse = createDelayedMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onConnectionChange = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onConnectionChange, @@ -665,15 +665,15 @@ describe('useLogStream', () => { stream.disconnect(); }); - it('calls onConnectionChange when disconnected', async () => { + it("calls onConnectionChange when disconnected", async () => { const mockResponse = createDelayedMockSSEResponse([ - { event: 'heartbeat', data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, + { event: "heartbeat", data: JSON.stringify({ ts: Date.now() }), delayMs: 100 }, ]); fetchMock.mockResolvedValueOnce(mockResponse); const onConnectionChange = vi.fn(); const stream = useLogStream({ - projectId: 'test-project', + projectId: "test-project", enabled: true, onLogs: vi.fn(), onConnectionChange, diff --git a/src/lib/hooks/use-incident-stream.svelte.ts b/src/lib/hooks/use-incident-stream.svelte.ts index d97d2c1..669ccdb 100644 --- a/src/lib/hooks/use-incident-stream.svelte.ts +++ b/src/lib/hooks/use-incident-stream.svelte.ts @@ -10,7 +10,7 @@ export interface ClientIncident { serviceName: string | null; sourceFile: string | null; lineNumber: number | null; - highestLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + highestLevel: "debug" | "info" | "warn" | "error" | "fatal"; firstSeen: string; lastSeen: string; totalEvents: number; @@ -69,22 +69,22 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden events: Array<{ event: string; data: string }>; remaining: string; } { - const lines = buffer.split('\n'); - const remaining = lines.pop() || ''; + const lines = buffer.split("\n"); + const remaining = lines.pop() || ""; const events: Array<{ event: string; data: string }> = []; - let currentEvent = ''; - let currentData = ''; + let currentEvent = ""; + let currentData = ""; for (const line of lines) { - if (line.startsWith('event: ')) { + if (line.startsWith("event: ")) { currentEvent = line.slice(7); - } else if (line.startsWith('data: ')) { + } else if (line.startsWith("data: ")) { currentData = line.slice(6); - } else if (line === '' && currentEvent && currentData) { + } else if (line === "" && currentEvent && currentData) { events.push({ event: currentEvent, data: currentData }); - currentEvent = ''; - currentData = ''; + currentEvent = ""; + currentData = ""; } } @@ -93,7 +93,7 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden function processSSEEvents(events: Array<{ event: string; data: string }>): void { for (const event of events) { - if (event.event === 'incidents') { + if (event.event === "incidents") { try { const incidents = JSON.parse(event.data) as ClientIncident[]; onIncidents?.(incidents); @@ -125,8 +125,8 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden _abortController = new AbortController(); fetch(`/api/projects/${projectId}/incidents/stream`, { - method: 'POST', - credentials: 'same-origin', + method: "POST", + credentials: "same-origin", signal: _abortController.signal, }) .then(async (response) => { @@ -134,7 +134,7 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden throw new Error(`HTTP ${response.status}: ${response.statusText}`); } if (!response.body) { - throw new Error('Response body is empty'); + throw new Error("Response body is empty"); } _isConnecting = false; @@ -143,7 +143,7 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden const reader = response.body.getReader(); const decoder = new TextDecoder(); - let buffer = ''; + let buffer = ""; try { while (true) { @@ -171,14 +171,14 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden }) .catch((error) => { _isConnecting = false; - if (error?.name === 'AbortError' && _isDisconnected) return; + if (error?.name === "AbortError" && _isDisconnected) return; _error = error instanceof Error ? error : new Error(String(error)); onError?.(_error); setConnected(false); // Don't reconnect on permanent errors (404) - if (_error.message.startsWith('HTTP 404:')) return; + if (_error.message.startsWith("HTTP 404:")) return; scheduleReconnect(); }); @@ -201,7 +201,7 @@ export function useIncidentStream(options: UseIncidentStreamOptions): UseInciden setConnected(false); } - if (enabled && typeof window !== 'undefined') { + if (enabled && typeof window !== "undefined") { queueMicrotask(() => connect()); } diff --git a/src/lib/hooks/use-log-stream.svelte.ts b/src/lib/hooks/use-log-stream.svelte.ts index 64a5517..1ba0f87 100644 --- a/src/lib/hooks/use-log-stream.svelte.ts +++ b/src/lib/hooks/use-log-stream.svelte.ts @@ -1,4 +1,4 @@ -import type { ClientLog } from '$lib/stores/logs.svelte'; +import type { ClientLog } from "$lib/stores/logs.svelte"; /** * Configuration options for the useLogStream hook @@ -92,22 +92,22 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { events: Array<{ event: string; data: string }>; remaining: string; } { - const lines = buffer.split('\n'); - const remaining = lines.pop() || ''; + const lines = buffer.split("\n"); + const remaining = lines.pop() || ""; const events: Array<{ event: string; data: string }> = []; - let currentEvent = ''; - let currentData = ''; + let currentEvent = ""; + let currentData = ""; for (const line of lines) { - if (line.startsWith('event: ')) { + if (line.startsWith("event: ")) { currentEvent = line.slice(7); - } else if (line.startsWith('data: ')) { + } else if (line.startsWith("data: ")) { currentData = line.slice(6); - } else if (line === '' && currentEvent && currentData) { + } else if (line === "" && currentEvent && currentData) { events.push({ event: currentEvent, data: currentData }); - currentEvent = ''; - currentData = ''; + currentEvent = ""; + currentData = ""; } } @@ -119,7 +119,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { */ function processSSEEvents(events: Array<{ event: string; data: string }>): void { for (const event of events) { - if (event.event === 'logs') { + if (event.event === "logs") { try { const logs = JSON.parse(event.data) as ClientLog[]; onLogs?.(logs); @@ -161,8 +161,8 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { _abortController = new AbortController(); fetch(`/api/projects/${projectId}/logs/stream`, { - method: 'POST', - credentials: 'same-origin', + method: "POST", + credentials: "same-origin", signal: _abortController.signal, }) .then(async (response) => { @@ -171,7 +171,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { } if (!response.body) { - throw new Error('Response body is empty'); + throw new Error("Response body is empty"); } _isConnecting = false; @@ -180,7 +180,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { const reader = response.body.getReader(); const decoder = new TextDecoder(); - let buffer = ''; + let buffer = ""; try { while (true) { @@ -213,7 +213,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { _isConnecting = false; // Ignore abort errors from intentional disconnection - if (error?.name === 'AbortError' && _isDisconnected) { + if (error?.name === "AbortError" && _isDisconnected) { return; } @@ -222,7 +222,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { setConnected(false); // Don't reconnect on permanent errors (404) - if (_error.message.startsWith('HTTP 404:')) return; + if (_error.message.startsWith("HTTP 404:")) return; // Schedule reconnection attempt scheduleReconnect(); @@ -253,7 +253,7 @@ export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { } // Auto-connect if enabled on creation (only in browser) - if (enabled && typeof window !== 'undefined') { + if (enabled && typeof window !== "undefined") { // Use queueMicrotask to allow the return value to be captured first queueMicrotask(() => { connect(); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 9461634..5502721 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,8 +1,8 @@ -import { betterAuth } from 'better-auth'; -import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { username } from 'better-auth/plugins'; -import { env } from '$lib/server/config/env'; -import type { DatabaseClient } from './db/db'; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { username } from "better-auth/plugins"; +import { env } from "$lib/server/config/env"; +import type { DatabaseClient } from "./db/db"; /** * Creates a better-auth instance with the provided database @@ -11,7 +11,7 @@ import type { DatabaseClient } from './db/db'; export function createAuth(database: DatabaseClient) { return betterAuth({ database: drizzleAdapter(database, { - provider: 'pg', + provider: "pg", }), emailAndPassword: { enabled: true, @@ -40,7 +40,7 @@ async function initAuth(): Promise { if (_initPromise) return _initPromise; _initPromise = (async () => { - const { db } = await import('./db'); + const { db } = await import("./db"); _auth = createAuth(db); })(); @@ -52,7 +52,7 @@ export const auth = new Proxy({} as ReturnType, { if (!_auth) { // For synchronous access, we need to throw if not initialized // The hooks.server.ts should call initAuth() first - throw new Error('Auth not initialized. Call initAuth() before accessing auth properties.'); + throw new Error("Auth not initialized. Call initAuth() before accessing auth properties."); } return _auth[prop as keyof typeof _auth]; }, @@ -63,5 +63,5 @@ export { initAuth }; /** * Type exports for better-auth session and user */ -export type Session = ReturnType['$Infer']['Session']['session']; -export type User = ReturnType['$Infer']['Session']['user']; +export type Session = ReturnType["$Infer"]["Session"]["session"]; +export type User = ReturnType["$Infer"]["Session"]["user"]; diff --git a/src/lib/server/config/env.ts b/src/lib/server/config/env.ts index 2b4da46..6a2a4bf 100644 --- a/src/lib/server/config/env.ts +++ b/src/lib/server/config/env.ts @@ -24,13 +24,13 @@ export class EnvValidationError extends Error { public readonly variable: string, ) { super(message); - this.name = 'EnvValidationError'; + this.name = "EnvValidationError"; } } // Get NODE_ENV first for conditional validation -const nodeEnv = process.env.NODE_ENV || 'development'; -const isProd = nodeEnv === 'production'; +const nodeEnv = process.env.NODE_ENV || "development"; +const isProd = nodeEnv === "production"; // Collect validation errors const validationErrors: Array<{ variable: string; message: string }> = []; @@ -39,21 +39,21 @@ const validationErrors: Array<{ variable: string; message: string }> = []; const rawDatabaseUrl = process.env.DATABASE_URL; if (!rawDatabaseUrl) { validationErrors.push({ - variable: 'DATABASE_URL', - message: 'DATABASE_URL environment variable is required', + variable: "DATABASE_URL", + message: "DATABASE_URL environment variable is required", }); -} else if (!rawDatabaseUrl.startsWith('postgres')) { +} else if (!rawDatabaseUrl.startsWith("postgres")) { validationErrors.push({ - variable: 'DATABASE_URL', + variable: "DATABASE_URL", message: - 'DATABASE_URL must be a PostgreSQL connection string (starts with postgres:// or postgresql://)', + "DATABASE_URL must be a PostgreSQL connection string (starts with postgres:// or postgresql://)", }); } // Re-read after validation to get narrowed type (validation throws if missing) function getDatabaseUrl(): string { const url = process.env.DATABASE_URL; - if (!url) throw new Error('DATABASE_URL missing after validation'); + if (!url) throw new Error("DATABASE_URL missing after validation"); return url; } @@ -62,24 +62,24 @@ const authSecret = process.env.BETTER_AUTH_SECRET; if (isProd) { if (!authSecret) { validationErrors.push({ - variable: 'BETTER_AUTH_SECRET', - message: 'BETTER_AUTH_SECRET environment variable is required in production', + variable: "BETTER_AUTH_SECRET", + message: "BETTER_AUTH_SECRET environment variable is required in production", }); } else if (authSecret.length < 32) { validationErrors.push({ - variable: 'BETTER_AUTH_SECRET', - message: 'BETTER_AUTH_SECRET must be at least 32 characters long', + variable: "BETTER_AUTH_SECRET", + message: "BETTER_AUTH_SECRET must be at least 32 characters long", }); } } // Throw aggregated error if validation failed if (validationErrors.length > 0) { - const errorMessages = validationErrors.map((e) => `- ${e.variable}: ${e.message}`).join('\n'); + const errorMessages = validationErrors.map((e) => `- ${e.variable}: ${e.message}`).join("\n"); const firstError = validationErrors[0]; throw new EnvValidationError( `Environment validation failed:\n${errorMessages}`, - firstError?.variable ?? 'unknown', + firstError?.variable ?? "unknown", ); } @@ -91,7 +91,7 @@ export const env = { DATABASE_URL: getDatabaseUrl(), /** Secret key for better-auth sessions (defaults to dev secret in development) */ - BETTER_AUTH_SECRET: authSecret || 'default-secret-for-development-only', + BETTER_AUTH_SECRET: authSecret || "default-secret-for-development-only", /** Password for seeding admin user (optional) */ ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, @@ -107,14 +107,14 @@ export const env = { * Check if running in production mode */ export function isProduction(): boolean { - return env.NODE_ENV === 'production'; + return env.NODE_ENV === "production"; } /** * Check if running in development mode */ export function isDevelopment(): boolean { - return env.NODE_ENV !== 'production'; + return env.NODE_ENV !== "production"; } /** @@ -145,26 +145,26 @@ export function validateEnv(): ValidationResult { if (!process.env.DATABASE_URL) { errors.push({ - variable: 'DATABASE_URL', - message: 'DATABASE_URL environment variable is required', + variable: "DATABASE_URL", + message: "DATABASE_URL environment variable is required", }); - } else if (!process.env.DATABASE_URL.startsWith('postgres')) { + } else if (!process.env.DATABASE_URL.startsWith("postgres")) { errors.push({ - variable: 'DATABASE_URL', - message: 'DATABASE_URL must be a PostgreSQL connection string', + variable: "DATABASE_URL", + message: "DATABASE_URL must be a PostgreSQL connection string", }); } if (isProduction()) { if (!process.env.BETTER_AUTH_SECRET) { errors.push({ - variable: 'BETTER_AUTH_SECRET', - message: 'BETTER_AUTH_SECRET is required in production', + variable: "BETTER_AUTH_SECRET", + message: "BETTER_AUTH_SECRET is required in production", }); } else if (process.env.BETTER_AUTH_SECRET.length < 32) { errors.push({ - variable: 'BETTER_AUTH_SECRET', - message: 'BETTER_AUTH_SECRET must be at least 32 characters', + variable: "BETTER_AUTH_SECRET", + message: "BETTER_AUTH_SECRET must be at least 32 characters", }); } } @@ -173,15 +173,15 @@ export function validateEnv(): ValidationResult { } function maskValue(value: string | undefined): string { - if (!value) return '[not set]'; - return '*'.repeat(Math.min(value.length, 16)); + if (!value) return "[not set]"; + return "*".repeat(Math.min(value.length, 16)); } function maskDatabaseUrl(url: string | undefined): string { - if (!url) return '[not set]'; + if (!url) return "[not set]"; try { const parsed = new URL(url); - if (parsed.password) parsed.password = '****'; + if (parsed.password) parsed.password = "****"; return parsed.toString(); } catch { return maskValue(url); @@ -197,7 +197,7 @@ export function getEnvSummary(): EnvSummary { DATABASE_URL: maskDatabaseUrl(process.env.DATABASE_URL), BETTER_AUTH_SECRET: maskValue(process.env.BETTER_AUTH_SECRET), ADMIN_PASSWORD: maskValue(process.env.ADMIN_PASSWORD), - ORIGIN: process.env.ORIGIN || '[not set]', + ORIGIN: process.env.ORIGIN || "[not set]", NODE_ENV: env.NODE_ENV, }; } diff --git a/src/lib/server/config/env.unit.test.ts b/src/lib/server/config/env.unit.test.ts index 9ade50a..dcabd4d 100644 --- a/src/lib/server/config/env.unit.test.ts +++ b/src/lib/server/config/env.unit.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -describe('Environment Configuration', () => { +describe("Environment Configuration", () => { // Store original env const originalEnv = { ...process.env }; @@ -15,188 +15,188 @@ describe('Environment Configuration', () => { vi.resetModules(); }); - describe('Required Environment Variables', () => { - it('throws error when DATABASE_URL is not set', async () => { + describe("Required Environment Variables", () => { + it("throws error when DATABASE_URL is not set", async () => { delete process.env.DATABASE_URL; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + process.env.BETTER_AUTH_SECRET = "a".repeat(32); - await expect(import('./env')).rejects.toThrow('DATABASE_URL'); + await expect(import("./env")).rejects.toThrow("DATABASE_URL"); }); - it('throws error when BETTER_AUTH_SECRET is not set in production', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.NODE_ENV = 'production'; + it("throws error when BETTER_AUTH_SECRET is not set in production", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.NODE_ENV = "production"; delete process.env.BETTER_AUTH_SECRET; - await expect(import('./env')).rejects.toThrow('BETTER_AUTH_SECRET'); + await expect(import("./env")).rejects.toThrow("BETTER_AUTH_SECRET"); }); - it('throws error when BETTER_AUTH_SECRET is too short in production', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.NODE_ENV = 'production'; - process.env.BETTER_AUTH_SECRET = 'too-short'; + it("throws error when BETTER_AUTH_SECRET is too short in production", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.NODE_ENV = "production"; + process.env.BETTER_AUTH_SECRET = "too-short"; - await expect(import('./env')).rejects.toThrow('32 characters'); + await expect(import("./env")).rejects.toThrow("32 characters"); }); - it('allows missing BETTER_AUTH_SECRET in development', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.NODE_ENV = 'development'; + it("allows missing BETTER_AUTH_SECRET in development", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.NODE_ENV = "development"; delete process.env.BETTER_AUTH_SECRET; - const { env } = await import('./env'); + const { env } = await import("./env"); expect(env.BETTER_AUTH_SECRET).toBeDefined(); }); }); - describe('Database Configuration', () => { - it('exports DATABASE_URL from environment', async () => { - process.env.DATABASE_URL = 'postgres://user:pass@localhost:5432/mydb'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + describe("Database Configuration", () => { + it("exports DATABASE_URL from environment", async () => { + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/mydb"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); - const { env } = await import('./env'); - expect(env.DATABASE_URL).toBe('postgres://user:pass@localhost:5432/mydb'); + const { env } = await import("./env"); + expect(env.DATABASE_URL).toBe("postgres://user:pass@localhost:5432/mydb"); }); - it('validates DATABASE_URL format starts with postgres', async () => { - process.env.DATABASE_URL = 'mysql://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("validates DATABASE_URL format starts with postgres", async () => { + process.env.DATABASE_URL = "mysql://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); - await expect(import('./env')).rejects.toThrow('PostgreSQL'); + await expect(import("./env")).rejects.toThrow("PostgreSQL"); }); }); - describe('Authentication Configuration', () => { - it('exports BETTER_AUTH_SECRET from environment', async () => { - const secret = 'a'.repeat(32); - process.env.DATABASE_URL = 'postgres://localhost/test'; + describe("Authentication Configuration", () => { + it("exports BETTER_AUTH_SECRET from environment", async () => { + const secret = "a".repeat(32); + process.env.DATABASE_URL = "postgres://localhost/test"; process.env.BETTER_AUTH_SECRET = secret; - const { env } = await import('./env'); + const { env } = await import("./env"); expect(env.BETTER_AUTH_SECRET).toBe(secret); }); - it('exports ADMIN_PASSWORD when set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.ADMIN_PASSWORD = 'securepassword123'; + it("exports ADMIN_PASSWORD when set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.ADMIN_PASSWORD = "securepassword123"; - const { env } = await import('./env'); - expect(env.ADMIN_PASSWORD).toBe('securepassword123'); + const { env } = await import("./env"); + expect(env.ADMIN_PASSWORD).toBe("securepassword123"); }); - it('returns undefined for ADMIN_PASSWORD when not set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("returns undefined for ADMIN_PASSWORD when not set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.ADMIN_PASSWORD; - const { env } = await import('./env'); + const { env } = await import("./env"); expect(env.ADMIN_PASSWORD).toBeUndefined(); }); }); - describe('Optional Configuration', () => { - it('exports ORIGIN when set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.ORIGIN = 'https://myapp.com'; + describe("Optional Configuration", () => { + it("exports ORIGIN when set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.ORIGIN = "https://myapp.com"; - const { env } = await import('./env'); - expect(env.ORIGIN).toBe('https://myapp.com'); + const { env } = await import("./env"); + expect(env.ORIGIN).toBe("https://myapp.com"); }); - it('returns undefined for ORIGIN when not set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("returns undefined for ORIGIN when not set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.ORIGIN; - const { env } = await import('./env'); + const { env } = await import("./env"); expect(env.ORIGIN).toBeUndefined(); }); - it('exports NODE_ENV with default of development', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("exports NODE_ENV with default of development", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.NODE_ENV; - const { env } = await import('./env'); - expect(env.NODE_ENV).toBe('development'); + const { env } = await import("./env"); + expect(env.NODE_ENV).toBe("development"); }); }); - describe('isProduction helper', () => { - it('returns true when NODE_ENV is production', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.NODE_ENV = 'production'; + describe("isProduction helper", () => { + it("returns true when NODE_ENV is production", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.NODE_ENV = "production"; - const { isProduction } = await import('./env'); + const { isProduction } = await import("./env"); expect(isProduction()).toBe(true); }); - it('returns false when NODE_ENV is development', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.NODE_ENV = 'development'; + it("returns false when NODE_ENV is development", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.NODE_ENV = "development"; - const { isProduction } = await import('./env'); + const { isProduction } = await import("./env"); expect(isProduction()).toBe(false); }); - it('returns false when NODE_ENV is not set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("returns false when NODE_ENV is not set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.NODE_ENV; - const { isProduction } = await import('./env'); + const { isProduction } = await import("./env"); expect(isProduction()).toBe(false); }); }); - describe('isDevelopment helper', () => { - it('returns true when NODE_ENV is development', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.NODE_ENV = 'development'; + describe("isDevelopment helper", () => { + it("returns true when NODE_ENV is development", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.NODE_ENV = "development"; - const { isDevelopment } = await import('./env'); + const { isDevelopment } = await import("./env"); expect(isDevelopment()).toBe(true); }); - it('returns true when NODE_ENV is not set', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("returns true when NODE_ENV is not set", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.NODE_ENV; - const { isDevelopment } = await import('./env'); + const { isDevelopment } = await import("./env"); expect(isDevelopment()).toBe(true); }); - it('returns false when NODE_ENV is production', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); - process.env.NODE_ENV = 'production'; + it("returns false when NODE_ENV is production", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); + process.env.NODE_ENV = "production"; - const { isDevelopment } = await import('./env'); + const { isDevelopment } = await import("./env"); expect(isDevelopment()).toBe(false); }); }); - describe('validateEnv function', () => { - it('returns valid result for correct configuration', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + describe("validateEnv function", () => { + it("returns valid result for correct configuration", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); - const { validateEnv } = await import('./env'); + const { validateEnv } = await import("./env"); const result = validateEnv(); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); - it('returns all validation errors at once', async () => { + it("returns all validation errors at once", async () => { delete process.env.DATABASE_URL; - process.env.NODE_ENV = 'production'; + process.env.NODE_ENV = "production"; delete process.env.BETTER_AUTH_SECRET; // Can't test this via import since it throws, use internal validation @@ -205,34 +205,34 @@ describe('Environment Configuration', () => { }); }); - describe('getEnvSummary function', () => { - it('returns masked summary of environment configuration', async () => { - process.env.DATABASE_URL = 'postgres://user:password@localhost:5432/mydb'; - process.env.BETTER_AUTH_SECRET = 'supersecretkey12345678901234567890'; - process.env.ADMIN_PASSWORD = 'adminpassword'; - process.env.NODE_ENV = 'production'; + describe("getEnvSummary function", () => { + it("returns masked summary of environment configuration", async () => { + process.env.DATABASE_URL = "postgres://user:password@localhost:5432/mydb"; + process.env.BETTER_AUTH_SECRET = "supersecretkey12345678901234567890"; + process.env.ADMIN_PASSWORD = "adminpassword"; + process.env.NODE_ENV = "production"; - const { getEnvSummary } = await import('./env'); + const { getEnvSummary } = await import("./env"); const summary = getEnvSummary(); // Should mask sensitive values - expect(summary.DATABASE_URL).not.toContain('password'); + expect(summary.DATABASE_URL).not.toContain("password"); expect(summary.BETTER_AUTH_SECRET).toMatch(/^\*+$/); expect(summary.ADMIN_PASSWORD).toMatch(/^\*+$/); - expect(summary.NODE_ENV).toBe('production'); + expect(summary.NODE_ENV).toBe("production"); }); - it('shows [not set] for missing optional variables', async () => { - process.env.DATABASE_URL = 'postgres://localhost/test'; - process.env.BETTER_AUTH_SECRET = 'a'.repeat(32); + it("shows [not set] for missing optional variables", async () => { + process.env.DATABASE_URL = "postgres://localhost/test"; + process.env.BETTER_AUTH_SECRET = "a".repeat(32); delete process.env.ORIGIN; delete process.env.ADMIN_PASSWORD; - const { getEnvSummary } = await import('./env'); + const { getEnvSummary } = await import("./env"); const summary = getEnvSummary(); - expect(summary.ORIGIN).toBe('[not set]'); - expect(summary.ADMIN_PASSWORD).toBe('[not set]'); + expect(summary.ORIGIN).toBe("[not set]"); + expect(summary.ADMIN_PASSWORD).toBe("[not set]"); }); }); }); diff --git a/src/lib/server/config/index.ts b/src/lib/server/config/index.ts index 5cd0684..027b6d1 100644 --- a/src/lib/server/config/index.ts +++ b/src/lib/server/config/index.ts @@ -20,7 +20,7 @@ export { isProduction, type ValidationResult, validateEnv, -} from './env'; +} from "./env"; export { API_CONFIG, @@ -30,4 +30,4 @@ export { SSE_CONFIG, type SSEConfigType, validateSSEConfig, -} from './performance'; +} from "./performance"; diff --git a/src/lib/server/config/performance.ts b/src/lib/server/config/performance.ts index 272c7a8..aff0cd7 100644 --- a/src/lib/server/config/performance.ts +++ b/src/lib/server/config/performance.ts @@ -21,7 +21,7 @@ */ function parseEnvInt(key: string, defaultValue: number): number { const value = process.env[key]; - if (value === undefined || value === '') { + if (value === undefined || value === "") { return defaultValue; } const parsed = Number.parseInt(value, 10); @@ -57,17 +57,17 @@ const SSE_BOUNDS = { */ export const SSE_CONFIG = { BATCH_WINDOW_MS: clamp( - parseEnvInt('SSE_BATCH_WINDOW_MS', SSE_DEFAULTS.BATCH_WINDOW_MS), + parseEnvInt("SSE_BATCH_WINDOW_MS", SSE_DEFAULTS.BATCH_WINDOW_MS), SSE_BOUNDS.BATCH_WINDOW_MS.min, SSE_BOUNDS.BATCH_WINDOW_MS.max, ), MAX_BATCH_SIZE: clamp( - parseEnvInt('SSE_MAX_BATCH_SIZE', SSE_DEFAULTS.MAX_BATCH_SIZE), + parseEnvInt("SSE_MAX_BATCH_SIZE", SSE_DEFAULTS.MAX_BATCH_SIZE), SSE_BOUNDS.MAX_BATCH_SIZE.min, SSE_BOUNDS.MAX_BATCH_SIZE.max, ), HEARTBEAT_INTERVAL_MS: clamp( - parseEnvInt('SSE_HEARTBEAT_INTERVAL_MS', SSE_DEFAULTS.HEARTBEAT_INTERVAL_MS), + parseEnvInt("SSE_HEARTBEAT_INTERVAL_MS", SSE_DEFAULTS.HEARTBEAT_INTERVAL_MS), SSE_BOUNDS.HEARTBEAT_INTERVAL_MS.min, SSE_BOUNDS.HEARTBEAT_INTERVAL_MS.max, ), @@ -88,7 +88,7 @@ const LOG_STREAM_DEFAULTS = { export const LOG_STREAM_CONFIG = { MAX_LOGS_UPPER_LIMIT: LOG_STREAM_DEFAULTS.MAX_LOGS_UPPER_LIMIT, DEFAULT_MAX_LOGS: clamp( - parseEnvInt('LOG_STREAM_MAX_LOGS', LOG_STREAM_DEFAULTS.DEFAULT_MAX_LOGS), + parseEnvInt("LOG_STREAM_MAX_LOGS", LOG_STREAM_DEFAULTS.DEFAULT_MAX_LOGS), 1, LOG_STREAM_DEFAULTS.MAX_LOGS_UPPER_LIMIT, ), @@ -113,12 +113,12 @@ const RETENTION_BOUNDS = { */ export const RETENTION_CONFIG = { LOG_RETENTION_DAYS: clamp( - parseEnvInt('LOG_RETENTION_DAYS', RETENTION_DEFAULTS.LOG_RETENTION_DAYS), + parseEnvInt("LOG_RETENTION_DAYS", RETENTION_DEFAULTS.LOG_RETENTION_DAYS), RETENTION_BOUNDS.LOG_RETENTION_DAYS.min, RETENTION_BOUNDS.LOG_RETENTION_DAYS.max, ), LOG_CLEANUP_INTERVAL_MS: clamp( - parseEnvInt('LOG_CLEANUP_INTERVAL_MS', RETENTION_DEFAULTS.LOG_CLEANUP_INTERVAL_MS), + parseEnvInt("LOG_CLEANUP_INTERVAL_MS", RETENTION_DEFAULTS.LOG_CLEANUP_INTERVAL_MS), RETENTION_BOUNDS.LOG_CLEANUP_INTERVAL_MS.min, RETENTION_BOUNDS.LOG_CLEANUP_INTERVAL_MS.max, ), @@ -162,7 +162,7 @@ const INCIDENT_BOUNDS = { */ export const INCIDENT_CONFIG = { AUTO_RESOLVE_MINUTES: clamp( - parseEnvInt('INCIDENT_AUTO_RESOLVE_MINUTES', INCIDENT_DEFAULTS.AUTO_RESOLVE_MINUTES), + parseEnvInt("INCIDENT_AUTO_RESOLVE_MINUTES", INCIDENT_DEFAULTS.AUTO_RESOLVE_MINUTES), INCIDENT_BOUNDS.AUTO_RESOLVE_MINUTES.min, INCIDENT_BOUNDS.AUTO_RESOLVE_MINUTES.max, ), diff --git a/src/lib/server/config/performance.unit.test.ts b/src/lib/server/config/performance.unit.test.ts index 6ff4a43..fe3257d 100644 --- a/src/lib/server/config/performance.unit.test.ts +++ b/src/lib/server/config/performance.unit.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -describe('Performance Configuration', () => { +describe("Performance Configuration", () => { // Store original env const originalEnv = { ...process.env }; @@ -15,114 +15,114 @@ describe('Performance Configuration', () => { vi.resetModules(); }); - describe('SSE Batching Configuration', () => { + describe("SSE Batching Configuration", () => { it.each([ - ['BATCH_WINDOW_MS', 1500], - ['MAX_BATCH_SIZE', 50], - ['HEARTBEAT_INTERVAL_MS', 30000], - ])('exports %s with default value of %d', async (key, expected) => { + ["BATCH_WINDOW_MS", 1500], + ["MAX_BATCH_SIZE", 50], + ["HEARTBEAT_INTERVAL_MS", 30000], + ])("exports %s with default value of %d", async (key, expected) => { vi.resetModules(); - const { SSE_CONFIG } = await import('./performance'); + const { SSE_CONFIG } = await import("./performance"); expect(SSE_CONFIG[key as keyof typeof SSE_CONFIG]).toBe(expected); }); it.each([ - ['SSE_BATCH_WINDOW_MS', 'BATCH_WINDOW_MS', '2000', 2000], - ['SSE_MAX_BATCH_SIZE', 'MAX_BATCH_SIZE', '100', 100], - ['SSE_HEARTBEAT_INTERVAL_MS', 'HEARTBEAT_INTERVAL_MS', '60000', 60000], - ])('respects %s environment variable', async (envKey, configKey, envValue, expected) => { + ["SSE_BATCH_WINDOW_MS", "BATCH_WINDOW_MS", "2000", 2000], + ["SSE_MAX_BATCH_SIZE", "MAX_BATCH_SIZE", "100", 100], + ["SSE_HEARTBEAT_INTERVAL_MS", "HEARTBEAT_INTERVAL_MS", "60000", 60000], + ])("respects %s environment variable", async (envKey, configKey, envValue, expected) => { vi.resetModules(); process.env[envKey] = envValue; - const { SSE_CONFIG } = await import('./performance'); + const { SSE_CONFIG } = await import("./performance"); expect(SSE_CONFIG[configKey as keyof typeof SSE_CONFIG]).toBe(expected); }); it.each([ - ['SSE_BATCH_WINDOW_MS', 'BATCH_WINDOW_MS', '50', 100], - ['SSE_MAX_BATCH_SIZE', 'MAX_BATCH_SIZE', '0', 1], - ['SSE_HEARTBEAT_INTERVAL_MS', 'HEARTBEAT_INTERVAL_MS', '1000', 5000], - ])('clamps %s to minimum', async (envKey, configKey, envValue, expected) => { + ["SSE_BATCH_WINDOW_MS", "BATCH_WINDOW_MS", "50", 100], + ["SSE_MAX_BATCH_SIZE", "MAX_BATCH_SIZE", "0", 1], + ["SSE_HEARTBEAT_INTERVAL_MS", "HEARTBEAT_INTERVAL_MS", "1000", 5000], + ])("clamps %s to minimum", async (envKey, configKey, envValue, expected) => { vi.resetModules(); process.env[envKey] = envValue; - const { SSE_CONFIG } = await import('./performance'); + const { SSE_CONFIG } = await import("./performance"); expect(SSE_CONFIG[configKey as keyof typeof SSE_CONFIG]).toBe(expected); }); - it('ignores invalid (non-numeric) environment values', async () => { - process.env.SSE_BATCH_WINDOW_MS = 'invalid'; - const { SSE_CONFIG } = await import('./performance'); + it("ignores invalid (non-numeric) environment values", async () => { + process.env.SSE_BATCH_WINDOW_MS = "invalid"; + const { SSE_CONFIG } = await import("./performance"); expect(SSE_CONFIG.BATCH_WINDOW_MS).toBe(1500); }); }); - describe('Log Stream Configuration', () => { - it('exports DEFAULT_MAX_LOGS with value of 1000', async () => { - const { LOG_STREAM_CONFIG } = await import('./performance'); + describe("Log Stream Configuration", () => { + it("exports DEFAULT_MAX_LOGS with value of 1000", async () => { + const { LOG_STREAM_CONFIG } = await import("./performance"); expect(LOG_STREAM_CONFIG.DEFAULT_MAX_LOGS).toBe(1000); }); - it('exports MAX_LOGS_UPPER_LIMIT with value of 10000', async () => { - const { LOG_STREAM_CONFIG } = await import('./performance'); + it("exports MAX_LOGS_UPPER_LIMIT with value of 10000", async () => { + const { LOG_STREAM_CONFIG } = await import("./performance"); expect(LOG_STREAM_CONFIG.MAX_LOGS_UPPER_LIMIT).toBe(10000); }); - it('respects LOG_STREAM_MAX_LOGS environment variable', async () => { - process.env.LOG_STREAM_MAX_LOGS = '5000'; - const { LOG_STREAM_CONFIG } = await import('./performance'); + it("respects LOG_STREAM_MAX_LOGS environment variable", async () => { + process.env.LOG_STREAM_MAX_LOGS = "5000"; + const { LOG_STREAM_CONFIG } = await import("./performance"); expect(LOG_STREAM_CONFIG.DEFAULT_MAX_LOGS).toBe(5000); }); - it('clamps DEFAULT_MAX_LOGS to MAX_LOGS_UPPER_LIMIT', async () => { - process.env.LOG_STREAM_MAX_LOGS = '20000'; - const { LOG_STREAM_CONFIG } = await import('./performance'); + it("clamps DEFAULT_MAX_LOGS to MAX_LOGS_UPPER_LIMIT", async () => { + process.env.LOG_STREAM_MAX_LOGS = "20000"; + const { LOG_STREAM_CONFIG } = await import("./performance"); expect(LOG_STREAM_CONFIG.DEFAULT_MAX_LOGS).toBe(10000); }); }); - describe('API Rate Limiting Configuration', () => { - it('exports BATCH_INSERT_LIMIT with default value of 100', async () => { - const { API_CONFIG } = await import('./performance'); + describe("API Rate Limiting Configuration", () => { + it("exports BATCH_INSERT_LIMIT with default value of 100", async () => { + const { API_CONFIG } = await import("./performance"); expect(API_CONFIG.BATCH_INSERT_LIMIT).toBe(100); }); - it('exports DEFAULT_PAGE_SIZE with value of 100', async () => { - const { API_CONFIG } = await import('./performance'); + it("exports DEFAULT_PAGE_SIZE with value of 100", async () => { + const { API_CONFIG } = await import("./performance"); expect(API_CONFIG.DEFAULT_PAGE_SIZE).toBe(100); }); - it('exports MAX_PAGE_SIZE with value of 500', async () => { - const { API_CONFIG } = await import('./performance'); + it("exports MAX_PAGE_SIZE with value of 500", async () => { + const { API_CONFIG } = await import("./performance"); expect(API_CONFIG.MAX_PAGE_SIZE).toBe(500); }); }); - describe('Incident Configuration', () => { - it('exports AUTO_RESOLVE_MINUTES with default value of 30', async () => { - const { INCIDENT_CONFIG } = await import('./performance'); + describe("Incident Configuration", () => { + it("exports AUTO_RESOLVE_MINUTES with default value of 30", async () => { + const { INCIDENT_CONFIG } = await import("./performance"); expect(INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES).toBe(30); }); - it('respects INCIDENT_AUTO_RESOLVE_MINUTES environment variable', async () => { - process.env.INCIDENT_AUTO_RESOLVE_MINUTES = '45'; - const { INCIDENT_CONFIG } = await import('./performance'); + it("respects INCIDENT_AUTO_RESOLVE_MINUTES environment variable", async () => { + process.env.INCIDENT_AUTO_RESOLVE_MINUTES = "45"; + const { INCIDENT_CONFIG } = await import("./performance"); expect(INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES).toBe(45); }); - it('clamps INCIDENT_AUTO_RESOLVE_MINUTES to minimum', async () => { - process.env.INCIDENT_AUTO_RESOLVE_MINUTES = '0'; - const { INCIDENT_CONFIG } = await import('./performance'); + it("clamps INCIDENT_AUTO_RESOLVE_MINUTES to minimum", async () => { + process.env.INCIDENT_AUTO_RESOLVE_MINUTES = "0"; + const { INCIDENT_CONFIG } = await import("./performance"); expect(INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES).toBe(1); }); }); - describe('Configuration Validation', () => { - it('validateSSEConfig returns true for valid config', async () => { - const { validateSSEConfig, SSE_CONFIG } = await import('./performance'); + describe("Configuration Validation", () => { + it("validateSSEConfig returns true for valid config", async () => { + const { validateSSEConfig, SSE_CONFIG } = await import("./performance"); expect(validateSSEConfig(SSE_CONFIG)).toBe(true); }); - it('validateSSEConfig returns false for invalid batch window', async () => { - const { validateSSEConfig } = await import('./performance'); + it("validateSSEConfig returns false for invalid batch window", async () => { + const { validateSSEConfig } = await import("./performance"); const invalidConfig = { BATCH_WINDOW_MS: -1, MAX_BATCH_SIZE: 50, @@ -131,8 +131,8 @@ describe('Performance Configuration', () => { expect(validateSSEConfig(invalidConfig)).toBe(false); }); - it('validateSSEConfig returns false for invalid batch size', async () => { - const { validateSSEConfig } = await import('./performance'); + it("validateSSEConfig returns false for invalid batch size", async () => { + const { validateSSEConfig } = await import("./performance"); const invalidConfig = { BATCH_WINDOW_MS: 1500, MAX_BATCH_SIZE: 0, diff --git a/src/lib/server/config/retention.unit.test.ts b/src/lib/server/config/retention.unit.test.ts index 30bd857..496207f 100644 --- a/src/lib/server/config/retention.unit.test.ts +++ b/src/lib/server/config/retention.unit.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -describe('Retention Configuration', () => { +describe("Retention Configuration", () => { // Store original env const originalEnv = { ...process.env }; @@ -15,83 +15,83 @@ describe('Retention Configuration', () => { vi.resetModules(); }); - describe('LOG_RETENTION_DAYS', () => { - it('should default to 30 when not set', async () => { + describe("LOG_RETENTION_DAYS", () => { + it("should default to 30 when not set", async () => { vi.resetModules(); delete process.env.LOG_RETENTION_DAYS; - const { RETENTION_CONFIG } = await import('./performance'); + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(30); }); - it('should accept 0 (disabled)', async () => { + it("should accept 0 (disabled)", async () => { vi.resetModules(); - process.env.LOG_RETENTION_DAYS = '0'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_RETENTION_DAYS = "0"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(0); }); - it('should clamp negative values to 0', async () => { + it("should clamp negative values to 0", async () => { vi.resetModules(); - process.env.LOG_RETENTION_DAYS = '-10'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_RETENTION_DAYS = "-10"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(0); }); - it('should clamp values above 3650 to 3650', async () => { + it("should clamp values above 3650 to 3650", async () => { vi.resetModules(); - process.env.LOG_RETENTION_DAYS = '5000'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_RETENTION_DAYS = "5000"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(3650); }); - it('should accept valid values within range', async () => { + it("should accept valid values within range", async () => { vi.resetModules(); - process.env.LOG_RETENTION_DAYS = '90'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_RETENTION_DAYS = "90"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(90); }); - it('should ignore invalid (non-numeric) values', async () => { + it("should ignore invalid (non-numeric) values", async () => { vi.resetModules(); - process.env.LOG_RETENTION_DAYS = 'invalid'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_RETENTION_DAYS = "invalid"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(30); }); }); - describe('LOG_CLEANUP_INTERVAL_MS', () => { - it('should default to 3600000 (1 hour) when not set', async () => { + describe("LOG_CLEANUP_INTERVAL_MS", () => { + it("should default to 3600000 (1 hour) when not set", async () => { vi.resetModules(); delete process.env.LOG_CLEANUP_INTERVAL_MS; - const { RETENTION_CONFIG } = await import('./performance'); + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(3600000); }); - it('should clamp to minimum 60000 (1 minute)', async () => { + it("should clamp to minimum 60000 (1 minute)", async () => { vi.resetModules(); - process.env.LOG_CLEANUP_INTERVAL_MS = '30000'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_CLEANUP_INTERVAL_MS = "30000"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(60000); }); - it('should clamp to maximum 86400000 (24 hours)', async () => { + it("should clamp to maximum 86400000 (24 hours)", async () => { vi.resetModules(); - process.env.LOG_CLEANUP_INTERVAL_MS = '100000000'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_CLEANUP_INTERVAL_MS = "100000000"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(86400000); }); - it('should accept valid values within range', async () => { + it("should accept valid values within range", async () => { vi.resetModules(); - process.env.LOG_CLEANUP_INTERVAL_MS = '1800000'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_CLEANUP_INTERVAL_MS = "1800000"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(1800000); }); - it('should ignore invalid (non-numeric) values', async () => { + it("should ignore invalid (non-numeric) values", async () => { vi.resetModules(); - process.env.LOG_CLEANUP_INTERVAL_MS = 'invalid'; - const { RETENTION_CONFIG } = await import('./performance'); + process.env.LOG_CLEANUP_INTERVAL_MS = "invalid"; + const { RETENTION_CONFIG } = await import("./performance"); expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(3600000); }); }); diff --git a/src/lib/server/db/README.md b/src/lib/server/db/README.md index 5bc6cd8..00ef130 100644 --- a/src/lib/server/db/README.md +++ b/src/lib/server/db/README.md @@ -9,7 +9,7 @@ This directory contains the database schema, client initialization, and test uti Drizzle ORM table definitions for the application. ```typescript -import * as schema from './schema'; +import * as schema from "./schema"; // Access tables const users = schema.user; @@ -20,7 +20,7 @@ const users = schema.user; Production database client initialization using postgres.js. ```typescript -import { db } from './index'; +import { db } from "./index"; // Use in production/development const users = await db.select().from(schema.user); @@ -44,7 +44,7 @@ In-memory PGlite database for testing. **Do not use in production.** Creates a new in-memory PGlite database with all schema tables. ```typescript -import { createTestDatabase } from './test-db'; +import { createTestDatabase } from "./test-db"; const db = await createTestDatabase(); // All schema tables are created automatically @@ -55,7 +55,7 @@ const db = await createTestDatabase(); Truncates all tables and restarts identity sequences. ```typescript -import { cleanDatabase } from './test-db'; +import { cleanDatabase } from "./test-db"; await cleanDatabase(db); // All tables are now empty, sequences reset to 1 @@ -66,7 +66,7 @@ await cleanDatabase(db); Convenience function that creates a database and returns it with a cleanup function. ```typescript -import { setupTestDatabase } from './test-db'; +import { setupTestDatabase } from "./test-db"; const { db, cleanup } = await setupTestDatabase(); @@ -78,12 +78,12 @@ await cleanup(); // Truncates all tables #### Usage in Tests ```typescript -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import { afterEach, beforeEach, describe, it } from 'vitest'; -import * as schema from './schema'; -import { setupTestDatabase } from './test-db'; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import { afterEach, beforeEach, describe, it } from "vitest"; +import * as schema from "./schema"; +import { setupTestDatabase } from "./test-db"; -describe('Database Tests', () => { +describe("Database Tests", () => { let db: PgliteDatabase; let cleanup: () => Promise; @@ -97,7 +97,7 @@ describe('Database Tests', () => { await cleanup(); }); - it('should work with database', async () => { + it("should work with database", async () => { const result = await db.insert(schema.user).values({ age: 25 }).returning(); // Test assertions... }); @@ -120,10 +120,10 @@ Example: ```typescript // schema.ts -export const posts = pgTable('posts', { - id: serial('id').primaryKey(), - title: text('title').notNull(), - content: text('content').notNull(), +export const posts = pgTable("posts", { + id: serial("id").primaryKey(), + title: text("title").notNull(), + content: text("content").notNull(), }); // test-db.ts automatically creates the "posts" table diff --git a/src/lib/server/db/db.ts b/src/lib/server/db/db.ts index db20768..817efa2 100644 --- a/src/lib/server/db/db.ts +++ b/src/lib/server/db/db.ts @@ -6,9 +6,9 @@ * (e.g. raw query result shapes) so callers never think about them. */ -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import type * as schema from './schema'; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type * as schema from "./schema"; /** Unified database client consumed by all business logic and routes. */ export type DatabaseClient = PostgresJsDatabase | PgliteDatabase; @@ -39,7 +39,7 @@ export async function getDbClient(locals: App.Locals): Promise { if (locals.db) { return locals.db as DatabaseClient; } - const { db } = await import('./index'); + const { db } = await import("./index"); return db; } @@ -49,7 +49,7 @@ export async function getDbClient(locals: App.Locals): Promise { */ export async function executeQuery( db: DatabaseClient, - query: Parameters[0], + query: Parameters[0], ): Promise { const raw = await db.execute(query); return getQueryRows(raw as QueryRows); diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 4785f09..3d3056e 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,9 +1,9 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import { env } from '$env/dynamic/private'; -import * as schema from './schema'; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { env } from "$env/dynamic/private"; +import * as schema from "./schema"; -if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); +if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); const client = postgres(env.DATABASE_URL, { max: 20, @@ -13,4 +13,4 @@ const client = postgres(env.DATABASE_URL, { export const db = drizzle(client, { schema }); -export type { DatabaseClient } from './db'; +export type { DatabaseClient } from "./db"; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8ce1486..bd09248 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import { type SQL, sql } from 'drizzle-orm'; +import { type SQL, sql } from "drizzle-orm"; import { boolean, customType, @@ -10,32 +10,32 @@ import { text, timestamp, uniqueIndex, -} from 'drizzle-orm/pg-core'; +} from "drizzle-orm/pg-core"; // Project table export const project = pgTable( - 'project', + "project", { - id: text('id').primaryKey(), - name: text('name').notNull(), + id: text("id").primaryKey(), + name: text("name").notNull(), // API keys are stored hashed only (SHA-256). The plaintext key is shown to // the user once at creation/regeneration and never persisted. - apiKeyHash: text('api_key_hash').notNull().unique(), + apiKeyHash: text("api_key_hash").notNull().unique(), // Owner of the project - required for authorization - ownerId: text('owner_id') + ownerId: text("owner_id") .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => user.id, { onDelete: "cascade" }), // Log retention configuration: // - null: use system default (LOG_RETENTION_DAYS env var) // - 0: never auto-delete logs // - >0: delete logs older than N days - retentionDays: integer('retention_days'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + retentionDays: integer("retention_days"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, (table) => [ - index('idx_project_owner_id').on(table.ownerId), - uniqueIndex('uq_project_name_owner').on(table.name, table.ownerId), + index("idx_project_owner_id").on(table.ownerId), + uniqueIndex("uq_project_name_owner").on(table.name, table.ownerId), ], ); @@ -46,37 +46,37 @@ export type NewProject = typeof project.$inferInsert; // Custom tsvector type for Drizzle const tsvector = customType<{ data: string }>({ dataType() { - return 'tsvector'; + return "tsvector"; }, }); // Log level enum -export const logLevelEnum = pgEnum('log_level', ['debug', 'info', 'warn', 'error', 'fatal']); +export const logLevelEnum = pgEnum("log_level", ["debug", "info", "warn", "error", "fatal"]); // Incident table with fingerprint grouping export const incident = pgTable( - 'incident', + "incident", { - id: text('id').primaryKey(), - projectId: text('project_id') + id: text("id").primaryKey(), + projectId: text("project_id") .notNull() - .references(() => project.id, { onDelete: 'cascade' }), - fingerprint: text('fingerprint').notNull(), - title: text('title').notNull(), - normalizedMessage: text('normalized_message').notNull(), - serviceName: text('service_name'), - sourceFile: text('source_file'), - lineNumber: integer('line_number'), - highestLevel: logLevelEnum('highest_level').notNull(), - firstSeen: timestamp('first_seen', { withTimezone: true }).notNull(), - lastSeen: timestamp('last_seen', { withTimezone: true }).notNull(), - totalEvents: integer('total_events').notNull().default(0), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + .references(() => project.id, { onDelete: "cascade" }), + fingerprint: text("fingerprint").notNull(), + title: text("title").notNull(), + normalizedMessage: text("normalized_message").notNull(), + serviceName: text("service_name"), + sourceFile: text("source_file"), + lineNumber: integer("line_number"), + highestLevel: logLevelEnum("highest_level").notNull(), + firstSeen: timestamp("first_seen", { withTimezone: true }).notNull(), + lastSeen: timestamp("last_seen", { withTimezone: true }).notNull(), + totalEvents: integer("total_events").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, (table) => [ - index('idx_incident_project_last_seen').on(table.projectId, table.lastSeen), - uniqueIndex('uq_incident_project_fingerprint').on(table.projectId, table.fingerprint), + index("idx_incident_project_last_seen").on(table.projectId, table.lastSeen), + uniqueIndex("uq_incident_project_fingerprint").on(table.projectId, table.fingerprint), ], ); @@ -86,42 +86,42 @@ export type NewIncident = typeof incident.$inferInsert; // Log table with full-text search export const log = pgTable( - 'log', + "log", { - id: text('id').primaryKey(), - projectId: text('project_id') + id: text("id").primaryKey(), + projectId: text("project_id") .notNull() - .references(() => project.id, { onDelete: 'cascade' }), - incidentId: text('incident_id').references(() => incident.id, { onDelete: 'set null' }), - fingerprint: text('fingerprint'), - serviceName: text('service_name'), - level: logLevelEnum('level').notNull(), - message: text('message').notNull(), - metadata: jsonb('metadata'), - timeUnixNano: text('time_unix_nano'), - observedTimeUnixNano: text('observed_time_unix_nano'), - severityNumber: integer('severity_number'), - severityText: text('severity_text'), - body: jsonb('body'), - droppedAttributesCount: integer('dropped_attributes_count'), - flags: integer('flags'), - traceId: text('trace_id'), - spanId: text('span_id'), - resourceAttributes: jsonb('resource_attributes'), - resourceDroppedAttributesCount: integer('resource_dropped_attributes_count'), - resourceSchemaUrl: text('resource_schema_url'), - scopeName: text('scope_name'), - scopeVersion: text('scope_version'), - scopeAttributes: jsonb('scope_attributes'), - scopeDroppedAttributesCount: integer('scope_dropped_attributes_count'), - scopeSchemaUrl: text('scope_schema_url'), - sourceFile: text('source_file'), - lineNumber: integer('line_number'), - requestId: text('request_id'), - userId: text('user_id'), - ipAddress: text('ip_address'), - timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(), - search: tsvector('search').generatedAlwaysAs( + .references(() => project.id, { onDelete: "cascade" }), + incidentId: text("incident_id").references(() => incident.id, { onDelete: "set null" }), + fingerprint: text("fingerprint"), + serviceName: text("service_name"), + level: logLevelEnum("level").notNull(), + message: text("message").notNull(), + metadata: jsonb("metadata"), + timeUnixNano: text("time_unix_nano"), + observedTimeUnixNano: text("observed_time_unix_nano"), + severityNumber: integer("severity_number"), + severityText: text("severity_text"), + body: jsonb("body"), + droppedAttributesCount: integer("dropped_attributes_count"), + flags: integer("flags"), + traceId: text("trace_id"), + spanId: text("span_id"), + resourceAttributes: jsonb("resource_attributes"), + resourceDroppedAttributesCount: integer("resource_dropped_attributes_count"), + resourceSchemaUrl: text("resource_schema_url"), + scopeName: text("scope_name"), + scopeVersion: text("scope_version"), + scopeAttributes: jsonb("scope_attributes"), + scopeDroppedAttributesCount: integer("scope_dropped_attributes_count"), + scopeSchemaUrl: text("scope_schema_url"), + sourceFile: text("source_file"), + lineNumber: integer("line_number"), + requestId: text("request_id"), + userId: text("user_id"), + ipAddress: text("ip_address"), + timestamp: timestamp("timestamp", { withTimezone: true }).defaultNow().notNull(), + search: tsvector("search").generatedAlwaysAs( (): SQL => sql`setweight(to_tsvector('english', ${log.message}), 'A') || setweight(to_tsvector('english', COALESCE(${log.body}::text, '')), 'B') || @@ -131,43 +131,43 @@ export const log = pgTable( ), }, (table) => [ - index('idx_log_project_incident_timestamp').on( + index("idx_log_project_incident_timestamp").on( table.projectId, table.incidentId, table.timestamp, ), - index('idx_log_project_fingerprint_timestamp').on( + index("idx_log_project_fingerprint_timestamp").on( table.projectId, table.fingerprint, table.timestamp, ), - index('idx_log_project_service_name').on(table.projectId, table.serviceName), - index('idx_log_timestamp').on(table.timestamp), - index('idx_log_level').on(table.level), - index('idx_log_project_timestamp').on(table.projectId, table.timestamp), + index("idx_log_project_service_name").on(table.projectId, table.serviceName), + index("idx_log_timestamp").on(table.timestamp), + index("idx_log_level").on(table.level), + index("idx_log_project_timestamp").on(table.projectId, table.timestamp), // GIN index for full-text search - index('idx_log_search').using('gin', table.search), + index("idx_log_search").using("gin", table.search), ], ); // Type exports for log export type Log = typeof log.$inferSelect; export type NewLog = typeof log.$inferInsert; -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; // better-auth tables // User table -export const user = pgTable('user', { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - emailVerified: boolean('email_verified').default(false).notNull(), - image: text('image'), - username: text('username').unique(), - displayUsername: text('display_username'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + username: text("username").unique(), + displayUsername: text("display_username"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), @@ -179,23 +179,23 @@ export type NewUser = typeof user.$inferInsert; // Session table export const session = pgTable( - 'session', + "session", { - id: text('id').primaryKey(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - token: text('token').notNull().unique(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - userId: text('user_id') + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => user.id, { onDelete: "cascade" }), }, - (table) => [index('session_userId_idx').on(table.userId)], + (table) => [index("session_userId_idx").on(table.userId)], ); // Type exports for session @@ -204,28 +204,28 @@ export type NewSession = typeof session.$inferInsert; // Account table export const account = pgTable( - 'account', + "account", { - id: text('id').primaryKey(), - accountId: text('account_id').notNull(), - providerId: text('provider_id').notNull(), - userId: text('user_id') + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - idToken: text('id_token'), - accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), - scope: text('scope'), - password: text('password'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, - (table) => [index('account_userId_idx').on(table.userId)], + (table) => [index("account_userId_idx").on(table.userId)], ); // Type exports for account @@ -234,19 +234,19 @@ export type NewAccount = typeof account.$inferInsert; // Verification table export const verification = pgTable( - 'verification', + "verification", { - id: text('id').primaryKey(), - identifier: text('identifier').notNull(), - value: text('value').notNull(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, - (table) => [index('verification_identifier_idx').on(table.identifier)], + (table) => [index("verification_identifier_idx").on(table.identifier)], ); // Type exports for verification diff --git a/src/lib/server/db/test-db.ts b/src/lib/server/db/test-db.ts index 80aa9ff..cf5780c 100644 --- a/src/lib/server/db/test-db.ts +++ b/src/lib/server/db/test-db.ts @@ -1,9 +1,9 @@ -import { PGlite } from '@electric-sql/pglite'; -import { is, sql } from 'drizzle-orm'; -import { getTableConfig, PgTable } from 'drizzle-orm/pg-core'; -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import { drizzle } from 'drizzle-orm/pglite'; -import * as schema from './schema'; +import { PGlite } from "@electric-sql/pglite"; +import { is, sql } from "drizzle-orm"; +import { getTableConfig, PgTable } from "drizzle-orm/pg-core"; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import * as schema from "./schema"; /** * Generates CREATE TABLE SQL from Drizzle schema table definition @@ -21,8 +21,8 @@ function generateCreateTableSQL(table: PgTable): string { // Handle custom types (like tsvector) const columnType = column.columnType; - const isCustomType = columnType === 'PgCustomColumn'; - const isEnumType = columnType === 'PgEnumColumn'; + const isCustomType = columnType === "PgCustomColumn"; + const isEnumType = columnType === "PgEnumColumn"; const isGeneratedColumn = (column as { generated?: unknown }).generated !== undefined; // Add data type @@ -33,7 +33,7 @@ function generateCreateTableSQL(table: PgTable): string { parts.push(customColumn.getSQLType()); } else { // Fallback for tsvector - parts.push('TSVECTOR'); + parts.push("TSVECTOR"); } } else if (isEnumType) { // For enum columns, use the enum type name @@ -41,48 +41,48 @@ function generateCreateTableSQL(table: PgTable): string { if (enumColumn.enumName) { parts.push(enumColumn.enumName); } else { - parts.push('TEXT'); + parts.push("TEXT"); } - } else if (column.dataType === 'number') { - if (column.columnType === 'PgSerial') { - parts.push('SERIAL'); + } else if (column.dataType === "number") { + if (column.columnType === "PgSerial") { + parts.push("SERIAL"); } else { - parts.push('INTEGER'); + parts.push("INTEGER"); } - } else if (column.dataType === 'string') { - if (column.columnType.includes('Text')) { - parts.push('TEXT'); - } else if (column.columnType.includes('Varchar')) { + } else if (column.dataType === "string") { + if (column.columnType.includes("Text")) { + parts.push("TEXT"); + } else if (column.columnType.includes("Varchar")) { // Extract length if available - parts.push('VARCHAR(255)'); + parts.push("VARCHAR(255)"); } else { - parts.push('TEXT'); + parts.push("TEXT"); } - } else if (column.dataType === 'boolean') { - parts.push('BOOLEAN'); - } else if (column.dataType === 'date') { + } else if (column.dataType === "boolean") { + parts.push("BOOLEAN"); + } else if (column.dataType === "date") { // Check if it's a timestamp with timezone - if (column.columnType === 'PgTimestamp') { + if (column.columnType === "PgTimestamp") { const withTimezone = (column as unknown as { withTimezone?: boolean }).withTimezone; if (withTimezone) { - parts.push('TIMESTAMPTZ'); + parts.push("TIMESTAMPTZ"); } else { - parts.push('TIMESTAMP'); + parts.push("TIMESTAMP"); } } else { - parts.push('TIMESTAMP'); + parts.push("TIMESTAMP"); } - } else if (column.dataType === 'json') { - parts.push('JSONB'); + } else if (column.dataType === "json") { + parts.push("JSONB"); } else { // Default fallback - parts.push('TEXT'); + parts.push("TEXT"); } // Handle generated columns if (isGeneratedColumn) { const generated = (column as { generated?: { as: unknown; type?: string } }).generated; - if (generated && generated.type === 'stored') { + if (generated && generated.type === "stored") { // Skip generated columns in PGlite as it has limited support // We'll handle full-text search via triggers continue; @@ -91,29 +91,29 @@ function generateCreateTableSQL(table: PgTable): string { // Add constraints if (column.notNull) { - parts.push('NOT NULL'); + parts.push("NOT NULL"); } if (column.primary) { - parts.push('PRIMARY KEY'); + parts.push("PRIMARY KEY"); } // Handle default values - check hasDefault first if (column.hasDefault && !isGeneratedColumn) { // For timestamp columns with defaultNow(), we need to check the actual default - if (column.dataType === 'date') { + if (column.dataType === "date") { // Check if the column has a default function const defaultFn = (column as unknown as { default?: unknown }).default; if (defaultFn) { - parts.push('DEFAULT NOW()'); + parts.push("DEFAULT NOW()"); } - } else if (column.dataType === 'boolean') { + } else if (column.dataType === "boolean") { // Handle boolean defaults const defaultValue = (column as unknown as { default?: unknown }).default; if (defaultValue !== undefined) { // Check if it's a simple value or wrapped const value = - typeof defaultValue === 'object' && defaultValue !== null && 'value' in defaultValue + typeof defaultValue === "object" && defaultValue !== null && "value" in defaultValue ? (defaultValue as { value: unknown }).value : defaultValue; parts.push(`DEFAULT ${value}`); @@ -121,18 +121,18 @@ function generateCreateTableSQL(table: PgTable): string { } else if (column.default !== undefined) { const rawDefault = column.default; const defaultValue = - typeof rawDefault === 'object' && rawDefault !== null && 'value' in rawDefault + typeof rawDefault === "object" && rawDefault !== null && "value" in rawDefault ? (rawDefault as { value?: unknown }).value : rawDefault; - if (defaultValue && typeof defaultValue === 'object' && 'sql' in defaultValue) { + if (defaultValue && typeof defaultValue === "object" && "sql" in defaultValue) { // Handle SQL default expressions const sqlValue = (defaultValue as { sql?: string }).sql; parts.push(`DEFAULT ${sqlValue}`); - } else if (typeof defaultValue === 'string') { + } else if (typeof defaultValue === "string") { parts.push(`DEFAULT '${defaultValue}'`); - } else if (typeof defaultValue === 'number') { + } else if (typeof defaultValue === "number") { parts.push(`DEFAULT ${defaultValue}`); - } else if (typeof defaultValue === 'boolean') { + } else if (typeof defaultValue === "boolean") { parts.push(`DEFAULT ${defaultValue}`); } } @@ -143,7 +143,7 @@ function generateCreateTableSQL(table: PgTable): string { uniqueConstraints.push(`UNIQUE("${column.name}")`); } - columns.push(parts.join(' ')); + columns.push(parts.join(" ")); } // Process foreign keys from table config @@ -159,8 +159,8 @@ function generateCreateTableSQL(table: PgTable): string { foreignTable: PgTable; }; - const localColumns = refDetails.columns.map((c) => `"${c.name}"`).join(', '); - const foreignColumns = refDetails.foreignColumns.map((c) => `"${c.name}"`).join(', '); + const localColumns = refDetails.columns.map((c) => `"${c.name}"`).join(", "); + const foreignColumns = refDetails.foreignColumns.map((c) => `"${c.name}"`).join(", "); // Get the foreign table name const foreignTableConfig = getTableConfig(refDetails.foreignTable); @@ -180,7 +180,7 @@ function generateCreateTableSQL(table: PgTable): string { foreignKeys.push(fkConstraint); } catch (error) { - console.warn('Could not process foreign key:', error); + console.warn("Could not process foreign key:", error); } } } @@ -199,7 +199,7 @@ function generateCreateTableSQL(table: PgTable): string { return colName ? `"${colName}"` : null; }) .filter((name): name is string => name !== null) - .join(', '); + .join(", "); if (columnNames) { uniqueConstraints.push(`UNIQUE(${columnNames})`); } @@ -211,7 +211,7 @@ function generateCreateTableSQL(table: PgTable): string { const allConstraints = [...columns, ...uniqueConstraints, ...foreignKeys]; // Create table SQL - const createTableSQL = `CREATE TABLE IF NOT EXISTS "${tableName}" (${allConstraints.join(', ')})`; + const createTableSQL = `CREATE TABLE IF NOT EXISTS "${tableName}" (${allConstraints.join(", ")})`; return createTableSQL; } @@ -233,13 +233,13 @@ function generateIndexSQL(table: PgTable): string[] { .map((col) => { const colName = (col as { name: string }).name; // Skip tsvector search column in PGlite - if (colName === 'search') { + if (colName === "search") { return null; } return `"${colName}"`; }) .filter((name) => name !== null) - .join(', '); + .join(", "); if (columnNames) { const isUnique = (index as unknown as { config?: { unique?: boolean } }).config?.unique; @@ -273,7 +273,7 @@ async function createEnumTypes(db: PgliteDatabase): Promise ); } catch (error) { // Enum might already exist, ignore error - console.warn('Could not create log_level enum:', error); + console.warn("Could not create log_level enum:", error); } } @@ -308,7 +308,7 @@ async function createTriggers(db: PgliteDatabase): Promise ); } catch (error) { // Trigger might already exist, ignore error - console.warn('Could not create log search trigger:', error); + console.warn("Could not create log search trigger:", error); } } @@ -325,13 +325,13 @@ export async function createTestDatabase(): Promise project/session/account/verification -> log const tableOrder = [ - 'user', // No dependencies - 'project', // No dependencies - 'incident', // Depends on project - 'session', // Depends on user - 'account', // Depends on user - 'verification', // No dependencies - 'log', // Depends on project + incident + "user", // No dependencies + "project", // No dependencies + "incident", // Depends on project + "session", // Depends on user + "account", // Depends on user + "verification", // No dependencies + "log", // Depends on project + incident ]; const tables = Object.values(schema).filter((item) => is(item, PgTable)); diff --git a/src/lib/server/db/test-utils.ts b/src/lib/server/db/test-utils.ts index a6ce925..9c5d092 100644 --- a/src/lib/server/db/test-utils.ts +++ b/src/lib/server/db/test-utils.ts @@ -6,8 +6,4 @@ * from fixtures/db.ts for seeding helpers. */ -export { - cleanDatabase, - createTestDatabase, - setupTestDatabase, -} from './test-db'; +export { cleanDatabase, createTestDatabase, setupTestDatabase } from "./test-db"; diff --git a/src/lib/server/error-handler.ts b/src/lib/server/error-handler.ts index 4a746a2..6fbb0ee 100644 --- a/src/lib/server/error-handler.ts +++ b/src/lib/server/error-handler.ts @@ -1,4 +1,4 @@ -import { nanoid } from 'nanoid'; +import { nanoid } from "nanoid"; /** * Context for error handling @@ -49,7 +49,7 @@ export function createErrorHandler(): (context: ErrorContext) => ErrorResponse { // For 5xx errors, sanitize the message to avoid leaking internal details // For 4xx errors, preserve the user-friendly message - const clientMessage = status >= 500 ? 'Internal server error' : message; + const clientMessage = status >= 500 ? "Internal server error" : message; return { id: errorId, diff --git a/src/lib/server/events.ts b/src/lib/server/events.ts index 8f05bd8..a63d777 100644 --- a/src/lib/server/events.ts +++ b/src/lib/server/events.ts @@ -1,4 +1,4 @@ -import type { Incident, Log } from './db/schema'; +import type { Incident, Log } from "./db/schema"; export type LogListener = (log: Log) => void; export type IncidentListener = (incident: Incident) => void; @@ -48,7 +48,7 @@ class LogEventBus { try { listener(log); } catch (e) { - console.error('[events] listener error:', e); + console.error("[events] listener error:", e); } } } @@ -90,7 +90,7 @@ class LogEventBus { try { listener(incident); } catch (e) { - console.error('[events] listener error:', e); + console.error("[events] listener error:", e); } } } diff --git a/src/lib/server/events.unit.test.ts b/src/lib/server/events.unit.test.ts index bd1d276..524ca30 100644 --- a/src/lib/server/events.unit.test.ts +++ b/src/lib/server/events.unit.test.ts @@ -1,18 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Incident, Log } from './db/schema'; -import { logEventBus } from './events'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Incident, Log } from "./db/schema"; +import { logEventBus } from "./events"; -describe('Log Event Bus', () => { +describe("Log Event Bus", () => { // Sample log for testing const sampleLog: Log = { - id: 'log-1', - projectId: 'project-1', + id: "log-1", + projectId: "project-1", incidentId: null, fingerprint: null, serviceName: null, - level: 'info', - message: 'Test log message', - metadata: { key: 'value' }, + level: "info", + message: "Test log message", + metadata: { key: "value" }, timeUnixNano: null, observedTimeUnixNano: null, severityNumber: null, @@ -30,25 +30,25 @@ describe('Log Event Bus', () => { scopeAttributes: null, scopeDroppedAttributesCount: null, scopeSchemaUrl: null, - sourceFile: 'test.ts', + sourceFile: "test.ts", lineNumber: 42, - requestId: 'req-123', - userId: 'user-1', - ipAddress: '127.0.0.1', + requestId: "req-123", + userId: "user-1", + ipAddress: "127.0.0.1", timestamp: new Date(), search: null, }; const sampleIncident: Incident = { - id: 'inc-1', - projectId: 'project-1', - fingerprint: 'abcd1234', - title: 'Database timeout', - normalizedMessage: 'database timeout after {num}ms', - serviceName: 'api', - sourceFile: 'db.ts', + id: "inc-1", + projectId: "project-1", + fingerprint: "abcd1234", + title: "Database timeout", + normalizedMessage: "database timeout after {num}ms", + serviceName: "api", + sourceFile: "db.ts", lineNumber: 42, - highestLevel: 'error', + highestLevel: "error", firstSeen: new Date(), lastSeen: new Date(), totalEvents: 5, @@ -65,10 +65,10 @@ describe('Log Event Bus', () => { vi.restoreAllMocks(); }); - describe('emitLog', () => { - it('triggers registered listeners', () => { + describe("emitLog", () => { + it("triggers registered listeners", () => { const listener = vi.fn(); - logEventBus.onLog('project-1', listener); + logEventBus.onLog("project-1", listener); logEventBus.emitLog(sampleLog); @@ -76,11 +76,11 @@ describe('Log Event Bus', () => { expect(listener).toHaveBeenCalledWith(sampleLog); }); - it('triggers multiple listeners for same project', () => { + it("triggers multiple listeners for same project", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - logEventBus.onLog('project-1', listener1); - logEventBus.onLog('project-1', listener2); + logEventBus.onLog("project-1", listener1); + logEventBus.onLog("project-1", listener2); logEventBus.emitLog(sampleLog); @@ -88,22 +88,22 @@ describe('Log Event Bus', () => { expect(listener2).toHaveBeenCalledTimes(1); }); - it('does not throw when emitting to project with no listeners', () => { + it("does not throw when emitting to project with no listeners", () => { expect(() => logEventBus.emitLog(sampleLog)).not.toThrow(); }); }); - describe('onLog', () => { - it('returns unsubscribe function', () => { + describe("onLog", () => { + it("returns unsubscribe function", () => { const listener = vi.fn(); - const unsubscribe = logEventBus.onLog('project-1', listener); + const unsubscribe = logEventBus.onLog("project-1", listener); - expect(typeof unsubscribe).toBe('function'); + expect(typeof unsubscribe).toBe("function"); }); - it('registers listener for specific project', () => { + it("registers listener for specific project", () => { const listener = vi.fn(); - logEventBus.onLog('project-1', listener); + logEventBus.onLog("project-1", listener); // Emit log for the subscribed project logEventBus.emitLog(sampleLog); @@ -112,10 +112,10 @@ describe('Log Event Bus', () => { }); }); - describe('unsubscribe', () => { - it('removes listener from receiving events', () => { + describe("unsubscribe", () => { + it("removes listener from receiving events", () => { const listener = vi.fn(); - const unsubscribe = logEventBus.onLog('project-1', listener); + const unsubscribe = logEventBus.onLog("project-1", listener); // Emit first log - should be received logEventBus.emitLog(sampleLog); @@ -125,15 +125,15 @@ describe('Log Event Bus', () => { unsubscribe(); // Emit second log - should NOT be received - logEventBus.emitLog({ ...sampleLog, id: 'log-2' }); + logEventBus.emitLog({ ...sampleLog, id: "log-2" }); expect(listener).toHaveBeenCalledTimes(1); }); - it('only removes the specific listener', () => { + it("only removes the specific listener", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - const unsubscribe1 = logEventBus.onLog('project-1', listener1); - logEventBus.onLog('project-1', listener2); + const unsubscribe1 = logEventBus.onLog("project-1", listener1); + logEventBus.onLog("project-1", listener2); // Unsubscribe first listener only unsubscribe1(); @@ -144,9 +144,9 @@ describe('Log Event Bus', () => { expect(listener2).toHaveBeenCalledTimes(1); }); - it('is idempotent - calling multiple times is safe', () => { + it("is idempotent - calling multiple times is safe", () => { const listener = vi.fn(); - const unsubscribe = logEventBus.onLog('project-1', listener); + const unsubscribe = logEventBus.onLog("project-1", listener); unsubscribe(); expect(() => unsubscribe()).not.toThrow(); @@ -154,12 +154,12 @@ describe('Log Event Bus', () => { }); }); - describe('project scoping', () => { - it('events are project-scoped - only matching project receives events', () => { + describe("project scoping", () => { + it("events are project-scoped - only matching project receives events", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - logEventBus.onLog('project-1', listener1); - logEventBus.onLog('project-2', listener2); + logEventBus.onLog("project-1", listener1); + logEventBus.onLog("project-2", listener2); // Emit log for project-1 logEventBus.emitLog(sampleLog); @@ -168,14 +168,14 @@ describe('Log Event Bus', () => { expect(listener2).not.toHaveBeenCalled(); }); - it('logs are routed to correct project listeners', () => { + it("logs are routed to correct project listeners", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - logEventBus.onLog('project-1', listener1); - logEventBus.onLog('project-2', listener2); + logEventBus.onLog("project-1", listener1); + logEventBus.onLog("project-2", listener2); // Emit log for project-2 - const project2Log = { ...sampleLog, id: 'log-2', projectId: 'project-2' }; + const project2Log = { ...sampleLog, id: "log-2", projectId: "project-2" }; logEventBus.emitLog(project2Log); expect(listener1).not.toHaveBeenCalled(); @@ -183,65 +183,65 @@ describe('Log Event Bus', () => { expect(listener2).toHaveBeenCalledWith(project2Log); }); - it('same listener can subscribe to multiple projects', () => { + it("same listener can subscribe to multiple projects", () => { const listener = vi.fn(); - logEventBus.onLog('project-1', listener); - logEventBus.onLog('project-2', listener); + logEventBus.onLog("project-1", listener); + logEventBus.onLog("project-2", listener); logEventBus.emitLog(sampleLog); - logEventBus.emitLog({ ...sampleLog, id: 'log-2', projectId: 'project-2' }); + logEventBus.emitLog({ ...sampleLog, id: "log-2", projectId: "project-2" }); expect(listener).toHaveBeenCalledTimes(2); }); }); - describe('getListenerCount', () => { - it('returns 0 for project with no listeners', () => { - expect(logEventBus.getListenerCount('non-existent')).toBe(0); + describe("getListenerCount", () => { + it("returns 0 for project with no listeners", () => { + expect(logEventBus.getListenerCount("non-existent")).toBe(0); }); - it('returns correct count of listeners', () => { - logEventBus.onLog('project-1', vi.fn()); - logEventBus.onLog('project-1', vi.fn()); - logEventBus.onLog('project-2', vi.fn()); + it("returns correct count of listeners", () => { + logEventBus.onLog("project-1", vi.fn()); + logEventBus.onLog("project-1", vi.fn()); + logEventBus.onLog("project-2", vi.fn()); - expect(logEventBus.getListenerCount('project-1')).toBe(2); - expect(logEventBus.getListenerCount('project-2')).toBe(1); + expect(logEventBus.getListenerCount("project-1")).toBe(2); + expect(logEventBus.getListenerCount("project-2")).toBe(1); }); - it('decrements count after unsubscribe', () => { - const unsubscribe = logEventBus.onLog('project-1', vi.fn()); - logEventBus.onLog('project-1', vi.fn()); + it("decrements count after unsubscribe", () => { + const unsubscribe = logEventBus.onLog("project-1", vi.fn()); + logEventBus.onLog("project-1", vi.fn()); - expect(logEventBus.getListenerCount('project-1')).toBe(2); + expect(logEventBus.getListenerCount("project-1")).toBe(2); unsubscribe(); - expect(logEventBus.getListenerCount('project-1')).toBe(1); + expect(logEventBus.getListenerCount("project-1")).toBe(1); }); }); - describe('clear', () => { - it('removes all listeners from all projects', () => { + describe("clear", () => { + it("removes all listeners from all projects", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - logEventBus.onLog('project-1', listener1); - logEventBus.onLog('project-2', listener2); + logEventBus.onLog("project-1", listener1); + logEventBus.onLog("project-2", listener2); logEventBus.clear(); logEventBus.emitLog(sampleLog); - logEventBus.emitLog({ ...sampleLog, projectId: 'project-2' }); + logEventBus.emitLog({ ...sampleLog, projectId: "project-2" }); expect(listener1).not.toHaveBeenCalled(); expect(listener2).not.toHaveBeenCalled(); }); }); - describe('incident events', () => { - it('triggers registered incident listeners', () => { + describe("incident events", () => { + it("triggers registered incident listeners", () => { const listener = vi.fn(); - logEventBus.onIncident('project-1', listener); + logEventBus.onIncident("project-1", listener); logEventBus.emitIncident(sampleIncident); @@ -249,11 +249,11 @@ describe('Log Event Bus', () => { expect(listener).toHaveBeenCalledWith(sampleIncident); }); - it('incident listeners are project-scoped', () => { + it("incident listeners are project-scoped", () => { const listener1 = vi.fn(); const listener2 = vi.fn(); - logEventBus.onIncident('project-1', listener1); - logEventBus.onIncident('project-2', listener2); + logEventBus.onIncident("project-1", listener1); + logEventBus.onIncident("project-2", listener2); logEventBus.emitIncident(sampleIncident); @@ -261,11 +261,11 @@ describe('Log Event Bus', () => { expect(listener2).not.toHaveBeenCalled(); }); - it('returns incident listener count', () => { - logEventBus.onIncident('project-1', vi.fn()); - logEventBus.onIncident('project-1', vi.fn()); + it("returns incident listener count", () => { + logEventBus.onIncident("project-1", vi.fn()); + logEventBus.onIncident("project-1", vi.fn()); - expect(logEventBus.getIncidentListenerCount('project-1')).toBe(2); + expect(logEventBus.getIncidentListenerCount("project-1")).toBe(2); }); }); }); diff --git a/src/lib/server/jobs/cleanup-scheduler.ts b/src/lib/server/jobs/cleanup-scheduler.ts index 49453e8..a0abc5d 100644 --- a/src/lib/server/jobs/cleanup-scheduler.ts +++ b/src/lib/server/jobs/cleanup-scheduler.ts @@ -1,5 +1,5 @@ -import { RETENTION_CONFIG } from '$lib/server/config'; -import { cleanupOldLogs } from './log-cleanup'; +import { RETENTION_CONFIG } from "$lib/server/config"; +import { cleanupOldLogs } from "./log-cleanup"; let cleanupStarted = false; let cleanupIntervalId: ReturnType | null = null; @@ -83,11 +83,11 @@ async function runCleanup(): Promise { } if (result.errors.length > 0) { - console.error('[cleanup-scheduler] Errors during cleanup:', result.errors); + console.error("[cleanup-scheduler] Errors during cleanup:", result.errors); } } catch (error) { console.error( - '[cleanup-scheduler] Fatal error during cleanup:', + "[cleanup-scheduler] Fatal error during cleanup:", error instanceof Error ? error.message : String(error), ); } diff --git a/src/lib/server/jobs/log-cleanup.ts b/src/lib/server/jobs/log-cleanup.ts index 36c5de0..f017954 100644 --- a/src/lib/server/jobs/log-cleanup.ts +++ b/src/lib/server/jobs/log-cleanup.ts @@ -1,8 +1,8 @@ -import { sql } from 'drizzle-orm'; -import { RETENTION_CONFIG } from '$lib/server/config'; -import type { DatabaseClient } from '$lib/server/db/db'; -import { getQueryRows } from '$lib/server/db/db'; -import { project } from '$lib/server/db/schema'; +import { sql } from "drizzle-orm"; +import { RETENTION_CONFIG } from "$lib/server/config"; +import type { DatabaseClient } from "$lib/server/db/db"; +import { getQueryRows } from "$lib/server/db/db"; +import { project } from "$lib/server/db/schema"; export interface CleanupResult { projectsProcessed: number; @@ -26,7 +26,7 @@ const BATCH_SIZE = 1000; * @returns Summary of cleanup operation */ export async function cleanupOldLogs(dbClient?: DatabaseClient): Promise { - const db = dbClient ?? (await import('$lib/server/db')).db; + const db = dbClient ?? (await import("$lib/server/db")).db; const result: CleanupResult = { projectsProcessed: 0, diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index 392ee6e..a32a64c 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -5,23 +5,23 @@ * integration test setup. */ -import { eq } from 'drizzle-orm'; -import { session as sessionTable, user as userTable } from '$lib/server/db/schema'; -import type { Session, User } from './auth'; -import type { DatabaseClient } from './db/db'; +import { eq } from "drizzle-orm"; +import { session as sessionTable, user as userTable } from "$lib/server/db/schema"; +import type { Session, User } from "./auth"; +import type { DatabaseClient } from "./db/db"; /** * Extracts session token from request headers */ function getSessionToken(headers: Headers): string | null { - const cookie = headers.get('cookie'); + const cookie = headers.get("cookie"); if (!cookie) return null; // Parse cookies and find better-auth session token - const cookies = cookie.split(';').map((c) => c.trim()); + const cookies = cookie.split(";").map((c) => c.trim()); for (const cookie of cookies) { - if (cookie.startsWith('better-auth.session_token=')) { - return cookie.substring('better-auth.session_token='.length); + if (cookie.startsWith("better-auth.session_token=")) { + return cookie.substring("better-auth.session_token=".length); } } @@ -38,7 +38,7 @@ export async function getSession( database?: DatabaseClient, ): Promise<{ user: User; session: Session } | null> { // Lazy-load production database if not provided - const db = database || (await import('$lib/server/db')).db; + const db = database || (await import("$lib/server/db")).db; const token = getSessionToken(headers); if (!token) return null; diff --git a/src/lib/server/utils/api-error.ts b/src/lib/server/utils/api-error.ts index c02eb0a..4cd75c3 100644 --- a/src/lib/server/utils/api-error.ts +++ b/src/lib/server/utils/api-error.ts @@ -2,6 +2,6 @@ export function apiError(status: number, error: string, message?: string): Response { return new Response(JSON.stringify({ error, ...(message ? { message } : {}) }), { status, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, }); } diff --git a/src/lib/server/utils/api-key.ts b/src/lib/server/utils/api-key.ts index 6b76868..6c8e4d9 100644 --- a/src/lib/server/utils/api-key.ts +++ b/src/lib/server/utils/api-key.ts @@ -1,8 +1,8 @@ -import { createHash } from 'node:crypto'; -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import type { DatabaseClient } from '$lib/server/db/db'; -import { project } from '../db/schema'; +import { createHash } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import type { DatabaseClient } from "$lib/server/db/db"; +import { project } from "../db/schema"; /** * Custom error class for API key validation errors @@ -14,7 +14,7 @@ export class ApiKeyError extends Error { constructor(status: number, message: string) { super(message); - this.name = 'ApiKeyError'; + this.name = "ApiKeyError"; this.status = status; this.body = { message }; } @@ -78,7 +78,7 @@ const API_KEY_REGEX = /^lw_[A-Za-z0-9_-]{32}$/; * Hash an API key using SHA-256 */ export function hashApiKey(key: string): string { - return createHash('sha256').update(key).digest('hex'); + return createHash("sha256").update(key).digest("hex"); } /** @@ -99,7 +99,7 @@ export function generateApiKey(): string { * @returns true if key matches format, false otherwise */ export function validateApiKeyFormat(key: string): boolean { - if (!key || typeof key !== 'string') { + if (!key || typeof key !== "string") { return false; } return API_KEY_REGEX.test(key); @@ -154,9 +154,9 @@ function setNegativeCache(keyHash: string): void { */ export async function validateApiKey(request: Request, dbClient?: DatabaseClient): Promise { // Extract Authorization header - const authHeader = request.headers.get('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - throw new ApiKeyError(401, 'Missing or invalid authorization header'); + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + throw new ApiKeyError(401, "Missing or invalid authorization header"); } // Extract API key from Bearer token @@ -164,7 +164,7 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient // Validate format first (fast fail) if (!validateApiKeyFormat(apiKey)) { - throw new ApiKeyError(401, 'Invalid API key format'); + throw new ApiKeyError(401, "Invalid API key format"); } const keyHash = hashApiKey(apiKey); @@ -173,7 +173,7 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient const negCached = NEGATIVE_CACHE.get(keyHash); if (negCached) { if (negCached.expiresAt > Date.now()) { - throw new ApiKeyError(401, 'Invalid API key'); + throw new ApiKeyError(401, "Invalid API key"); } NEGATIVE_CACHE.delete(keyHash); } @@ -185,7 +185,7 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient } // Lazy load default db only when needed (avoids issues in unit tests) - const db = dbClient ?? (await import('$lib/server/db')).db; + const db = dbClient ?? (await import("$lib/server/db")).db; // Query database by key hash const [result] = await db @@ -196,7 +196,7 @@ export async function validateApiKey(request: Request, dbClient?: DatabaseClient if (!result) { // Store in negative cache (bounded + prunes expired) setNegativeCache(keyHash); - throw new ApiKeyError(401, 'Invalid API key'); + throw new ApiKeyError(401, "Invalid API key"); } // Evict if at capacity before inserting diff --git a/src/lib/server/utils/api-key.unit.test.ts b/src/lib/server/utils/api-key.unit.test.ts index 7882edd..cd357a1 100644 --- a/src/lib/server/utils/api-key.unit.test.ts +++ b/src/lib/server/utils/api-key.unit.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from 'vitest'; -import { generateApiKey, validateApiKeyFormat } from './api-key'; +import { describe, expect, it } from "vite-plus/test"; +import { generateApiKey, validateApiKeyFormat } from "./api-key"; -describe('API Key Generation', () => { - it('generateApiKey returns lw_ prefixed 32-char string', () => { +describe("API Key Generation", () => { + it("generateApiKey returns lw_ prefixed 32-char string", () => { const apiKey = generateApiKey(); expect(apiKey).toBeDefined(); @@ -10,7 +10,7 @@ describe('API Key Generation', () => { expect(apiKey.length).toBe(35); // 'lw_' (3 chars) + 32 chars }); - it('generateApiKey returns unique keys on multiple calls', () => { + it("generateApiKey returns unique keys on multiple calls", () => { const key1 = generateApiKey(); const key2 = generateApiKey(); const key3 = generateApiKey(); @@ -21,14 +21,14 @@ describe('API Key Generation', () => { }); }); -describe('API Key Format Validation', () => { - it('validateApiKeyFormat accepts valid API key format', () => { +describe("API Key Format Validation", () => { + it("validateApiKeyFormat accepts valid API key format", () => { const validKeys = [ - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - 'lw_12345678901234567890123456789012', - 'lw_abcdefghijklmnopqrstuvwxyz123456', - 'lw_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456', - 'lw_aB1-_cD2eF3gH4iJ5kL6mN7oP8qR9sT0', + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + "lw_12345678901234567890123456789012", + "lw_abcdefghijklmnopqrstuvwxyz123456", + "lw_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "lw_aB1-_cD2eF3gH4iJ5kL6mN7oP8qR9sT0", ]; for (const key of validKeys) { @@ -36,12 +36,12 @@ describe('API Key Format Validation', () => { } }); - it('validateApiKeyFormat rejects key without lw_ prefix', () => { + it("validateApiKeyFormat rejects key without lw_ prefix", () => { const invalidKeys = [ - 'aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789', - 'api_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456', - 'l_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345678', - 'lwaBcDeFgHiJkLmNoPqRsTuVwXyZ12345678', + "aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789", + "api_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", + "l_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345678", + "lwaBcDeFgHiJkLmNoPqRsTuVwXyZ12345678", ]; for (const key of invalidKeys) { @@ -49,14 +49,14 @@ describe('API Key Format Validation', () => { } }); - it('validateApiKeyFormat rejects key with wrong length', () => { + it("validateApiKeyFormat rejects key with wrong length", () => { const invalidKeys = [ - 'lw_short', - 'lw_123', - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345', // 31 chars - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567', // 33 chars - 'lw_', - 'lw_a', + "lw_short", + "lw_123", + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ12345", // 31 chars + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567", // 33 chars + "lw_", + "lw_a", ]; for (const key of invalidKeys) { @@ -64,13 +64,13 @@ describe('API Key Format Validation', () => { } }); - it('validateApiKeyFormat rejects key with invalid characters', () => { + it("validateApiKeyFormat rejects key with invalid characters", () => { const invalidKeys = [ - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234$6', // $ not allowed - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234@6', // @ not allowed - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234!6', // ! not allowed - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234 6', // space not allowed - 'lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234.6', // . not allowed + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234$6", // $ not allowed + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234@6", // @ not allowed + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234!6", // ! not allowed + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234 6", // space not allowed + "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234.6", // . not allowed ]; for (const key of invalidKeys) { @@ -78,11 +78,11 @@ describe('API Key Format Validation', () => { } }); - it('validateApiKeyFormat rejects empty string', () => { - expect(validateApiKeyFormat('')).toBe(false); + it("validateApiKeyFormat rejects empty string", () => { + expect(validateApiKeyFormat("")).toBe(false); }); - it('validateApiKeyFormat rejects null and undefined', () => { + it("validateApiKeyFormat rejects null and undefined", () => { expect(validateApiKeyFormat(null as unknown as string)).toBe(false); expect(validateApiKeyFormat(undefined as unknown as string)).toBe(false); }); diff --git a/src/lib/server/utils/auth-guard.ts b/src/lib/server/utils/auth-guard.ts index cd2e902..2dce07a 100644 --- a/src/lib/server/utils/auth-guard.ts +++ b/src/lib/server/utils/auth-guard.ts @@ -1,6 +1,6 @@ -import type { RequestEvent } from '@sveltejs/kit'; -import { error, redirect } from '@sveltejs/kit'; -import type { Session, User } from '../auth'; +import type { RequestEvent } from "@sveltejs/kit"; +import { error, redirect } from "@sveltejs/kit"; +import type { Session, User } from "../auth"; /** * Result of successful authentication check @@ -15,7 +15,7 @@ export interface AuthenticatedSession { * Check if a route is an API route based on its route ID */ function isApiRoute(routeId: string | null): boolean { - return routeId?.startsWith('/api/') ?? false; + return routeId?.startsWith("/api/") ?? false; } /** @@ -45,9 +45,9 @@ export async function requireAuth(event: RequestEvent): Promise { - test('returns empty string for null or undefined', () => { - expect(escapeCSVField(null)).toBe(''); - expect(escapeCSVField(undefined)).toBe(''); +import { describe, expect, test } from "vite-plus/test"; +import type { ExportableLog } from "$lib/types/export"; +import { escapeCSVField, serializeToCsv } from "./csv-serializer"; + +describe("escapeCSVField", () => { + test("returns empty string for null or undefined", () => { + expect(escapeCSVField(null)).toBe(""); + expect(escapeCSVField(undefined)).toBe(""); }); - test('converts numbers to strings', () => { - expect(escapeCSVField(42)).toBe('42'); - expect(escapeCSVField(3.14)).toBe('3.14'); + test("converts numbers to strings", () => { + expect(escapeCSVField(42)).toBe("42"); + expect(escapeCSVField(3.14)).toBe("3.14"); }); - test('handles plain text without special characters', () => { - expect(escapeCSVField('simple text')).toBe('simple text'); + test("handles plain text without special characters", () => { + expect(escapeCSVField("simple text")).toBe("simple text"); }); - test('wraps fields with commas in quotes', () => { - expect(escapeCSVField('hello, world')).toBe('"hello, world"'); + test("wraps fields with commas in quotes", () => { + expect(escapeCSVField("hello, world")).toBe('"hello, world"'); }); - test('escapes double quotes by doubling them', () => { + test("escapes double quotes by doubling them", () => { expect(escapeCSVField('say "hello"')).toBe('"say ""hello"""'); }); - test('wraps fields with newlines in quotes', () => { - expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"'); + test("wraps fields with newlines in quotes", () => { + expect(escapeCSVField("line1\nline2")).toBe('"line1\nline2"'); }); - test('handles combination of comma and quotes', () => { + test("handles combination of comma and quotes", () => { expect(escapeCSVField('error: "value", unexpected')).toBe('"error: ""value"", unexpected"'); }); - test('handles empty strings', () => { - expect(escapeCSVField('')).toBe(''); + test("handles empty strings", () => { + expect(escapeCSVField("")).toBe(""); }); - test('prefixes formula-starting characters with single quote (OWASP CSV injection)', () => { - expect(escapeCSVField('=cmd|/C calc')).toBe("'=cmd|/C calc"); - expect(escapeCSVField('+cmd|/C calc')).toBe("'+cmd|/C calc"); - expect(escapeCSVField('-cmd|/C calc')).toBe("'-cmd|/C calc"); - expect(escapeCSVField('@SUM(A1:A10)')).toBe("'@SUM(A1:A10)"); + test("prefixes formula-starting characters with single quote (OWASP CSV injection)", () => { + expect(escapeCSVField("=cmd|/C calc")).toBe("'=cmd|/C calc"); + expect(escapeCSVField("+cmd|/C calc")).toBe("'+cmd|/C calc"); + expect(escapeCSVField("-cmd|/C calc")).toBe("'-cmd|/C calc"); + expect(escapeCSVField("@SUM(A1:A10)")).toBe("'@SUM(A1:A10)"); }); - test('does not prefix safe values', () => { - expect(escapeCSVField('normal text')).toBe('normal text'); - expect(escapeCSVField('=acceptable when quoted')).toBe("'" + '=acceptable when quoted'); - expect(escapeCSVField('42')).toBe('42'); - expect(escapeCSVField('test-value')).toBe('test-value'); - expect(escapeCSVField('+1234567890')).toBe("'+1234567890"); + test("does not prefix safe values", () => { + expect(escapeCSVField("normal text")).toBe("normal text"); + expect(escapeCSVField("=acceptable when quoted")).toBe("'" + "=acceptable when quoted"); + expect(escapeCSVField("42")).toBe("42"); + expect(escapeCSVField("test-value")).toBe("test-value"); + expect(escapeCSVField("+1234567890")).toBe("'+1234567890"); }); - test('handles formula chars with commas and quotes correctly', () => { - expect(escapeCSVField('=formula, with comma')).toBe('"\'=formula, with comma"'); + test("handles formula chars with commas and quotes correctly", () => { + expect(escapeCSVField("=formula, with comma")).toBe('"\'=formula, with comma"'); expect(escapeCSVField('+formula "with" quotes')).toBe('"\'+formula ""with"" quotes"'); }); }); -describe('serializeToCsv', () => { - test('returns only headers for empty array', () => { +describe("serializeToCsv", () => { + test("returns only headers for empty array", () => { const result = serializeToCsv([]); expect(result).toBe( - 'id,timestamp,level,message,metadata,sourceFile,lineNumber,requestId,userId,ipAddress\n', + "id,timestamp,level,message,metadata,sourceFile,lineNumber,requestId,userId,ipAddress\n", ); }); - test('serializes single log entry', () => { + test("serializes single log entry", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'Test message', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "Test message", metadata: null, sourceFile: null, lineNumber: null, @@ -83,21 +83,21 @@ describe('serializeToCsv', () => { ]; const result = serializeToCsv(logs); - const lines = result.split('\n'); + const lines = result.split("\n"); expect(lines[0]).toBe( - 'id,timestamp,level,message,metadata,sourceFile,lineNumber,requestId,userId,ipAddress', + "id,timestamp,level,message,metadata,sourceFile,lineNumber,requestId,userId,ipAddress", ); - expect(lines[1]).toBe('log-1,2026-01-03T10:00:00Z,info,Test message,,,,,,'); + expect(lines[1]).toBe("log-1,2026-01-03T10:00:00Z,info,Test message,,,,,,"); }); - test('serializes multiple log entries', () => { + test("serializes multiple log entries", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'First message', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "First message", metadata: null, sourceFile: null, lineNumber: null, @@ -106,10 +106,10 @@ describe('serializeToCsv', () => { ipAddress: null, }, { - id: 'log-2', - timestamp: '2026-01-03T10:01:00Z', - level: 'error', - message: 'Second message', + id: "log-2", + timestamp: "2026-01-03T10:01:00Z", + level: "error", + message: "Second message", metadata: null, sourceFile: null, lineNumber: null, @@ -120,20 +120,20 @@ describe('serializeToCsv', () => { ]; const result = serializeToCsv(logs); - const lines = result.split('\n'); + const lines = result.split("\n"); expect(lines).toHaveLength(4); // header + 2 logs + trailing newline - expect(lines[1]).toContain('log-1'); - expect(lines[2]).toContain('log-2'); + expect(lines[1]).toContain("log-1"); + expect(lines[2]).toContain("log-2"); }); - test('handles metadata as JSON string', () => { + test("handles metadata as JSON string", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'Test', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "Test", metadata: '{"key":"value","count":42}', sourceFile: null, lineNumber: null, @@ -147,13 +147,13 @@ describe('serializeToCsv', () => { expect(result).toContain('"{""key"":""value"",""count"":42}"'); }); - test('handles messages with commas', () => { + test("handles messages with commas", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'Error occurred, check logs, fix immediately', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "Error occurred, check logs, fix immediately", metadata: null, sourceFile: null, lineNumber: null, @@ -167,12 +167,12 @@ describe('serializeToCsv', () => { expect(result).toContain('"Error occurred, check logs, fix immediately"'); }); - test('handles messages with quotes', () => { + test("handles messages with quotes", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'error', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "error", message: 'Unexpected "token" found', metadata: null, sourceFile: null, @@ -187,13 +187,13 @@ describe('serializeToCsv', () => { expect(result).toContain('"Unexpected ""token"" found"'); }); - test('handles newlines in messages', () => { + test("handles newlines in messages", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'error', - message: 'Stack trace:\nline 1\nline 2', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "error", + message: "Stack trace:\nline 1\nline 2", metadata: null, sourceFile: null, lineNumber: null, @@ -207,37 +207,37 @@ describe('serializeToCsv', () => { expect(result).toContain('"Stack trace:\nline 1\nline 2"'); }); - test('handles all fields populated', () => { + test("handles all fields populated", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'warn', - message: 'Warning message', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "warn", + message: "Warning message", metadata: '{"context":"test"}', - sourceFile: '/app/server.ts', + sourceFile: "/app/server.ts", lineNumber: 42, - requestId: 'req-123', - userId: 'user-456', - ipAddress: '192.168.1.1', + requestId: "req-123", + userId: "user-456", + ipAddress: "192.168.1.1", }, ]; const result = serializeToCsv(logs); - const lines = result.split('\n'); + const lines = result.split("\n"); expect(lines[1]).toBe( 'log-1,2026-01-03T10:00:00Z,warn,Warning message,"{""context"":""test""}",/app/server.ts,42,req-123,user-456,192.168.1.1', ); }); - test('handles null and undefined fields gracefully', () => { + test("handles null and undefined fields gracefully", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'Test', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "Test", metadata: null, sourceFile: null, lineNumber: null, @@ -248,18 +248,18 @@ describe('serializeToCsv', () => { ]; const result = serializeToCsv(logs); - expect(result).toContain('log-1,2026-01-03T10:00:00Z,info,Test,,,,,,'); + expect(result).toContain("log-1,2026-01-03T10:00:00Z,info,Test,,,,,,"); }); - test('handles lineNumber as number field', () => { + test("handles lineNumber as number field", () => { const logs: ExportableLog[] = [ { - id: 'log-1', - timestamp: '2026-01-03T10:00:00Z', - level: 'info', - message: 'Test', + id: "log-1", + timestamp: "2026-01-03T10:00:00Z", + level: "info", + message: "Test", metadata: null, - sourceFile: 'test.ts', + sourceFile: "test.ts", lineNumber: 123, requestId: null, userId: null, @@ -268,6 +268,6 @@ describe('serializeToCsv', () => { ]; const result = serializeToCsv(logs); - expect(result).toContain(',test.ts,123,'); + expect(result).toContain(",test.ts,123,"); }); }); diff --git a/src/lib/server/utils/cursor.ts b/src/lib/server/utils/cursor.ts index cf15ef9..0f5a51d 100644 --- a/src/lib/server/utils/cursor.ts +++ b/src/lib/server/utils/cursor.ts @@ -16,9 +16,9 @@ */ export function encodeCursor(timestamp: Date | null | undefined, id: string): string { if (!timestamp) { - throw new Error('Cannot encode cursor for log without timestamp'); + throw new Error("Cannot encode cursor for log without timestamp"); } - return Buffer.from(`${timestamp.toISOString()}_${id}`).toString('base64url'); + return Buffer.from(`${timestamp.toISOString()}_${id}`).toString("base64url"); } /** @@ -30,14 +30,14 @@ export function encodeCursor(timestamp: Date | null | undefined, id: string): st */ export function decodeCursor(cursor: string): { timestamp: Date; id: string } { try { - const decoded = Buffer.from(cursor, 'base64url').toString('utf-8'); + const decoded = Buffer.from(cursor, "base64url").toString("utf-8"); // ISO 8601 timestamps always end with 'Z', so find the 'Z_' pattern // This handles IDs that may contain underscores - const separatorIndex = decoded.indexOf('Z_'); + const separatorIndex = decoded.indexOf("Z_"); if (separatorIndex === -1) { - throw new Error('Invalid cursor format: missing separator'); + throw new Error("Invalid cursor format: missing separator"); } // Include the 'Z' in the timestamp @@ -45,19 +45,19 @@ export function decodeCursor(cursor: string): { timestamp: Date; id: string } { const id = decoded.substring(separatorIndex + 2); // Skip 'Z_' if (!timestampStr || !id) { - throw new Error('Invalid cursor format: empty timestamp or id'); + throw new Error("Invalid cursor format: empty timestamp or id"); } const timestamp = new Date(timestampStr); if (Number.isNaN(timestamp.getTime())) { - throw new Error('Invalid cursor format: invalid timestamp'); + throw new Error("Invalid cursor format: invalid timestamp"); } return { timestamp, id }; } catch (error) { - if (error instanceof Error && error.message.startsWith('Invalid cursor')) { + if (error instanceof Error && error.message.startsWith("Invalid cursor")) { throw error; } - throw new Error('Invalid cursor'); + throw new Error("Invalid cursor"); } } diff --git a/src/lib/server/utils/cursor.unit.test.ts b/src/lib/server/utils/cursor.unit.test.ts index cfc170c..24a3bb2 100644 --- a/src/lib/server/utils/cursor.unit.test.ts +++ b/src/lib/server/utils/cursor.unit.test.ts @@ -1,36 +1,36 @@ -import { describe, expect, it } from 'vitest'; -import { decodeCursor, encodeCursor } from './cursor'; +import { describe, expect, it } from "vite-plus/test"; +import { decodeCursor, encodeCursor } from "./cursor"; -describe('cursor utilities', () => { - describe('encodeCursor', () => { - it('creates valid base64url string', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); - const id = 'log_123'; +describe("cursor utilities", () => { + describe("encodeCursor", () => { + it("creates valid base64url string", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); + const id = "log_123"; const cursor = encodeCursor(timestamp, id); // Should be a non-empty string expect(cursor).toBeTruthy(); - expect(typeof cursor).toBe('string'); + expect(typeof cursor).toBe("string"); // Should be valid base64url (no +, /, or = characters) expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); }); - it('handles IDs with underscores', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); - const id = 'log_with_underscores_123'; + it("handles IDs with underscores", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); + const id = "log_with_underscores_123"; const cursor = encodeCursor(timestamp, id); expect(cursor).toBeTruthy(); - expect(typeof cursor).toBe('string'); + expect(typeof cursor).toBe("string"); }); - it('creates different cursors for different timestamps', () => { - const id = 'same_id'; - const timestamp1 = new Date('2024-01-15T10:30:00.000Z'); - const timestamp2 = new Date('2024-01-15T10:31:00.000Z'); + it("creates different cursors for different timestamps", () => { + const id = "same_id"; + const timestamp1 = new Date("2024-01-15T10:30:00.000Z"); + const timestamp2 = new Date("2024-01-15T10:31:00.000Z"); const cursor1 = encodeCursor(timestamp1, id); const cursor2 = encodeCursor(timestamp2, id); @@ -38,10 +38,10 @@ describe('cursor utilities', () => { expect(cursor1).not.toBe(cursor2); }); - it('creates different cursors for different IDs', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); - const id1 = 'log_123'; - const id2 = 'log_456'; + it("creates different cursors for different IDs", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); + const id1 = "log_123"; + const id2 = "log_456"; const cursor1 = encodeCursor(timestamp, id1); const cursor2 = encodeCursor(timestamp, id2); @@ -50,10 +50,10 @@ describe('cursor utilities', () => { }); }); - describe('decodeCursor', () => { - it('parses cursor correctly', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); - const id = 'log_123'; + describe("decodeCursor", () => { + it("parses cursor correctly", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); + const id = "log_123"; const cursor = encodeCursor(timestamp, id); const result = decodeCursor(cursor); @@ -62,9 +62,9 @@ describe('cursor utilities', () => { expect(result.id).toBe(id); }); - it('handles IDs with underscores', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); - const id = 'log_with_underscores_123'; + it("handles IDs with underscores", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); + const id = "log_with_underscores_123"; const cursor = encodeCursor(timestamp, id); const result = decodeCursor(cursor); @@ -73,36 +73,36 @@ describe('cursor utilities', () => { expect(result.id).toBe(id); }); - it('throws on invalid base64url', () => { - expect(() => decodeCursor('not-valid-base64!@#$%')).toThrow('Invalid cursor'); + it("throws on invalid base64url", () => { + expect(() => decodeCursor("not-valid-base64!@#$%")).toThrow("Invalid cursor"); }); - it('throws on missing separator', () => { + it("throws on missing separator", () => { // Create a base64 string without underscore separator - const invalid = Buffer.from('2024-01-15T10:30:00.000Zlog123').toString('base64url'); - expect(() => decodeCursor(invalid)).toThrow('Invalid cursor format'); + const invalid = Buffer.from("2024-01-15T10:30:00.000Zlog123").toString("base64url"); + expect(() => decodeCursor(invalid)).toThrow("Invalid cursor format"); }); - it('throws on empty timestamp', () => { - const invalid = Buffer.from('_log_123').toString('base64url'); - expect(() => decodeCursor(invalid)).toThrow('Invalid cursor format'); + it("throws on empty timestamp", () => { + const invalid = Buffer.from("_log_123").toString("base64url"); + expect(() => decodeCursor(invalid)).toThrow("Invalid cursor format"); }); - it('throws on empty id', () => { - const invalid = Buffer.from('2024-01-15T10:30:00.000Z_').toString('base64url'); - expect(() => decodeCursor(invalid)).toThrow('Invalid cursor format'); + it("throws on empty id", () => { + const invalid = Buffer.from("2024-01-15T10:30:00.000Z_").toString("base64url"); + expect(() => decodeCursor(invalid)).toThrow("Invalid cursor format"); }); - it('throws on invalid timestamp', () => { - const invalid = Buffer.from('not-a-date_log_123').toString('base64url'); - expect(() => decodeCursor(invalid)).toThrow('Invalid cursor format'); + it("throws on invalid timestamp", () => { + const invalid = Buffer.from("not-a-date_log_123").toString("base64url"); + expect(() => decodeCursor(invalid)).toThrow("Invalid cursor format"); }); }); - describe('roundtrip encode/decode', () => { - it('successfully roundtrips timestamp and id', () => { - const timestamp = new Date('2024-01-15T10:30:00.123Z'); - const id = 'log_abc_123'; + describe("roundtrip encode/decode", () => { + it("successfully roundtrips timestamp and id", () => { + const timestamp = new Date("2024-01-15T10:30:00.123Z"); + const id = "log_abc_123"; const cursor = encodeCursor(timestamp, id); const result = decodeCursor(cursor); @@ -111,9 +111,9 @@ describe('cursor utilities', () => { expect(result.id).toBe(id); }); - it('handles timestamps with milliseconds', () => { - const timestamp = new Date('2024-01-15T10:30:00.999Z'); - const id = 'log_999'; + it("handles timestamps with milliseconds", () => { + const timestamp = new Date("2024-01-15T10:30:00.999Z"); + const id = "log_999"; const cursor = encodeCursor(timestamp, id); const result = decodeCursor(cursor); @@ -122,14 +122,14 @@ describe('cursor utilities', () => { expect(result.id).toBe(id); }); - it('handles various ID formats', () => { - const timestamp = new Date('2024-01-15T10:30:00.000Z'); + it("handles various ID formats", () => { + const timestamp = new Date("2024-01-15T10:30:00.000Z"); const testIds = [ - 'simple', - 'with_underscores', - 'with-dashes', - 'MixedCase123', - 'log_2024_01_15_abc', + "simple", + "with_underscores", + "with-dashes", + "MixedCase123", + "log_2024_01_15_abc", ]; for (const id of testIds) { diff --git a/src/lib/server/utils/incident-backfill.ts b/src/lib/server/utils/incident-backfill.ts index 5c8c8db..fc51185 100644 --- a/src/lib/server/utils/incident-backfill.ts +++ b/src/lib/server/utils/incident-backfill.ts @@ -1,15 +1,15 @@ -import { and, eq, gte, inArray, sql } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import type { DatabaseClient } from '$lib/server/db/db'; -import { type Incident, incident, type LogLevel, log } from '$lib/server/db/schema'; +import { and, eq, gte, inArray, sql } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import type { DatabaseClient } from "$lib/server/db/db"; +import { type Incident, incident, type LogLevel, log } from "$lib/server/db/schema"; import { assignIncidentIds, buildIncidentTitle, groupPreparedLogsByFingerprint, prepareLogsForIncidents, -} from './incidents'; +} from "./incidents"; -const GROUPED_LEVELS: LogLevel[] = ['error', 'fatal']; +const GROUPED_LEVELS: LogLevel[] = ["error", "fatal"]; export interface BackfillProjectResult { processedLogs: number; diff --git a/src/lib/server/utils/incident-fingerprint.ts b/src/lib/server/utils/incident-fingerprint.ts index c22c0a3..2fa7f1c 100644 --- a/src/lib/server/utils/incident-fingerprint.ts +++ b/src/lib/server/utils/incident-fingerprint.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { createHash } from "node:crypto"; /** * UUID matcher. @@ -47,14 +47,14 @@ export function normalizeIncidentMessage(message: string): string { const normalized = message .toLowerCase() .trim() - .replace(UUID_REGEX, '{uuid}') - .replace(HEX_ID_REGEX, '{hex}') - .replace(IPV4_REGEX, '{ip}') - .replace(NUMBER_REGEX, '{num}') - .replace(WHITESPACE_REGEX, ' ') + .replace(UUID_REGEX, "{uuid}") + .replace(HEX_ID_REGEX, "{hex}") + .replace(IPV4_REGEX, "{ip}") + .replace(NUMBER_REGEX, "{num}") + .replace(WHITESPACE_REGEX, " ") .trim(); - return normalized || 'unknown error'; + return normalized || "unknown error"; } /** @@ -66,8 +66,8 @@ export function buildIncidentFingerprintSeed(params: { lineNumber: number | null; normalizedMessage: string; }): string { - const serviceName = params.serviceName ?? 'unknown-service'; - const sourceFile = params.sourceFile ?? 'unknown-source'; + const serviceName = params.serviceName ?? "unknown-service"; + const sourceFile = params.sourceFile ?? "unknown-source"; const lineNumber = params.lineNumber ?? 0; return `${serviceName}|${sourceFile}|${lineNumber}|${params.normalizedMessage}`; @@ -77,7 +77,7 @@ export function buildIncidentFingerprintSeed(params: { * Returns a stable SHA-256 based fingerprint (truncated hex). */ export function hashIncidentFingerprint(seed: string): string { - return createHash('sha256').update(seed).digest('hex').slice(0, INCIDENT_FINGERPRINT_LENGTH); + return createHash("sha256").update(seed).digest("hex").slice(0, INCIDENT_FINGERPRINT_LENGTH); } /** diff --git a/src/lib/server/utils/incident-fingerprint.unit.test.ts b/src/lib/server/utils/incident-fingerprint.unit.test.ts index 8ebac9d..4503a9e 100644 --- a/src/lib/server/utils/incident-fingerprint.unit.test.ts +++ b/src/lib/server/utils/incident-fingerprint.unit.test.ts @@ -1,27 +1,27 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; import { buildIncidentFingerprint, buildIncidentFingerprintSeed, hashIncidentFingerprint, INCIDENT_FINGERPRINT_LENGTH, normalizeIncidentMessage, -} from './incident-fingerprint'; +} from "./incident-fingerprint"; -describe('incident-fingerprint', () => { - it('normalizes message in deterministic order', () => { +describe("incident-fingerprint", () => { + it("normalizes message in deterministic order", () => { const message = - ' ERROR User 123 from 192.168.10.20 hit tx 0xdeadbeefcafebabe and request 550e8400-e29b-41d4-a716-446655440000 '; + " ERROR User 123 from 192.168.10.20 hit tx 0xdeadbeefcafebabe and request 550e8400-e29b-41d4-a716-446655440000 "; const normalized = normalizeIncidentMessage(message); - expect(normalized).toBe('error user {num} from {ip} hit tx {hex} and request {uuid}'); + expect(normalized).toBe("error user {num} from {ip} hit tx {hex} and request {uuid}"); }); - it('hashes seed into truncated sha256 hex', () => { + it("hashes seed into truncated sha256 hex", () => { const seed = buildIncidentFingerprintSeed({ - serviceName: 'api', - sourceFile: 'auth.ts', + serviceName: "api", + sourceFile: "auth.ts", lineNumber: 42, - normalizedMessage: 'database timeout after {num}ms', + normalizedMessage: "database timeout after {num}ms", }); const fingerprint = hashIncidentFingerprint(seed); @@ -29,18 +29,18 @@ describe('incident-fingerprint', () => { expect(fingerprint).toMatch(/^[0-9a-f]+$/); }); - it('returns same fingerprint for same normalized template', () => { + it("returns same fingerprint for same normalized template", () => { const first = buildIncidentFingerprint({ - message: 'Database timeout after 1000ms for user 123', - serviceName: 'api', - sourceFile: 'db.ts', + message: "Database timeout after 1000ms for user 123", + serviceName: "api", + sourceFile: "db.ts", lineNumber: 88, }); const second = buildIncidentFingerprint({ - message: 'Database timeout after 2500ms for user 999', - serviceName: 'api', - sourceFile: 'db.ts', + message: "Database timeout after 2500ms for user 999", + serviceName: "api", + sourceFile: "db.ts", lineNumber: 88, }); diff --git a/src/lib/server/utils/incident-status.unit.test.ts b/src/lib/server/utils/incident-status.unit.test.ts index 68bcd5b..a2851fd 100644 --- a/src/lib/server/utils/incident-status.unit.test.ts +++ b/src/lib/server/utils/incident-status.unit.test.ts @@ -1,25 +1,25 @@ -import { describe, expect, it } from 'vitest'; -import { getIncidentStatus } from './incidents'; +import { describe, expect, it } from "vite-plus/test"; +import { getIncidentStatus } from "./incidents"; -describe('incident status helpers', () => { - it('returns open when within threshold', () => { - const now = new Date('2026-02-12T12:00:00.000Z'); - const lastSeen = new Date('2026-02-12T11:31:00.000Z'); +describe("incident status helpers", () => { + it("returns open when within threshold", () => { + const now = new Date("2026-02-12T12:00:00.000Z"); + const lastSeen = new Date("2026-02-12T11:31:00.000Z"); - expect(getIncidentStatus(lastSeen, now, 30)).toBe('open'); + expect(getIncidentStatus(lastSeen, now, 30)).toBe("open"); }); - it('returns open exactly at threshold boundary', () => { - const now = new Date('2026-02-12T12:00:00.000Z'); - const lastSeen = new Date('2026-02-12T11:30:00.000Z'); + it("returns open exactly at threshold boundary", () => { + const now = new Date("2026-02-12T12:00:00.000Z"); + const lastSeen = new Date("2026-02-12T11:30:00.000Z"); - expect(getIncidentStatus(lastSeen, now, 30)).toBe('open'); + expect(getIncidentStatus(lastSeen, now, 30)).toBe("open"); }); - it('returns resolved after threshold passes', () => { - const now = new Date('2026-02-12T12:00:01.000Z'); - const lastSeen = new Date('2026-02-12T11:30:00.000Z'); + it("returns resolved after threshold passes", () => { + const now = new Date("2026-02-12T12:00:01.000Z"); + const lastSeen = new Date("2026-02-12T11:30:00.000Z"); - expect(getIncidentStatus(lastSeen, now, 30)).toBe('resolved'); + expect(getIncidentStatus(lastSeen, now, 30)).toBe("resolved"); }); }); diff --git a/src/lib/server/utils/incidents.ts b/src/lib/server/utils/incidents.ts index 84c81fd..f2edf90 100644 --- a/src/lib/server/utils/incidents.ts +++ b/src/lib/server/utils/incidents.ts @@ -1,10 +1,10 @@ -import { sql } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import type { DatabaseClient } from '$lib/server/db/db'; -import { type IncidentStatus, isIncidentGroupedLevel, maxIncidentLevel } from '../../shared/types'; -import { INCIDENT_CONFIG } from '../config'; -import { type Incident, incident, type LogLevel } from '../db/schema'; -import { buildIncidentFingerprint } from './incident-fingerprint'; +import { sql } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import type { DatabaseClient } from "$lib/server/db/db"; +import { type IncidentStatus, isIncidentGroupedLevel, maxIncidentLevel } from "../../shared/types"; +import { INCIDENT_CONFIG } from "../config"; +import { type Incident, incident, type LogLevel } from "../db/schema"; +import { buildIncidentFingerprint } from "./incident-fingerprint"; /** * Log input shape needed for incident grouping. @@ -52,7 +52,7 @@ export interface IncidentUpsertResult { } function asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; @@ -62,7 +62,7 @@ function stringField(record: Record | null, keys: string[]): st if (!record) return null; for (const key of keys) { const value = record[key]; - if (typeof value === 'string' && value.trim()) { + if (typeof value === "string" && value.trim()) { return value.trim(); } } @@ -77,8 +77,8 @@ export function extractServiceName(resourceAttributes: unknown, metadata: unknow const meta = asRecord(metadata); return ( - stringField(resource, ['service.name', 'service_name', 'service']) ?? - stringField(meta, ['service.name', 'service_name', 'service']) ?? + stringField(resource, ["service.name", "service_name", "service"]) ?? + stringField(meta, ["service.name", "service_name", "service"]) ?? null ); } @@ -88,7 +88,7 @@ export function extractServiceName(resourceAttributes: unknown, metadata: unknow */ export function buildIncidentTitle(message: string): string { const trimmed = message.trim(); - if (!trimmed) return 'Unknown error'; + if (!trimmed) return "Unknown error"; return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed; } @@ -175,7 +175,7 @@ export function getIncidentStatus( autoResolveMinutes: number = INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES, ): IncidentStatus { const thresholdMs = autoResolveMinutes * 60 * 1000; - return now.getTime() - lastSeen.getTime() <= thresholdMs ? 'open' : 'resolved'; + return now.getTime() - lastSeen.getTime() <= thresholdMs ? "open" : "resolved"; } /** diff --git a/src/lib/server/utils/incidents.unit.test.ts b/src/lib/server/utils/incidents.unit.test.ts index baa0492..1d38b26 100644 --- a/src/lib/server/utils/incidents.unit.test.ts +++ b/src/lib/server/utils/incidents.unit.test.ts @@ -1,14 +1,14 @@ -import { eq } from 'drizzle-orm'; -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import { nanoid } from 'nanoid'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type * as schema from '../db/schema'; -import { incident, project, user } from '../db/schema'; -import { setupTestDatabase } from '../db/test-db'; -import { hashApiKey } from './api-key'; -import { type PreparedIncidentLog, upsertIncidentsForPreparedLogs } from './incidents'; +import { eq } from "drizzle-orm"; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import { nanoid } from "nanoid"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import type * as schema from "../db/schema"; +import { incident, project, user } from "../db/schema"; +import { setupTestDatabase } from "../db/test-db"; +import { hashApiKey } from "./api-key"; +import { type PreparedIncidentLog, upsertIncidentsForPreparedLogs } from "./incidents"; -describe('upsertIncidentsForPreparedLogs', () => { +describe("upsertIncidentsForPreparedLogs", () => { let db: PgliteDatabase; let cleanup: () => Promise; let projectId: string; @@ -23,7 +23,7 @@ describe('upsertIncidentsForPreparedLogs', () => { await db.insert(user).values({ id: ownerId, - name: 'Test User', + name: "Test User", email: `${ownerId}@example.com`, emailVerified: false, }); @@ -40,27 +40,27 @@ describe('upsertIncidentsForPreparedLogs', () => { await cleanup(); }); - it('updates firstSeen when a later batch contains older logs', async () => { - const fingerprint = 'fp-out-of-order'; + it("updates firstSeen when a later batch contains older logs", async () => { + const fingerprint = "fp-out-of-order"; const recentLog: PreparedIncidentLog = { - level: 'error', - message: 'Database timeout', - timestamp: new Date('2026-03-02T12:00:00.000Z'), - sourceFile: 'src/db.ts', + level: "error", + message: "Database timeout", + timestamp: new Date("2026-03-02T12:00:00.000Z"), + sourceFile: "src/db.ts", lineNumber: 42, resourceAttributes: null, metadata: null, - serviceName: 'api', + serviceName: "api", fingerprint, - normalizedMessage: 'database timeout', - incidentTitle: 'Database timeout', + normalizedMessage: "database timeout", + incidentTitle: "Database timeout", incidentId: null, }; const olderLog: PreparedIncidentLog = { ...recentLog, - timestamp: new Date('2026-03-01T12:00:00.000Z'), + timestamp: new Date("2026-03-01T12:00:00.000Z"), }; await upsertIncidentsForPreparedLogs(db, projectId, [recentLog]); @@ -72,8 +72,8 @@ describe('upsertIncidentsForPreparedLogs', () => { .where(eq(incident.projectId, projectId)); expect(updatedIncident).toBeDefined(); - expect(updatedIncident!.firstSeen.toISOString()).toBe('2026-03-01T12:00:00.000Z'); - expect(updatedIncident!.lastSeen.toISOString()).toBe('2026-03-02T12:00:00.000Z'); + expect(updatedIncident!.firstSeen.toISOString()).toBe("2026-03-01T12:00:00.000Z"); + expect(updatedIncident!.lastSeen.toISOString()).toBe("2026-03-02T12:00:00.000Z"); expect(updatedIncident!.totalEvents).toBe(2); }); }); diff --git a/src/lib/server/utils/otlp.ts b/src/lib/server/utils/otlp.ts index 8b8de3b..b04bcfb 100644 --- a/src/lib/server/utils/otlp.ts +++ b/src/lib/server/utils/otlp.ts @@ -1,9 +1,9 @@ -import type { LogLevel } from '$lib/shared/types'; +import type { LogLevel } from "$lib/shared/types"; export class OtlpValidationError extends Error { constructor(message: string) { super(message); - this.name = 'OtlpValidationError'; + this.name = "OtlpValidationError"; } } @@ -81,18 +81,18 @@ const TRACE_ID_REGEX = /^[0-9a-f]{32}$/i; const SPAN_ID_REGEX = /^[0-9a-f]{16}$/i; function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === "object" && value !== null; } export function parseUint64String(value: unknown): string | null { - if (typeof value === 'string') { + if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; if (!/^\d+$/.test(trimmed)) return null; return trimmed; } if ( - typeof value === 'number' && + typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 @@ -103,10 +103,10 @@ export function parseUint64String(value: unknown): string | null { } function parseOptionalNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { + if (typeof value === "number" && Number.isFinite(value)) { return value; } - if (typeof value === 'string' && value.trim()) { + if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } @@ -114,10 +114,10 @@ function parseOptionalNumber(value: unknown): number | null { } function parseIntValue(value: unknown): number | string | null { - if (typeof value === 'number' && Number.isFinite(value)) { + if (typeof value === "number" && Number.isFinite(value)) { return Number.isSafeInteger(value) ? value : Math.trunc(value); } - if (typeof value === 'string') { + if (typeof value === "string") { const trimmed = value.trim(); if (!/^-?\d+$/.test(trimmed)) return null; const parsed = Number(trimmed); @@ -158,31 +158,31 @@ function parseSeverityNumber(value: unknown): number | null { function severityTextToLogLevel(value: string | null): LogLevel | null { if (!value) return null; const normalized = value.toLowerCase(); - if (normalized.includes('fatal') || normalized.includes('critical')) return 'fatal'; - if (normalized.includes('error')) return 'error'; - if (normalized.includes('warn')) return 'warn'; - if (normalized.includes('info')) return 'info'; - if (normalized.includes('debug') || normalized.includes('trace')) return 'debug'; + if (normalized.includes("fatal") || normalized.includes("critical")) return "fatal"; + if (normalized.includes("error")) return "error"; + if (normalized.includes("warn")) return "warn"; + if (normalized.includes("info")) return "info"; + if (normalized.includes("debug") || normalized.includes("trace")) return "debug"; return null; } export function severityNumberToLogLevel(value: number | null | undefined): LogLevel { if (!value || value <= 0) { - return 'info'; + return "info"; } if (value <= 8) { - return 'debug'; + return "debug"; } if (value <= 12) { - return 'info'; + return "info"; } if (value <= 16) { - return 'warn'; + return "warn"; } if (value <= 20) { - return 'error'; + return "error"; } - return 'fatal'; + return "fatal"; } function attributeString( @@ -192,7 +192,7 @@ function attributeString( if (!attributes) return null; for (const key of keys) { const value = attributes[key]; - if (typeof value === 'string' && value.trim()) { + if (typeof value === "string" && value.trim()) { return value; } } @@ -203,10 +203,10 @@ function attributeInt(attributes: Record | null, keys: string[] if (!attributes) return null; for (const key of keys) { const value = attributes[key]; - if (typeof value === 'number' && Number.isSafeInteger(value)) { + if (typeof value === "number" && Number.isSafeInteger(value)) { return value; } - if (typeof value === 'string' && value.trim()) { + if (typeof value === "string" && value.trim()) { const parsed = Number.parseInt(value, 10); if (Number.isSafeInteger(parsed)) { return parsed; @@ -217,30 +217,30 @@ function attributeInt(attributes: Record | null, keys: string[] } export function mapOtlpAttributesToLogColumns(attributes: Record | null) { - const sourceFile = attributeString(attributes, ['code.filepath', 'source.file', 'source_file']); - const lineNumber = attributeInt(attributes, ['code.lineno', 'source.line', 'line_number']); - const requestId = attributeString(attributes, ['request.id', 'request_id', 'http.request_id']); - const userId = attributeString(attributes, ['enduser.id', 'user.id', 'user_id']); + const sourceFile = attributeString(attributes, ["code.filepath", "source.file", "source_file"]); + const lineNumber = attributeInt(attributes, ["code.lineno", "source.line", "line_number"]); + const requestId = attributeString(attributes, ["request.id", "request_id", "http.request_id"]); + const userId = attributeString(attributes, ["enduser.id", "user.id", "user_id"]); const ipAddress = attributeString(attributes, [ - 'client.address', - 'ip', - 'ip_address', - 'net.peer.ip', - 'net.sock.peer.addr', + "client.address", + "ip", + "ip_address", + "net.peer.ip", + "net.sock.peer.addr", ]); return { sourceFile, lineNumber, requestId, userId, ipAddress }; } export function normalizeTraceId(value: unknown): string | null { - if (typeof value !== 'string') return null; + if (typeof value !== "string") return null; const trimmed = value.trim(); if (!TRACE_ID_REGEX.test(trimmed)) return null; return trimmed.toLowerCase(); } export function normalizeSpanId(value: unknown): string | null { - if (typeof value !== 'string') return null; + if (typeof value !== "string") return null; const trimmed = value.trim(); if (!SPAN_ID_REGEX.test(trimmed)) return null; return trimmed.toLowerCase(); @@ -283,7 +283,7 @@ function parseKeyValueList(values?: OtlpKeyValue[], depth = 0): Record = {}; for (const entry of values) { if (!isRecord(entry)) continue; - const key = typeof entry.key === 'string' ? entry.key : null; + const key = typeof entry.key === "string" ? entry.key : null; if (!key) continue; const parsedValue = entry.value ? parseOtlpAnyValue(entry.value, depth + 1) : null; record[key] = parsedValue; @@ -297,10 +297,10 @@ function parseAttributes(values?: OtlpKeyValue[]): Record | nul } function deriveMessage(body: unknown | null, attributes: Record | null): string { - if (typeof body === 'string') return body; - const attrMessage = attributes?.message ?? attributes?.['log.message']; - if (typeof attrMessage === 'string') return attrMessage; - if (body === null || body === undefined) return ''; + if (typeof body === "string") return body; + const attrMessage = attributes?.message ?? attributes?.["log.message"]; + if (typeof attrMessage === "string") return attrMessage; + if (body === null || body === undefined) return ""; try { return JSON.stringify(body); } catch { @@ -312,17 +312,17 @@ function deriveLevel(severityNumber: number | null, severityText: string | null) if (severityNumber && severityNumber > 0) { return severityNumberToLogLevel(severityNumber); } - return severityTextToLogLevel(severityText) ?? 'info'; + return severityTextToLogLevel(severityText) ?? "info"; } export function normalizeOtlpLogsRequest(body: unknown): NormalizedOtlpLogsResult { if (!isRecord(body)) { - throw new OtlpValidationError('Request body must be an object.'); + throw new OtlpValidationError("Request body must be an object."); } const resourceLogs = body.resourceLogs; if (!Array.isArray(resourceLogs)) { - throw new OtlpValidationError('resourceLogs must be an array.'); + throw new OtlpValidationError("resourceLogs must be an array."); } const records: NormalizedOtlpLogRecord[] = []; @@ -338,7 +338,7 @@ export function normalizeOtlpLogsRequest(body: unknown): NormalizedOtlpLogsResul const resourceAttributes = parseAttributes(resource?.attributes); const resourceDroppedAttributesCount = parseOptionalNumber(resource?.droppedAttributesCount); const resourceSchemaUrl = - typeof resourceLog.schemaUrl === 'string' ? resourceLog.schemaUrl : null; + typeof resourceLog.schemaUrl === "string" ? resourceLog.schemaUrl : null; const scopeLogs = Array.isArray(resourceLog.scopeLogs) ? resourceLog.scopeLogs : []; @@ -348,18 +348,18 @@ export function normalizeOtlpLogsRequest(body: unknown): NormalizedOtlpLogsResul } const scope = isRecord(scopeLog.scope) ? (scopeLog.scope as OtlpScope) : null; - const scopeName = typeof scope?.name === 'string' ? scope.name : null; - const scopeVersion = typeof scope?.version === 'string' ? scope.version : null; + const scopeName = typeof scope?.name === "string" ? scope.name : null; + const scopeVersion = typeof scope?.version === "string" ? scope.version : null; const scopeAttributes = parseAttributes(scope?.attributes); const scopeDroppedAttributesCount = parseOptionalNumber(scope?.droppedAttributesCount); - const scopeSchemaUrl = typeof scopeLog.schemaUrl === 'string' ? scopeLog.schemaUrl : null; + const scopeSchemaUrl = typeof scopeLog.schemaUrl === "string" ? scopeLog.schemaUrl : null; const logRecords = Array.isArray(scopeLog.logRecords) ? scopeLog.logRecords : []; for (const logRecord of logRecords) { if (!isRecord(logRecord)) { rejectedLogRecords += 1; - errors.push('Log record rejected: must be an object.'); + errors.push("Log record rejected: must be an object."); continue; } @@ -367,7 +367,7 @@ export function normalizeOtlpLogsRequest(body: unknown): NormalizedOtlpLogsResul const timeUnixNano = parseUint64String(record.timeUnixNano); const observedTimeUnixNano = parseUint64String(record.observedTimeUnixNano); const severityNumber = parseSeverityNumber(record.severityNumber); - const severityText = typeof record.severityText === 'string' ? record.severityText : null; + const severityText = typeof record.severityText === "string" ? record.severityText : null; const bodyValue = record.body ? parseOtlpAnyValue(record.body) : null; const attributes = parseAttributes(record.attributes); const droppedAttributesCount = parseOptionalNumber(record.droppedAttributesCount); diff --git a/src/lib/server/utils/otlp.unit.test.ts b/src/lib/server/utils/otlp.unit.test.ts index f4185a5..bcfc5fe 100644 --- a/src/lib/server/utils/otlp.unit.test.ts +++ b/src/lib/server/utils/otlp.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; import { normalizeOtlpLogsRequest, normalizeSpanId, @@ -6,42 +6,42 @@ import { parseOtlpAnyValue, parseUint64String, severityNumberToLogLevel, -} from './otlp'; +} from "./otlp"; -describe('normalizeOtlpLogsRequest', () => { - it('flattens resource/scope/log records and derives display fields', () => { +describe("normalizeOtlpLogsRequest", () => { + it("flattens resource/scope/log records and derives display fields", () => { const payload = { resourceLogs: [ { resource: { attributes: [ - { key: 'service.name', value: { stringValue: 'api' } }, - { key: 'service.version', value: { stringValue: '1.0.0' } }, + { key: "service.name", value: { stringValue: "api" } }, + { key: "service.version", value: { stringValue: "1.0.0" } }, ], droppedAttributesCount: 1, }, - schemaUrl: 'https://opentelemetry.io/schemas/1.0.0', + schemaUrl: "https://opentelemetry.io/schemas/1.0.0", scopeLogs: [ { scope: { - name: 'logger', - version: '2.1.0', - attributes: [{ key: 'scope.attr', value: { boolValue: true } }], + name: "logger", + version: "2.1.0", + attributes: [{ key: "scope.attr", value: { boolValue: true } }], droppedAttributesCount: 0, }, - schemaUrl: 'https://example.com/scope/1.0.0', + schemaUrl: "https://example.com/scope/1.0.0", logRecords: [ { - timeUnixNano: '1700000000000000000', - observedTimeUnixNano: '1700000000001000000', + timeUnixNano: "1700000000000000000", + observedTimeUnixNano: "1700000000001000000", severityNumber: 17, - severityText: 'ERROR', - body: { stringValue: 'Database failed' }, - attributes: [{ key: 'request.id', value: { stringValue: 'req-123' } }], + severityText: "ERROR", + body: { stringValue: "Database failed" }, + attributes: [{ key: "request.id", value: { stringValue: "req-123" } }], droppedAttributesCount: 0, flags: 1, - traceId: '5B8EFFF798038103D269B633813FC60C', - spanId: 'EEE19B7EC3C1B174', + traceId: "5B8EFFF798038103D269B633813FC60C", + spanId: "EEE19B7EC3C1B174", }, ], }, @@ -55,24 +55,24 @@ describe('normalizeOtlpLogsRequest', () => { const record = records[0]!; expect(record.resourceAttributes).toEqual({ - 'service.name': 'api', - 'service.version': '1.0.0', + "service.name": "api", + "service.version": "1.0.0", }); expect(record.resourceDroppedAttributesCount).toBe(1); - expect(record.resourceSchemaUrl).toBe('https://opentelemetry.io/schemas/1.0.0'); - expect(record.scopeName).toBe('logger'); - expect(record.scopeVersion).toBe('2.1.0'); - expect(record.scopeAttributes).toEqual({ 'scope.attr': true }); - expect(record.scopeSchemaUrl).toBe('https://example.com/scope/1.0.0'); - expect(record.attributes).toEqual({ 'request.id': 'req-123' }); - expect(record.message).toBe('Database failed'); - expect(record.level).toBe('error'); + expect(record.resourceSchemaUrl).toBe("https://opentelemetry.io/schemas/1.0.0"); + expect(record.scopeName).toBe("logger"); + expect(record.scopeVersion).toBe("2.1.0"); + expect(record.scopeAttributes).toEqual({ "scope.attr": true }); + expect(record.scopeSchemaUrl).toBe("https://example.com/scope/1.0.0"); + expect(record.attributes).toEqual({ "request.id": "req-123" }); + expect(record.message).toBe("Database failed"); + expect(record.level).toBe("error"); expect(record.timestamp.toISOString()).toBe(new Date(1700000000000).toISOString()); - expect(record.traceId).toBe('5b8efff798038103d269b633813fc60c'); - expect(record.spanId).toBe('eee19b7ec3c1b174'); + expect(record.traceId).toBe("5b8efff798038103d269b633813fc60c"); + expect(record.spanId).toBe("eee19b7ec3c1b174"); }); - it('stringifies non-string bodies for message fallback', () => { + it("stringifies non-string bodies for message fallback", () => { const payload = { resourceLogs: [ { @@ -80,12 +80,12 @@ describe('normalizeOtlpLogsRequest', () => { { logRecords: [ { - timeUnixNano: '1700000000000000000', + timeUnixNano: "1700000000000000000", body: { kvlistValue: { values: [ - { key: 'action', value: { stringValue: 'login' } }, - { key: 'success', value: { boolValue: true } }, + { key: "action", value: { stringValue: "login" } }, + { key: "success", value: { boolValue: true } }, ], }, }, @@ -103,84 +103,84 @@ describe('normalizeOtlpLogsRequest', () => { }); }); -describe('parseOtlpAnyValue', () => { - it('parses primitive values', () => { - expect(parseOtlpAnyValue({ stringValue: 'hello' })).toBe('hello'); +describe("parseOtlpAnyValue", () => { + it("parses primitive values", () => { + expect(parseOtlpAnyValue({ stringValue: "hello" })).toBe("hello"); expect(parseOtlpAnyValue({ boolValue: true })).toBe(true); - expect(parseOtlpAnyValue({ intValue: '42' })).toBe(42); + expect(parseOtlpAnyValue({ intValue: "42" })).toBe(42); expect(parseOtlpAnyValue({ doubleValue: 3.14 })).toBe(3.14); }); - it('preserves large int64 values as strings', () => { - expect(parseOtlpAnyValue({ intValue: '9007199254740993' })).toBe('9007199254740993'); + it("preserves large int64 values as strings", () => { + expect(parseOtlpAnyValue({ intValue: "9007199254740993" })).toBe("9007199254740993"); }); - it('parses array and kvlist values', () => { + it("parses array and kvlist values", () => { expect( parseOtlpAnyValue({ - arrayValue: { values: [{ stringValue: 'a' }, { intValue: 2 }] }, + arrayValue: { values: [{ stringValue: "a" }, { intValue: 2 }] }, }), - ).toEqual(['a', 2]); + ).toEqual(["a", 2]); expect( parseOtlpAnyValue({ kvlistValue: { - values: [{ key: 'foo', value: { stringValue: 'bar' } }], + values: [{ key: "foo", value: { stringValue: "bar" } }], }, }), - ).toEqual({ foo: 'bar' }); + ).toEqual({ foo: "bar" }); }); }); -describe('severityNumberToLogLevel', () => { - it('maps severity ranges to log levels', () => { - expect(severityNumberToLogLevel(1)).toBe('debug'); - expect(severityNumberToLogLevel(6)).toBe('debug'); - expect(severityNumberToLogLevel(9)).toBe('info'); - expect(severityNumberToLogLevel(14)).toBe('warn'); - expect(severityNumberToLogLevel(18)).toBe('error'); - expect(severityNumberToLogLevel(21)).toBe('fatal'); +describe("severityNumberToLogLevel", () => { + it("maps severity ranges to log levels", () => { + expect(severityNumberToLogLevel(1)).toBe("debug"); + expect(severityNumberToLogLevel(6)).toBe("debug"); + expect(severityNumberToLogLevel(9)).toBe("info"); + expect(severityNumberToLogLevel(14)).toBe("warn"); + expect(severityNumberToLogLevel(18)).toBe("error"); + expect(severityNumberToLogLevel(21)).toBe("fatal"); }); }); -describe('parseUint64String', () => { - it('accepts valid non-negative integer strings', () => { - expect(parseUint64String('0')).toBe('0'); - expect(parseUint64String('1700000000000000000')).toBe('1700000000000000000'); - expect(parseUint64String(' 42 ')).toBe('42'); +describe("parseUint64String", () => { + it("accepts valid non-negative integer strings", () => { + expect(parseUint64String("0")).toBe("0"); + expect(parseUint64String("1700000000000000000")).toBe("1700000000000000000"); + expect(parseUint64String(" 42 ")).toBe("42"); }); - it('rejects negative string values', () => { - expect(parseUint64String('-1')).toBeNull(); - expect(parseUint64String('-1000000')).toBeNull(); - expect(parseUint64String(' -42 ')).toBeNull(); + it("rejects negative string values", () => { + expect(parseUint64String("-1")).toBeNull(); + expect(parseUint64String("-1000000")).toBeNull(); + expect(parseUint64String(" -42 ")).toBeNull(); }); - it('rejects negative number values', () => { + it("rejects negative number values", () => { expect(parseUint64String(-1)).toBeNull(); expect(parseUint64String(-1000000)).toBeNull(); }); - it('rejects non-integer number values', () => { + it("rejects non-integer number values", () => { expect(parseUint64String(1.5)).toBeNull(); expect(parseUint64String(-1.5)).toBeNull(); }); - it('rejects non-numeric strings', () => { - expect(parseUint64String('abc')).toBeNull(); - expect(parseUint64String('1.5')).toBeNull(); - expect(parseUint64String('')).toBeNull(); - expect(parseUint64String(' ')).toBeNull(); + it("rejects non-numeric strings", () => { + expect(parseUint64String("abc")).toBeNull(); + expect(parseUint64String("1.5")).toBeNull(); + expect(parseUint64String("")).toBeNull(); + expect(parseUint64String(" ")).toBeNull(); }); - it('accepts non-negative integer numbers', () => { - expect(parseUint64String(0)).toBe('0'); - expect(parseUint64String(42)).toBe('42'); + it("accepts non-negative integer numbers", () => { + expect(parseUint64String(0)).toBe("0"); + expect(parseUint64String(42)).toBe("42"); }); }); -describe('normalizeOtlpLogsRequest edge cases', () => { - it('rejects negative timeUnixNano and falls back to current timestamp', () => { +describe("normalizeOtlpLogsRequest edge cases", () => { + it("rejects negative timeUnixNano and falls back to current timestamp", () => { const payload = { resourceLogs: [ { @@ -188,7 +188,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { { logRecords: [ { - timeUnixNano: '-1000000', + timeUnixNano: "-1000000", }, ], }, @@ -205,7 +205,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); }); - it('rejects negative observedTimeUnixNano and falls back to current timestamp', () => { + it("rejects negative observedTimeUnixNano and falls back to current timestamp", () => { const payload = { resourceLogs: [ { @@ -213,7 +213,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { { logRecords: [ { - observedTimeUnixNano: '-1000000', + observedTimeUnixNano: "-1000000", }, ], }, @@ -230,7 +230,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); }); - it('normalizes empty attributes to null', () => { + it("normalizes empty attributes to null", () => { const payload = { resourceLogs: [ { @@ -238,7 +238,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { { logRecords: [ { - timeUnixNano: '1700000000000000000', + timeUnixNano: "1700000000000000000", attributes: [], }, ], @@ -253,7 +253,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { expect(records[0]!.attributes).toBeNull(); }); - it('handles extremely large timeUnixNano without producing Invalid Date', () => { + it("handles extremely large timeUnixNano without producing Invalid Date", () => { const payload = { resourceLogs: [ { @@ -261,7 +261,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { { logRecords: [ { - timeUnixNano: '999999999999999999999999999999', + timeUnixNano: "999999999999999999999999999999", }, ], }, @@ -272,7 +272,7 @@ describe('normalizeOtlpLogsRequest edge cases', () => { const { records } = normalizeOtlpLogsRequest(payload); expect(records).toHaveLength(1); - expect(records[0]!.timeUnixNano).toBe('999999999999999999999999999999'); + expect(records[0]!.timeUnixNano).toBe("999999999999999999999999999999"); expect(Number.isNaN(records[0]!.timestamp.getTime())).toBe(false); const now = new Date(); expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); @@ -280,13 +280,13 @@ describe('normalizeOtlpLogsRequest edge cases', () => { }); }); -describe('normalizeTraceId/normalizeSpanId', () => { - it('normalizes valid hex ids and rejects invalid ones', () => { - expect(normalizeTraceId('5B8EFFF798038103D269B633813FC60C')).toBe( - '5b8efff798038103d269b633813fc60c', +describe("normalizeTraceId/normalizeSpanId", () => { + it("normalizes valid hex ids and rejects invalid ones", () => { + expect(normalizeTraceId("5B8EFFF798038103D269B633813FC60C")).toBe( + "5b8efff798038103d269b633813fc60c", ); - expect(normalizeTraceId('invalid')).toBeNull(); - expect(normalizeSpanId('EEE19B7EC3C1B174')).toBe('eee19b7ec3c1b174'); - expect(normalizeSpanId('1234')).toBeNull(); + expect(normalizeTraceId("invalid")).toBeNull(); + expect(normalizeSpanId("EEE19B7EC3C1B174")).toBe("eee19b7ec3c1b174"); + expect(normalizeSpanId("1234")).toBeNull(); }); }); diff --git a/src/lib/server/utils/project-guard.ts b/src/lib/server/utils/project-guard.ts index fd45935..a4249d3 100644 --- a/src/lib/server/utils/project-guard.ts +++ b/src/lib/server/utils/project-guard.ts @@ -1,8 +1,8 @@ -import { json, type RequestEvent } from '@sveltejs/kit'; -import { and, eq } from 'drizzle-orm'; -import { getDbClient } from '$lib/server/db/db'; -import { type Project, project } from '$lib/server/db/schema'; -import { type AuthenticatedSession, requireAuth } from './auth-guard'; +import { json, type RequestEvent } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import { getDbClient } from "$lib/server/db/db"; +import { type Project, project } from "$lib/server/db/schema"; +import { type AuthenticatedSession, requireAuth } from "./auth-guard"; /** * Result of successful project ownership check @@ -49,7 +49,7 @@ export async function requireProjectOwnership( if (!projectData) { // Return 404 to hide existence from non-owners - return json({ error: 'not_found', message: 'Project not found' }, { status: 404 }); + return json({ error: "not_found", message: "Project not found" }, { status: 404 }); } return { project: projectData, user, session }; diff --git a/src/lib/server/utils/search.ts b/src/lib/server/utils/search.ts index 555daca..f2cbf37 100644 --- a/src/lib/server/utils/search.ts +++ b/src/lib/server/utils/search.ts @@ -1,6 +1,6 @@ -import { desc, sql } from 'drizzle-orm'; -import type { DatabaseClient } from '$lib/server/db/db'; -import { type Log, log } from '../db/schema'; +import { desc, sql } from "drizzle-orm"; +import type { DatabaseClient } from "$lib/server/db/db"; +import { type Log, log } from "../db/schema"; /** * Builds a PostgreSQL tsquery string from a search term @@ -19,8 +19,8 @@ import { type Log, log } from '../db/schema'; * // Returns: 'error & warning' */ export function buildSearchQuery(searchTerm: string): string { - if (!searchTerm || typeof searchTerm !== 'string') { - return ''; + if (!searchTerm || typeof searchTerm !== "string") { + return ""; } // PostgreSQL tsquery special characters that need to be removed @@ -28,12 +28,12 @@ export function buildSearchQuery(searchTerm: string): string { const specialCharsRegex = /[&|!():*\\'"<>]/g; // Remove special characters, then split on whitespace - const sanitized = searchTerm.replace(specialCharsRegex, ' '); + const sanitized = searchTerm.replace(specialCharsRegex, " "); // Split on whitespace, filter empty strings, and join with ' & ' const terms = sanitized.split(/\s+/).filter((term) => term.length > 0); - return terms.join(' & '); + return terms.join(" & "); } /** diff --git a/src/lib/server/utils/search.unit.test.ts b/src/lib/server/utils/search.unit.test.ts index 7065796..d84925b 100644 --- a/src/lib/server/utils/search.unit.test.ts +++ b/src/lib/server/utils/search.unit.test.ts @@ -1,102 +1,102 @@ -import { describe, expect, it } from 'vitest'; -import { buildSearchQuery } from './search'; +import { describe, expect, it } from "vite-plus/test"; +import { buildSearchQuery } from "./search"; -describe('buildSearchQuery', () => { - it('converts space-separated terms to tsquery with AND operator', () => { - const result = buildSearchQuery('database connection failed'); - expect(result).toBe('database & connection & failed'); +describe("buildSearchQuery", () => { + it("converts space-separated terms to tsquery with AND operator", () => { + const result = buildSearchQuery("database connection failed"); + expect(result).toBe("database & connection & failed"); }); - it('handles single term', () => { - const result = buildSearchQuery('error'); - expect(result).toBe('error'); + it("handles single term", () => { + const result = buildSearchQuery("error"); + expect(result).toBe("error"); }); - it('handles multiple spaces between terms', () => { - const result = buildSearchQuery('database connection failed'); - expect(result).toBe('database & connection & failed'); + it("handles multiple spaces between terms", () => { + const result = buildSearchQuery("database connection failed"); + expect(result).toBe("database & connection & failed"); }); - it('handles leading and trailing whitespace', () => { - const result = buildSearchQuery(' database connection '); - expect(result).toBe('database & connection'); + it("handles leading and trailing whitespace", () => { + const result = buildSearchQuery(" database connection "); + expect(result).toBe("database & connection"); }); - it('handles empty string', () => { - const result = buildSearchQuery(''); - expect(result).toBe(''); + it("handles empty string", () => { + const result = buildSearchQuery(""); + expect(result).toBe(""); }); - it('handles string with only whitespace', () => { - const result = buildSearchQuery(' '); - expect(result).toBe(''); + it("handles string with only whitespace", () => { + const result = buildSearchQuery(" "); + expect(result).toBe(""); }); - it('escapes ampersand (&) character', () => { - const result = buildSearchQuery('error & warning'); - expect(result).toBe('error & warning'); + it("escapes ampersand (&) character", () => { + const result = buildSearchQuery("error & warning"); + expect(result).toBe("error & warning"); }); - it('escapes pipe (|) character', () => { - const result = buildSearchQuery('error | warning'); - expect(result).toBe('error & warning'); + it("escapes pipe (|) character", () => { + const result = buildSearchQuery("error | warning"); + expect(result).toBe("error & warning"); }); - it('escapes exclamation (!) character', () => { - const result = buildSearchQuery('error! warning'); - expect(result).toBe('error & warning'); + it("escapes exclamation (!) character", () => { + const result = buildSearchQuery("error! warning"); + expect(result).toBe("error & warning"); }); - it('escapes parentheses () characters', () => { - const result = buildSearchQuery('error (warning) info'); - expect(result).toBe('error & warning & info'); + it("escapes parentheses () characters", () => { + const result = buildSearchQuery("error (warning) info"); + expect(result).toBe("error & warning & info"); }); - it('escapes colon (:) character', () => { - const result = buildSearchQuery('error:warning'); + it("escapes colon (:) character", () => { + const result = buildSearchQuery("error:warning"); // Colon replaced with space, creates two terms - expect(result).toBe('error & warning'); + expect(result).toBe("error & warning"); }); - it('escapes asterisk (*) character', () => { - const result = buildSearchQuery('error* warning'); - expect(result).toBe('error & warning'); + it("escapes asterisk (*) character", () => { + const result = buildSearchQuery("error* warning"); + expect(result).toBe("error & warning"); }); - it('escapes backslash (\\) character', () => { - const result = buildSearchQuery('error\\warning'); + it("escapes backslash (\\) character", () => { + const result = buildSearchQuery("error\\warning"); // Backslash replaced with space, creates two terms - expect(result).toBe('error & warning'); + expect(result).toBe("error & warning"); }); it("escapes single quote (') character", () => { const result = buildSearchQuery("error's warning"); // Quote replaced with space, creates three terms - expect(result).toBe('error & s & warning'); + expect(result).toBe("error & s & warning"); }); it('escapes double quote (") character', () => { const result = buildSearchQuery('error "warning" info'); - expect(result).toBe('error & warning & info'); + expect(result).toBe("error & warning & info"); }); - it('handles multiple special characters together', () => { - const result = buildSearchQuery('error!|&* (warning)'); - expect(result).toBe('error & warning'); + it("handles multiple special characters together", () => { + const result = buildSearchQuery("error!|&* (warning)"); + expect(result).toBe("error & warning"); }); - it('preserves alphanumeric characters and hyphens', () => { - const result = buildSearchQuery('error-500 database-connection'); - expect(result).toBe('error-500 & database-connection'); + it("preserves alphanumeric characters and hyphens", () => { + const result = buildSearchQuery("error-500 database-connection"); + expect(result).toBe("error-500 & database-connection"); }); - it('preserves underscores', () => { - const result = buildSearchQuery('user_id error_message'); - expect(result).toBe('user_id & error_message'); + it("preserves underscores", () => { + const result = buildSearchQuery("user_id error_message"); + expect(result).toBe("user_id & error_message"); }); - it('handles real-world example with mixed content', () => { - const result = buildSearchQuery('Database connection failed! (timeout: 30s)'); - expect(result).toBe('Database & connection & failed & timeout & 30s'); + it("handles real-world example with mixed content", () => { + const result = buildSearchQuery("Database connection failed! (timeout: 30s)"); + expect(result).toBe("Database & connection & failed & timeout & 30s"); }); }); diff --git a/src/lib/server/utils/simple-ingest.ts b/src/lib/server/utils/simple-ingest.ts index 5c3a1c5..f4079a0 100644 --- a/src/lib/server/utils/simple-ingest.ts +++ b/src/lib/server/utils/simple-ingest.ts @@ -1,10 +1,10 @@ -import type { LogLevel } from '../db/schema'; -import { mapOtlpAttributesToLogColumns } from './otlp'; +import type { LogLevel } from "../db/schema"; +import { mapOtlpAttributesToLogColumns } from "./otlp"; /** * Valid log levels for simple ingestion API */ -const VALID_LEVELS: readonly LogLevel[] = ['debug', 'info', 'warn', 'error', 'fatal'] as const; +const VALID_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error", "fatal"] as const; /** * Input format for a single log entry from the simple API @@ -26,7 +26,7 @@ export interface NormalizedSimpleLog { level: LogLevel; message: string; timestamp: Date; - resourceAttributes: { 'service.name': string } | null; + resourceAttributes: { "service.name": string } | null; metadata: Record | null; sourceFile: string | null; lineNumber: number | null; @@ -51,7 +51,7 @@ export interface SimpleIngestResult { export class SimpleIngestError extends Error { constructor(message: string) { super(message); - this.name = 'SimpleIngestError'; + this.name = "SimpleIngestError"; } } @@ -59,7 +59,7 @@ export class SimpleIngestError extends Error { * Validates that a value is a valid log level */ function isValidLevel(level: unknown): level is LogLevel { - return typeof level === 'string' && VALID_LEVELS.includes(level as LogLevel); + return typeof level === "string" && VALID_LEVELS.includes(level as LogLevel); } /** @@ -67,7 +67,7 @@ function isValidLevel(level: unknown): level is LogLevel { * Returns current date if timestamp is invalid or missing */ function parseTimestamp(timestamp: unknown): Date { - if (!timestamp || typeof timestamp !== 'string') { + if (!timestamp || typeof timestamp !== "string") { return new Date(); } @@ -87,45 +87,45 @@ function validateLogEntry( input: unknown, index: number, ): { log: NormalizedSimpleLog; error: null } | { log: null; error: string } { - if (!input || typeof input !== 'object') { + if (!input || typeof input !== "object") { return { log: null, error: `Entry at index ${index}: must be an object` }; } const entry = input as Record; // Validate level - if (!('level' in entry)) { + if (!("level" in entry)) { return { log: null, error: `Entry at index ${index}: missing required field 'level'` }; } if (!isValidLevel(entry.level)) { return { log: null, - error: `Entry at index ${index}: invalid level '${entry.level}' (must be one of: ${VALID_LEVELS.join(', ')})`, + error: `Entry at index ${index}: invalid level '${entry.level}' (must be one of: ${VALID_LEVELS.join(", ")})`, }; } // Validate message - if (!('message' in entry)) { + if (!("message" in entry)) { return { log: null, error: `Entry at index ${index}: missing required field 'message'` }; } - if (typeof entry.message !== 'string') { + if (typeof entry.message !== "string") { return { log: null, error: `Entry at index ${index}: message must be a string` }; } - if (entry.message.trim() === '') { + if (entry.message.trim() === "") { return { log: null, error: `Entry at index ${index}: message cannot be empty` }; } // Parse optional fields const timestamp = parseTimestamp(entry.timestamp); - const service = typeof entry.service === 'string' ? entry.service : null; + const service = typeof entry.service === "string" ? entry.service : null; const rawMetadata = - entry.metadata && typeof entry.metadata === 'object' + entry.metadata && typeof entry.metadata === "object" ? (entry.metadata as Record) : null; const metadata = rawMetadata && Object.keys(rawMetadata).length > 0 ? rawMetadata : null; - const sourceFile = typeof entry.sourceFile === 'string' ? entry.sourceFile : null; + const sourceFile = typeof entry.sourceFile === "string" ? entry.sourceFile : null; const lineNumber = - typeof entry.lineNumber === 'number' && entry.lineNumber > 0 ? entry.lineNumber : null; + typeof entry.lineNumber === "number" && entry.lineNumber > 0 ? entry.lineNumber : null; const mapped = mapOtlpAttributesToLogColumns(metadata); @@ -134,7 +134,7 @@ function validateLogEntry( level: entry.level as LogLevel, message: entry.message, timestamp, - resourceAttributes: service ? { 'service.name': service } : null, + resourceAttributes: service ? { "service.name": service } : null, metadata, sourceFile, lineNumber, @@ -159,14 +159,14 @@ function validateLogEntry( */ export function parseSimpleIngestRequest(body: unknown): SimpleIngestResult { if (body === null || body === undefined) { - throw new SimpleIngestError('Request body cannot be empty'); + throw new SimpleIngestError("Request body cannot be empty"); } // Normalize to array const entries = Array.isArray(body) ? body : [body]; if (entries.length === 0) { - throw new SimpleIngestError('Request body cannot be an empty array'); + throw new SimpleIngestError("Request body cannot be an empty array"); } const records: NormalizedSimpleLog[] = []; diff --git a/src/lib/server/utils/simple-ingest.unit.test.ts b/src/lib/server/utils/simple-ingest.unit.test.ts index c51924a..a405e2f 100644 --- a/src/lib/server/utils/simple-ingest.unit.test.ts +++ b/src/lib/server/utils/simple-ingest.unit.test.ts @@ -1,114 +1,114 @@ -import { describe, expect, it } from 'vitest'; -import { parseSimpleIngestRequest, SimpleIngestError } from './simple-ingest'; +import { describe, expect, it } from "vite-plus/test"; +import { parseSimpleIngestRequest, SimpleIngestError } from "./simple-ingest"; -describe('parseSimpleIngestRequest', () => { - const validEntry = { level: 'info', message: 'test message' }; +describe("parseSimpleIngestRequest", () => { + const validEntry = { level: "info", message: "test message" }; - describe('input validation', () => { - it('throws SimpleIngestError on null body', () => { + describe("input validation", () => { + it("throws SimpleIngestError on null body", () => { expect(() => parseSimpleIngestRequest(null)).toThrow(SimpleIngestError); - expect(() => parseSimpleIngestRequest(null)).toThrow('Request body cannot be empty'); + expect(() => parseSimpleIngestRequest(null)).toThrow("Request body cannot be empty"); }); - it('throws SimpleIngestError on undefined body', () => { + it("throws SimpleIngestError on undefined body", () => { expect(() => parseSimpleIngestRequest(undefined)).toThrow(SimpleIngestError); - expect(() => parseSimpleIngestRequest(undefined)).toThrow('Request body cannot be empty'); + expect(() => parseSimpleIngestRequest(undefined)).toThrow("Request body cannot be empty"); }); - it('throws SimpleIngestError on empty array', () => { + it("throws SimpleIngestError on empty array", () => { expect(() => parseSimpleIngestRequest([])).toThrow(SimpleIngestError); - expect(() => parseSimpleIngestRequest([])).toThrow('Request body cannot be an empty array'); + expect(() => parseSimpleIngestRequest([])).toThrow("Request body cannot be an empty array"); }); - it('accepts single object and wraps in array', () => { + it("accepts single object and wraps in array", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.accepted).toBe(1); expect(result.records).toHaveLength(1); }); - it('accepts array of objects', () => { + it("accepts array of objects", () => { const result = parseSimpleIngestRequest([validEntry, validEntry]); expect(result.accepted).toBe(2); expect(result.records).toHaveLength(2); }); }); - describe('required fields', () => { - it('rejects entry missing level', () => { - const result = parseSimpleIngestRequest({ message: 'test' }); + describe("required fields", () => { + it("rejects entry missing level", () => { + const result = parseSimpleIngestRequest({ message: "test" }); expect(result.rejected).toBe(1); expect(result.errors[0]!).toContain("missing required field 'level'"); }); - it('rejects entry with invalid level', () => { - const result = parseSimpleIngestRequest({ level: 'invalid', message: 'test' }); + it("rejects entry with invalid level", () => { + const result = parseSimpleIngestRequest({ level: "invalid", message: "test" }); expect(result.rejected).toBe(1); expect(result.errors[0]!).toContain("invalid level 'invalid'"); - expect(result.errors[0]!).toContain('must be one of'); + expect(result.errors[0]!).toContain("must be one of"); }); - it('rejects entry missing message', () => { - const result = parseSimpleIngestRequest({ level: 'info' }); + it("rejects entry missing message", () => { + const result = parseSimpleIngestRequest({ level: "info" }); expect(result.rejected).toBe(1); expect(result.errors[0]!).toContain("missing required field 'message'"); }); - it('rejects entry with non-string message', () => { - const result = parseSimpleIngestRequest({ level: 'info', message: 123 }); + it("rejects entry with non-string message", () => { + const result = parseSimpleIngestRequest({ level: "info", message: 123 }); expect(result.rejected).toBe(1); - expect(result.errors[0]!).toContain('message must be a string'); + expect(result.errors[0]!).toContain("message must be a string"); }); - it('rejects entry with empty message', () => { - const result = parseSimpleIngestRequest({ level: 'info', message: ' ' }); + it("rejects entry with empty message", () => { + const result = parseSimpleIngestRequest({ level: "info", message: " " }); expect(result.rejected).toBe(1); - expect(result.errors[0]!).toContain('message cannot be empty'); + expect(result.errors[0]!).toContain("message cannot be empty"); }); - it('rejects null entry', () => { + it("rejects null entry", () => { const result = parseSimpleIngestRequest([null]); expect(result.rejected).toBe(1); - expect(result.errors[0]!).toContain('must be an object'); + expect(result.errors[0]!).toContain("must be an object"); }); - it('rejects string entry', () => { - const result = parseSimpleIngestRequest(['not an object']); + it("rejects string entry", () => { + const result = parseSimpleIngestRequest(["not an object"]); expect(result.rejected).toBe(1); - expect(result.errors[0]!).toContain('must be an object'); + expect(result.errors[0]!).toContain("must be an object"); }); - it('rejects number entry', () => { + it("rejects number entry", () => { const result = parseSimpleIngestRequest([123]); expect(result.rejected).toBe(1); - expect(result.errors[0]!).toContain('must be an object'); + expect(result.errors[0]!).toContain("must be an object"); }); }); - describe('valid log levels', () => { - it.each(['debug', 'info', 'warn', 'error', 'fatal'] as const)('accepts level "%s"', (level) => { - const result = parseSimpleIngestRequest({ level, message: 'test' }); + describe("valid log levels", () => { + it.each(["debug", "info", "warn", "error", "fatal"] as const)('accepts level "%s"', (level) => { + const result = parseSimpleIngestRequest({ level, message: "test" }); expect(result.accepted).toBe(1); expect(result.records[0]!.level).toBe(level); }); }); - describe('optional fields', () => { - describe('timestamp', () => { - it('parses valid ISO8601 timestamp', () => { - const timestamp = '2024-01-15T10:30:00Z'; + describe("optional fields", () => { + describe("timestamp", () => { + it("parses valid ISO8601 timestamp", () => { + const timestamp = "2024-01-15T10:30:00Z"; const result = parseSimpleIngestRequest({ ...validEntry, timestamp }); expect(result.records[0]!.timestamp).toEqual(new Date(timestamp)); }); - it('uses current date for invalid timestamp', () => { + it("uses current date for invalid timestamp", () => { const before = Date.now(); - const result = parseSimpleIngestRequest({ ...validEntry, timestamp: 'invalid' }); + const result = parseSimpleIngestRequest({ ...validEntry, timestamp: "invalid" }); const after = Date.now(); expect(result.records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(before); expect(result.records[0]!.timestamp.getTime()).toBeLessThanOrEqual(after); }); - it('uses current date for missing timestamp', () => { + it("uses current date for missing timestamp", () => { const before = Date.now(); const result = parseSimpleIngestRequest(validEntry); const after = Date.now(); @@ -116,7 +116,7 @@ describe('parseSimpleIngestRequest', () => { expect(result.records[0]!.timestamp.getTime()).toBeLessThanOrEqual(after); }); - it('uses current date for non-string timestamp', () => { + it("uses current date for non-string timestamp", () => { const before = Date.now(); const result = parseSimpleIngestRequest({ ...validEntry, timestamp: 12345 }); const after = Date.now(); @@ -125,163 +125,163 @@ describe('parseSimpleIngestRequest', () => { }); }); - describe('service', () => { - it('parses service name into resourceAttributes', () => { - const result = parseSimpleIngestRequest({ ...validEntry, service: 'my-app' }); - expect(result.records[0]!.resourceAttributes).toEqual({ 'service.name': 'my-app' }); + describe("service", () => { + it("parses service name into resourceAttributes", () => { + const result = parseSimpleIngestRequest({ ...validEntry, service: "my-app" }); + expect(result.records[0]!.resourceAttributes).toEqual({ "service.name": "my-app" }); }); - it('returns null resourceAttributes for missing service', () => { + it("returns null resourceAttributes for missing service", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.records[0]!.resourceAttributes).toBeNull(); }); - it('returns null resourceAttributes for non-string service', () => { + it("returns null resourceAttributes for non-string service", () => { const result = parseSimpleIngestRequest({ ...validEntry, service: 123 }); expect(result.records[0]!.resourceAttributes).toBeNull(); }); }); - describe('metadata', () => { - it('parses metadata object', () => { - const metadata = { foo: 'bar', nested: { a: 1 } }; + describe("metadata", () => { + it("parses metadata object", () => { + const metadata = { foo: "bar", nested: { a: 1 } }; const result = parseSimpleIngestRequest({ ...validEntry, metadata }); expect(result.records[0]!.metadata).toEqual(metadata); }); - it('returns null metadata for missing metadata', () => { + it("returns null metadata for missing metadata", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.records[0]!.metadata).toBeNull(); }); - it('returns null metadata for non-object metadata', () => { - const result = parseSimpleIngestRequest({ ...validEntry, metadata: 'string' }); + it("returns null metadata for non-object metadata", () => { + const result = parseSimpleIngestRequest({ ...validEntry, metadata: "string" }); expect(result.records[0]!.metadata).toBeNull(); }); - it('returns null metadata for null metadata', () => { + it("returns null metadata for null metadata", () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: null }); expect(result.records[0]!.metadata).toBeNull(); }); - it('returns null metadata for empty object metadata', () => { + it("returns null metadata for empty object metadata", () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: {} }); expect(result.records[0]!.metadata).toBeNull(); }); }); }); - describe('source location fields', () => { - describe('sourceFile', () => { - it('parses valid sourceFile', () => { - const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: '/app/index.ts' }); - expect(result.records[0]!.sourceFile).toBe('/app/index.ts'); + describe("source location fields", () => { + describe("sourceFile", () => { + it("parses valid sourceFile", () => { + const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: "/app/index.ts" }); + expect(result.records[0]!.sourceFile).toBe("/app/index.ts"); }); - it('returns null sourceFile for missing sourceFile', () => { + it("returns null sourceFile for missing sourceFile", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.records[0]!.sourceFile).toBeNull(); }); - it('returns null sourceFile for non-string sourceFile', () => { + it("returns null sourceFile for non-string sourceFile", () => { const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: 123 }); expect(result.records[0]!.sourceFile).toBeNull(); }); - it('returns null sourceFile for null sourceFile', () => { + it("returns null sourceFile for null sourceFile", () => { const result = parseSimpleIngestRequest({ ...validEntry, sourceFile: null }); expect(result.records[0]!.sourceFile).toBeNull(); }); }); - describe('lineNumber', () => { - it('parses valid lineNumber', () => { + describe("lineNumber", () => { + it("parses valid lineNumber", () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: 42 }); expect(result.records[0]!.lineNumber).toBe(42); }); - it('returns null lineNumber for missing lineNumber', () => { + it("returns null lineNumber for missing lineNumber", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.records[0]!.lineNumber).toBeNull(); }); - it('returns null lineNumber for non-number lineNumber', () => { - const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: '42' }); + it("returns null lineNumber for non-number lineNumber", () => { + const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: "42" }); expect(result.records[0]!.lineNumber).toBeNull(); }); - it('returns null lineNumber for zero', () => { + it("returns null lineNumber for zero", () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: 0 }); expect(result.records[0]!.lineNumber).toBeNull(); }); - it('returns null lineNumber for negative number', () => { + it("returns null lineNumber for negative number", () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: -5 }); expect(result.records[0]!.lineNumber).toBeNull(); }); - it('returns null lineNumber for null', () => { + it("returns null lineNumber for null", () => { const result = parseSimpleIngestRequest({ ...validEntry, lineNumber: null }); expect(result.records[0]!.lineNumber).toBeNull(); }); }); - describe('combined', () => { - it('parses both sourceFile and lineNumber together', () => { + describe("combined", () => { + it("parses both sourceFile and lineNumber together", () => { const result = parseSimpleIngestRequest({ ...validEntry, - sourceFile: '/app/utils.ts', + sourceFile: "/app/utils.ts", lineNumber: 100, }); - expect(result.records[0]!.sourceFile).toBe('/app/utils.ts'); + expect(result.records[0]!.sourceFile).toBe("/app/utils.ts"); expect(result.records[0]!.lineNumber).toBe(100); }); }); }); - describe('metadata extraction', () => { - it('extracts requestId from metadata using OTLP attribute keys', () => { + describe("metadata extraction", () => { + it("extracts requestId from metadata using OTLP attribute keys", () => { const result = parseSimpleIngestRequest({ ...validEntry, - metadata: { 'request.id': 'req-123' }, + metadata: { "request.id": "req-123" }, }); - expect(result.records[0]!.requestId).toBe('req-123'); + expect(result.records[0]!.requestId).toBe("req-123"); }); - it('extracts userId from metadata using OTLP attribute keys', () => { + it("extracts userId from metadata using OTLP attribute keys", () => { const result = parseSimpleIngestRequest({ ...validEntry, - metadata: { 'enduser.id': 'user-456' }, + metadata: { "enduser.id": "user-456" }, }); - expect(result.records[0]!.userId).toBe('user-456'); + expect(result.records[0]!.userId).toBe("user-456"); }); - it('extracts ipAddress from metadata using OTLP attribute keys', () => { + it("extracts ipAddress from metadata using OTLP attribute keys", () => { const result = parseSimpleIngestRequest({ ...validEntry, - metadata: { 'client.address': '192.168.1.1' }, + metadata: { "client.address": "192.168.1.1" }, }); - expect(result.records[0]!.ipAddress).toBe('192.168.1.1'); + expect(result.records[0]!.ipAddress).toBe("192.168.1.1"); }); - it('falls back to alternate metadata keys', () => { + it("falls back to alternate metadata keys", () => { const result = parseSimpleIngestRequest({ ...validEntry, - metadata: { request_id: 'req-789', user_id: 'user-999', ip_address: '10.0.0.1' }, + metadata: { request_id: "req-789", user_id: "user-999", ip_address: "10.0.0.1" }, }); - expect(result.records[0]!.requestId).toBe('req-789'); - expect(result.records[0]!.userId).toBe('user-999'); - expect(result.records[0]!.ipAddress).toBe('10.0.0.1'); + expect(result.records[0]!.requestId).toBe("req-789"); + expect(result.records[0]!.userId).toBe("user-999"); + expect(result.records[0]!.ipAddress).toBe("10.0.0.1"); }); - it('returns null for missing metadata fields', () => { + it("returns null for missing metadata fields", () => { const result = parseSimpleIngestRequest(validEntry); expect(result.records[0]!.requestId).toBeNull(); expect(result.records[0]!.userId).toBeNull(); expect(result.records[0]!.ipAddress).toBeNull(); }); - it('returns null for empty metadata', () => { + it("returns null for empty metadata", () => { const result = parseSimpleIngestRequest({ ...validEntry, metadata: {}, @@ -292,13 +292,13 @@ describe('parseSimpleIngestRequest', () => { }); }); - describe('batch processing', () => { - it('correctly counts accepted and rejected', () => { + describe("batch processing", () => { + it("correctly counts accepted and rejected", () => { const entries = [ validEntry, - { level: 'invalid', message: 'bad' }, - { level: 'debug', message: 'good' }, - { message: 'missing level' }, + { level: "invalid", message: "bad" }, + { level: "debug", message: "good" }, + { message: "missing level" }, ]; const result = parseSimpleIngestRequest(entries); expect(result.accepted).toBe(2); @@ -306,54 +306,54 @@ describe('parseSimpleIngestRequest', () => { expect(result.accepted + result.rejected).toBe(entries.length); }); - it('collects all errors', () => { + it("collects all errors", () => { const entries = [ - { level: 'invalid', message: 'bad' }, - { message: 'missing level' }, - { level: 'info' }, + { level: "invalid", message: "bad" }, + { message: "missing level" }, + { level: "info" }, ]; const result = parseSimpleIngestRequest(entries); expect(result.errors).toHaveLength(3); }); - it('processes valid entries despite errors in batch', () => { + it("processes valid entries despite errors in batch", () => { const entries = [ validEntry, - { level: 'invalid', message: 'bad' }, - { level: 'debug', message: 'good' }, + { level: "invalid", message: "bad" }, + { level: "debug", message: "good" }, ]; const result = parseSimpleIngestRequest(entries); expect(result.records).toHaveLength(2); - expect(result.records[0]!.message).toBe('test message'); - expect(result.records[1]!.message).toBe('good'); + expect(result.records[0]!.message).toBe("test message"); + expect(result.records[1]!.message).toBe("good"); }); - it('includes index in error messages', () => { - const entries = [validEntry, validEntry, { level: 'invalid', message: 'bad' }]; + it("includes index in error messages", () => { + const entries = [validEntry, validEntry, { level: "invalid", message: "bad" }]; const result = parseSimpleIngestRequest(entries); - expect(result.errors[0]!).toContain('index 2'); + expect(result.errors[0]!).toContain("index 2"); }); }); }); -describe('SimpleIngestError', () => { - it('has correct name', () => { - const error = new SimpleIngestError('test message'); - expect(error.name).toBe('SimpleIngestError'); +describe("SimpleIngestError", () => { + it("has correct name", () => { + const error = new SimpleIngestError("test message"); + expect(error.name).toBe("SimpleIngestError"); }); - it('has correct message', () => { - const error = new SimpleIngestError('test message'); - expect(error.message).toBe('test message'); + it("has correct message", () => { + const error = new SimpleIngestError("test message"); + expect(error.message).toBe("test message"); }); - it('is instance of Error', () => { - const error = new SimpleIngestError('test'); + it("is instance of Error", () => { + const error = new SimpleIngestError("test"); expect(error).toBeInstanceOf(Error); }); - it('is instance of SimpleIngestError', () => { - const error = new SimpleIngestError('test'); + it("is instance of SimpleIngestError", () => { + const error = new SimpleIngestError("test"); expect(error).toBeInstanceOf(SimpleIngestError); }); }); diff --git a/src/lib/shared/schemas/incident.ts b/src/lib/shared/schemas/incident.ts index 772cd93..0cdf5a2 100644 --- a/src/lib/shared/schemas/incident.ts +++ b/src/lib/shared/schemas/incident.ts @@ -1,10 +1,10 @@ -import { z } from 'zod'; -import type { LogLevel } from './log'; +import { z } from "zod"; +import type { LogLevel } from "./log"; /** * Valid incident status values. */ -export const INCIDENT_STATUSES = ['open', 'resolved'] as const; +export const INCIDENT_STATUSES = ["open", "resolved"] as const; const incidentStatusSchema = z.enum(INCIDENT_STATUSES); @@ -16,7 +16,7 @@ export type IncidentStatus = z.infer; /** * Supported incident range filters. */ -export const INCIDENT_RANGES = ['15m', '1h', '24h', '7d'] as const; +export const INCIDENT_RANGES = ["15m", "1h", "24h", "7d"] as const; const incidentRangeSchema = z.enum(INCIDENT_RANGES); @@ -78,7 +78,7 @@ export interface IncidentTimelineResponse { peakBucket: IncidentTimelinePoint | null; } -const INCIDENT_GROUPED_LEVELS: readonly LogLevel[] = ['error', 'fatal'] as const; +const INCIDENT_GROUPED_LEVELS: readonly LogLevel[] = ["error", "fatal"] as const; /** * Type guard for grouped levels. diff --git a/src/lib/shared/schemas/log-level.unit.test.ts b/src/lib/shared/schemas/log-level.unit.test.ts index 3716d03..87b5f64 100644 --- a/src/lib/shared/schemas/log-level.unit.test.ts +++ b/src/lib/shared/schemas/log-level.unit.test.ts @@ -1,16 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { LOG_LEVELS, logLevelSchema } from './log'; +import { describe, expect, it } from "vite-plus/test"; +import { LOG_LEVELS, logLevelSchema } from "./log"; -describe('logLevelSchema', () => { - it('accepts all valid log levels', () => { +describe("logLevelSchema", () => { + it("accepts all valid log levels", () => { for (const level of LOG_LEVELS) { const result = logLevelSchema.safeParse(level); expect(result.success).toBe(true); } }); - it('rejects invalid log level', () => { - const result = logLevelSchema.safeParse('invalid'); + it("rejects invalid log level", () => { + const result = logLevelSchema.safeParse("invalid"); expect(result.success).toBe(false); }); }); diff --git a/src/lib/shared/schemas/log.ts b/src/lib/shared/schemas/log.ts index cc929b7..2334977 100644 --- a/src/lib/shared/schemas/log.ts +++ b/src/lib/shared/schemas/log.ts @@ -1,9 +1,9 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Valid log levels */ -export const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'fatal'] as const; +export const LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"] as const; /** * Log level schema diff --git a/src/lib/shared/schemas/project.ts b/src/lib/shared/schemas/project.ts index 7046f29..585fe6f 100644 --- a/src/lib/shared/schemas/project.ts +++ b/src/lib/shared/schemas/project.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Project name regex pattern @@ -17,11 +17,11 @@ const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; export const projectCreatePayloadSchema = z.object({ name: z .string() - .min(1, 'Project name cannot be empty') - .max(50, 'Project name cannot exceed 50 characters') + .min(1, "Project name cannot be empty") + .max(50, "Project name cannot exceed 50 characters") .regex( PROJECT_NAME_PATTERN, - 'Project name must contain only alphanumeric characters, hyphens, and underscores', + "Project name must contain only alphanumeric characters, hyphens, and underscores", ), }); @@ -37,11 +37,11 @@ export const projectCreatePayloadSchema = z.object({ export const projectUpdatePayloadSchema = z.object({ name: z .string() - .min(1, 'Project name cannot be empty') - .max(50, 'Project name cannot exceed 50 characters') + .min(1, "Project name cannot be empty") + .max(50, "Project name cannot exceed 50 characters") .regex( PROJECT_NAME_PATTERN, - 'Project name must contain only alphanumeric characters, hyphens, and underscores', + "Project name must contain only alphanumeric characters, hyphens, and underscores", ) .optional(), retentionDays: z diff --git a/src/lib/shared/schemas/project.unit.test.ts b/src/lib/shared/schemas/project.unit.test.ts index a39028e..069de88 100644 --- a/src/lib/shared/schemas/project.unit.test.ts +++ b/src/lib/shared/schemas/project.unit.test.ts @@ -1,94 +1,94 @@ -import { describe, expect, it } from 'vitest'; -import { projectCreatePayloadSchema, projectUpdatePayloadSchema } from './project'; +import { describe, expect, it } from "vite-plus/test"; +import { projectCreatePayloadSchema, projectUpdatePayloadSchema } from "./project"; -describe('projectCreatePayloadSchema', () => { - it('should accept valid project name', () => { +describe("projectCreatePayloadSchema", () => { + it("should accept valid project name", () => { const payload = { - name: 'my-project', + name: "my-project", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('my-project'); + expect(result.data.name).toBe("my-project"); } }); - it('should reject empty project name', () => { + it("should reject empty project name", () => { const payload = { - name: '', + name: "", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(false); }); - it('should reject project name over 50 characters', () => { + it("should reject project name over 50 characters", () => { const payload = { - name: 'a'.repeat(51), + name: "a".repeat(51), }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(false); }); - it('should accept project name with hyphens', () => { + it("should accept project name with hyphens", () => { const payload = { - name: 'my-awesome-project', + name: "my-awesome-project", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('my-awesome-project'); + expect(result.data.name).toBe("my-awesome-project"); } }); - it('should accept project name with underscores', () => { + it("should accept project name with underscores", () => { const payload = { - name: 'my_awesome_project', + name: "my_awesome_project", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('my_awesome_project'); + expect(result.data.name).toBe("my_awesome_project"); } }); - it('should reject project name with special characters', () => { + it("should reject project name with special characters", () => { const payload = { - name: 'my-project@123', + name: "my-project@123", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(false); }); - it('should reject project name with spaces', () => { + it("should reject project name with spaces", () => { const payload = { - name: 'my project', + name: "my project", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(false); }); - it('should accept single character project name', () => { + it("should accept single character project name", () => { const payload = { - name: 'a', + name: "a", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('a'); + expect(result.data.name).toBe("a"); } }); - it('should accept project name with exactly 50 characters', () => { + it("should accept project name with exactly 50 characters", () => { const payload = { - name: 'a'.repeat(50), + name: "a".repeat(50), }; const result = projectCreatePayloadSchema.safeParse(payload); @@ -98,21 +98,21 @@ describe('projectCreatePayloadSchema', () => { } }); - it('should accept alphanumeric project name', () => { + it("should accept alphanumeric project name", () => { const payload = { - name: 'project123', + name: "project123", }; const result = projectCreatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('project123'); + expect(result.data.name).toBe("project123"); } }); }); -describe('projectUpdatePayloadSchema with retentionDays', () => { - it('should accept null (system default)', () => { +describe("projectUpdatePayloadSchema with retentionDays", () => { + it("should accept null (system default)", () => { const payload = { retentionDays: null, }; @@ -124,7 +124,7 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { } }); - it('should accept 0 (never delete)', () => { + it("should accept 0 (never delete)", () => { const payload = { retentionDays: 0, }; @@ -136,7 +136,7 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { } }); - it('should accept positive integers 1-3650', () => { + it("should accept positive integers 1-3650", () => { const testCases = [1, 30, 365, 1000, 3650]; for (const days of testCases) { @@ -152,7 +152,7 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { } }); - it('should reject negative numbers', () => { + it("should reject negative numbers", () => { const payload = { retentionDays: -1, }; @@ -161,7 +161,7 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { expect(result.success).toBe(false); }); - it('should reject non-integers (e.g., 3.5)', () => { + it("should reject non-integers (e.g., 3.5)", () => { const payload = { retentionDays: 3.5, }; @@ -170,7 +170,7 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { expect(result.success).toBe(false); }); - it('should reject values > 3650', () => { + it("should reject values > 3650", () => { const payload = { retentionDays: 3651, }; @@ -179,9 +179,9 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { expect(result.success).toBe(false); }); - it('should allow omitting retentionDays (optional field)', () => { + it("should allow omitting retentionDays (optional field)", () => { const payload = { - name: 'updated-project', + name: "updated-project", }; const result = projectUpdatePayloadSchema.safeParse(payload); @@ -191,16 +191,16 @@ describe('projectUpdatePayloadSchema with retentionDays', () => { } }); - it('should allow both name and retentionDays together', () => { + it("should allow both name and retentionDays together", () => { const payload = { - name: 'updated-project', + name: "updated-project", retentionDays: 30, }; const result = projectUpdatePayloadSchema.safeParse(payload); expect(result.success).toBe(true); if (result.success) { - expect(result.data.name).toBe('updated-project'); + expect(result.data.name).toBe("updated-project"); expect(result.data.retentionDays).toBe(30); } }); diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index 79b3dab..07521c1 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -12,5 +12,5 @@ export { type IncidentTimelineResponse, isIncidentGroupedLevel, maxIncidentLevel, -} from './schemas/incident'; -export { LOG_LEVELS, type LogLevel } from './schemas/log'; +} from "./schemas/incident"; +export { LOG_LEVELS, type LogLevel } from "./schemas/log"; diff --git a/src/lib/stores/__tests__/logs.unit.test.ts b/src/lib/stores/__tests__/logs.unit.test.ts index e382962..f1ce1a9 100644 --- a/src/lib/stores/__tests__/logs.unit.test.ts +++ b/src/lib/stores/__tests__/logs.unit.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, it } from 'vitest'; -import type { ClientLog } from '../logs.svelte'; +import { describe, expect, it } from "vite-plus/test"; +import type { ClientLog } from "../logs.svelte"; -describe('ClientLog type', () => { - it('can create a ClientLog-shaped object', () => { +describe("ClientLog type", () => { + it("can create a ClientLog-shaped object", () => { const log: ClientLog = { - id: 'log-1', - projectId: 'project-1', - level: 'info', - message: 'Test log message', + id: "log-1", + projectId: "project-1", + level: "info", + message: "Test log message", metadata: null, incidentId: null, fingerprint: null, @@ -19,7 +19,7 @@ describe('ClientLog type', () => { ipAddress: null, timestamp: new Date().toISOString(), }; - expect(log.id).toBe('log-1'); - expect(log.level).toBe('info'); + expect(log.id).toBe("log-1"); + expect(log.level).toBe("info"); }); }); diff --git a/src/lib/stores/logs.svelte.ts b/src/lib/stores/logs.svelte.ts index fcf07c8..9b094b2 100644 --- a/src/lib/stores/logs.svelte.ts +++ b/src/lib/stores/logs.svelte.ts @@ -1,4 +1,4 @@ -import type { LogLevel } from '$lib/shared/types'; +import type { LogLevel } from "$lib/shared/types"; /** * Client-side log representation for UI rendering diff --git a/src/lib/types/export.ts b/src/lib/types/export.ts index 446d6f6..79bf67e 100644 --- a/src/lib/types/export.ts +++ b/src/lib/types/export.ts @@ -1,4 +1,4 @@ -export type ExportFormat = 'csv' | 'json'; +export type ExportFormat = "csv" | "json"; export interface ExportableLog { id: string; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a56879a..8345588 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,13 +1,13 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // biome-ignore lint/suspicious/noExplicitAny: Required for conditional type check on arbitrary children prop -export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildren = T extends { children?: any } ? Omit : T; // biome-ignore lint/suspicious/noExplicitAny: Required for conditional type check on arbitrary child prop -export type WithoutChild = T extends { child?: any } ? Omit : T; +export type WithoutChild = T extends { child?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = T & { ref?: U | null }; diff --git a/src/lib/utils.unit.test.ts b/src/lib/utils.unit.test.ts index b201af0..1b5918a 100644 --- a/src/lib/utils.unit.test.ts +++ b/src/lib/utils.unit.test.ts @@ -1,12 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; -describe('Example Unit Test', () => { - it('should pass basic assertion', () => { +describe("Example Unit Test", () => { + it("should pass basic assertion", () => { expect(1 + 1).toBe(2); }); - it('should handle strings', () => { - const greeting = 'Hello, Logwell!'; - expect(greeting).toContain('Logwell'); + it("should handle strings", () => { + const greeting = "Hello, Logwell!"; + expect(greeting).toContain("Logwell"); }); }); diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts index be90656..94f0a5c 100644 --- a/src/lib/utils/colors.ts +++ b/src/lib/utils/colors.ts @@ -1,4 +1,4 @@ -import type { LogLevel } from '$lib/server/db/schema'; +import type { LogLevel } from "$lib/server/db/schema"; /** * Returns the HSL color string for a given log level @@ -7,16 +7,16 @@ import type { LogLevel } from '$lib/server/db/schema'; */ export function getLevelColor(level: LogLevel): string { switch (level) { - case 'debug': - return 'hsl(215, 15%, 50%)'; - case 'info': - return 'hsl(210, 100%, 50%)'; - case 'warn': - return 'hsl(45, 100%, 50%)'; - case 'error': - return 'hsl(0, 85%, 55%)'; - case 'fatal': - return 'hsl(270, 70%, 55%)'; + case "debug": + return "hsl(215, 15%, 50%)"; + case "info": + return "hsl(210, 100%, 50%)"; + case "warn": + return "hsl(45, 100%, 50%)"; + case "error": + return "hsl(0, 85%, 55%)"; + case "fatal": + return "hsl(270, 70%, 55%)"; } } @@ -27,15 +27,15 @@ export function getLevelColor(level: LogLevel): string { */ export function getLevelBgClass(level: LogLevel): string { switch (level) { - case 'debug': - return 'bg-slate-500/20'; - case 'info': - return 'bg-blue-500/20'; - case 'warn': - return 'bg-amber-500/20'; - case 'error': - return 'bg-red-500/20'; - case 'fatal': - return 'bg-purple-500/20'; + case "debug": + return "bg-slate-500/20"; + case "info": + return "bg-blue-500/20"; + case "warn": + return "bg-amber-500/20"; + case "error": + return "bg-red-500/20"; + case "fatal": + return "bg-purple-500/20"; } } diff --git a/src/lib/utils/colors.unit.test.ts b/src/lib/utils/colors.unit.test.ts index 12a72ed..cd467d0 100644 --- a/src/lib/utils/colors.unit.test.ts +++ b/src/lib/utils/colors.unit.test.ts @@ -1,33 +1,33 @@ -import { getLevelBgClass, getLevelColor } from './colors'; +import { getLevelBgClass, getLevelColor } from "./colors"; -describe('getLevelColor', () => { +describe("getLevelColor", () => { it.each([ - ['debug', 'hsl(215, 15%, 50%)'], - ['info', 'hsl(210, 100%, 50%)'], - ['warn', 'hsl(45, 100%, 50%)'], - ['error', 'hsl(0, 85%, 55%)'], - ['fatal', 'hsl(270, 70%, 55%)'], - ] as const)('returns %s for %s level', (level, expected) => { + ["debug", "hsl(215, 15%, 50%)"], + ["info", "hsl(210, 100%, 50%)"], + ["warn", "hsl(45, 100%, 50%)"], + ["error", "hsl(0, 85%, 55%)"], + ["fatal", "hsl(270, 70%, 55%)"], + ] as const)("returns %s for %s level", (level, expected) => { const result = getLevelColor(level); expect(result).toBe(expected); - expect(typeof result).toBe('string'); + expect(typeof result).toBe("string"); expect(result).toMatch(/^hsl\(\d+,\s*\d+%,\s*\d+%\)$/); }); }); -describe('getLevelBgClass', () => { +describe("getLevelBgClass", () => { it.each([ - ['debug', 'bg-slate-500/20', 'slate'], - ['info', 'bg-blue-500/20', 'blue'], - ['warn', 'bg-amber-500/20', 'amber'], - ['error', 'bg-red-500/20', 'red'], - ['fatal', 'bg-purple-500/20', 'purple'], - ] as const)('returns %s for %s level with semantic color %s', (level, expected, colorFamily) => { + ["debug", "bg-slate-500/20", "slate"], + ["info", "bg-blue-500/20", "blue"], + ["warn", "bg-amber-500/20", "amber"], + ["error", "bg-red-500/20", "red"], + ["fatal", "bg-purple-500/20", "purple"], + ] as const)("returns %s for %s level with semantic color %s", (level, expected, colorFamily) => { const result = getLevelBgClass(level); expect(result).toBe(expected); - expect(typeof result).toBe('string'); + expect(typeof result).toBe("string"); expect(result).toMatch(/^bg-[\w-]+\/\d+$/); - expect(result).toContain('/20'); + expect(result).toContain("/20"); expect(result).toContain(colorFamily); }); }); diff --git a/src/lib/utils/focus-trap.ts b/src/lib/utils/focus-trap.ts index 1624579..86634f9 100644 --- a/src/lib/utils/focus-trap.ts +++ b/src/lib/utils/focus-trap.ts @@ -25,13 +25,13 @@ export interface FocusTrapOptions { } const FOCUSABLE_SELECTOR = [ - 'button:not([disabled])', - '[href]', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', + "button:not([disabled])", + "[href]", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", '[tabindex]:not([tabindex="-1"])', -].join(', '); +].join(", "); /** * Gets all focusable elements within a container. @@ -41,7 +41,7 @@ function getFocusableElements(container: HTMLElement): HTMLElement[] { return Array.from(elements).filter((el) => { // Check if element is visible const style = window.getComputedStyle(el); - return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null; + return style.display !== "none" && style.visibility !== "hidden" && el.offsetParent !== null; }); } @@ -56,7 +56,7 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) const previouslyFocused = (returnFocus || document.activeElement) as HTMLElement | null; function handleKeyDown(event: KeyboardEvent) { - if (event.key !== 'Tab') return; + if (event.key !== "Tab") return; const focusableElements = getFocusableElements(container); if (focusableElements.length === 0) return; @@ -90,7 +90,7 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) let elementToFocus: HTMLElement | null = null; - if (typeof initialFocus === 'string') { + if (typeof initialFocus === "string") { elementToFocus = container.querySelector(initialFocus); } else if (initialFocus instanceof HTMLElement) { elementToFocus = initialFocus; @@ -109,7 +109,7 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) } // Activate the trap - container.addEventListener('keydown', handleKeyDown); + container.addEventListener("keydown", handleKeyDown); setInitialFocus(); // Return cleanup and restore focus functions @@ -118,8 +118,8 @@ function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}) * Deactivates the focus trap and restores focus to the previously focused element. */ deactivate() { - container.removeEventListener('keydown', handleKeyDown); - if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + container.removeEventListener("keydown", handleKeyDown); + if (previouslyFocused && typeof previouslyFocused.focus === "function") { previouslyFocused.focus(); } }, @@ -151,27 +151,27 @@ export function focusTrap(node: HTMLElement, options: FocusTrapOptions = {}) { */ export function announceToScreenReader( message: string, - priority: 'polite' | 'assertive' = 'polite', + priority: "polite" | "assertive" = "polite", ) { // Look for existing live region or create one - let liveRegion = document.getElementById('sr-announcer'); + let liveRegion = document.getElementById("sr-announcer"); if (!liveRegion) { - liveRegion = document.createElement('div'); - liveRegion.id = 'sr-announcer'; - liveRegion.setAttribute('aria-live', priority); - liveRegion.setAttribute('aria-atomic', 'true'); - liveRegion.className = 'sr-only'; + liveRegion = document.createElement("div"); + liveRegion.id = "sr-announcer"; + liveRegion.setAttribute("aria-live", priority); + liveRegion.setAttribute("aria-atomic", "true"); + liveRegion.className = "sr-only"; liveRegion.style.cssText = - 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;'; + "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;"; document.body.appendChild(liveRegion); } // Update priority if needed - liveRegion.setAttribute('aria-live', priority); + liveRegion.setAttribute("aria-live", priority); // Clear and set message (necessary for repeat announcements) - liveRegion.textContent = ''; + liveRegion.textContent = ""; requestAnimationFrame(() => { if (liveRegion) { liveRegion.textContent = message; diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index a3d3c3a..2004a78 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -10,10 +10,10 @@ * // Returns: "14:30:45.123" */ export function formatTimestamp(date: Date): string { - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); - const seconds = date.getUTCSeconds().toString().padStart(2, '0'); - const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0'); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); + const seconds = date.getUTCSeconds().toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); return `${hours}:${minutes}:${seconds}.${milliseconds}`; } @@ -39,14 +39,14 @@ export function formatRelativeTime(date: Date, referenceTime?: Date): string { // Handle future dates if (diffMs < 0) { - return 'just now'; + return "just now"; } const diffSeconds = Math.floor(diffMs / 1000); // Less than 5 seconds if (diffSeconds < 5) { - return 'just now'; + return "just now"; } // Less than 60 seconds @@ -58,20 +58,20 @@ export function formatRelativeTime(date: Date, referenceTime?: Date): string { // Less than 60 minutes if (diffMinutes < 60) { - return diffMinutes === 1 ? '1 minute ago' : `${diffMinutes} minutes ago`; + return diffMinutes === 1 ? "1 minute ago" : `${diffMinutes} minutes ago`; } const diffHours = Math.floor(diffMinutes / 60); // Less than 24 hours if (diffHours < 24) { - return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`; + return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`; } const diffDays = Math.floor(diffHours / 24); // 24 hours or more - return diffDays === 1 ? '1 day ago' : `${diffDays} days ago`; + return diffDays === 1 ? "1 day ago" : `${diffDays} days ago`; } /** @@ -88,18 +88,18 @@ export function formatRelativeTime(date: Date, referenceTime?: Date): string { * getTimeRangeStart('7d') * // Returns: Date object 7 days ago */ -export function getTimeRangeStart(range: '15m' | '1h' | '24h' | '7d', referenceTime?: Date): Date { +export function getTimeRangeStart(range: "15m" | "1h" | "24h" | "7d", referenceTime?: Date): Date { const now = referenceTime || new Date(); const nowMs = now.getTime(); switch (range) { - case '15m': + case "15m": return new Date(nowMs - 15 * 60 * 1000); - case '1h': + case "1h": return new Date(nowMs - 60 * 60 * 1000); - case '24h': + case "24h": return new Date(nowMs - 24 * 60 * 60 * 1000); - case '7d': + case "7d": return new Date(nowMs - 7 * 24 * 60 * 60 * 1000); } } @@ -117,12 +117,12 @@ export function getTimeRangeStart(range: '15m' | '1h' | '24h' | '7d', referenceT */ export function formatFullDate(date: Date): string { const year = date.getUTCFullYear(); - const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); - const day = date.getUTCDate().toString().padStart(2, '0'); - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); - const seconds = date.getUTCSeconds().toString().padStart(2, '0'); - const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0'); + const month = (date.getUTCMonth() + 1).toString().padStart(2, "0"); + const day = date.getUTCDate().toString().padStart(2, "0"); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); + const seconds = date.getUTCSeconds().toString().padStart(2, "0"); + const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds} UTC`; } diff --git a/src/lib/utils/format.unit.test.ts b/src/lib/utils/format.unit.test.ts index 411347e..f6c9200 100644 --- a/src/lib/utils/format.unit.test.ts +++ b/src/lib/utils/format.unit.test.ts @@ -1,116 +1,116 @@ -import { formatFullDate, formatRelativeTime, formatTimestamp, getTimeRangeStart } from './format'; +import { formatFullDate, formatRelativeTime, formatTimestamp, getTimeRangeStart } from "./format"; -describe('formatTimestamp', () => { +describe("formatTimestamp", () => { it.each([ - ['2024-01-15T14:30:45.123Z', '14:30:45.123', 'afternoon time'], - ['2024-01-15T09:15:30.456Z', '09:15:30.456', 'morning time'], - ['2024-01-15T00:00:00.000Z', '00:00:00.000', 'midnight'], - ['2024-01-15T12:00:00.000Z', '12:00:00.000', 'noon'], - ['2024-01-15T01:05:08.100Z', '01:05:08.100', 'single-digit hours'], - ['2024-01-15T14:05:45.123Z', '14:05:45.123', 'single-digit minutes'], - ['2024-01-15T14:30:05.123Z', '14:30:05.123', 'single-digit seconds'], - ['2024-01-15T14:30:45.001Z', '14:30:45.001', 'single-digit milliseconds'], - ['2024-01-15T14:30:45.010Z', '14:30:45.010', 'double-digit milliseconds'], - ['2024-01-15T14:30:45.100Z', '14:30:45.100', 'triple-digit milliseconds'], - ['2024-01-15T23:59:59.999Z', '23:59:59.999', 'maximum time values'], - ])('formatTimestamp(%s) returns %s (%s)', (input, expected) => { + ["2024-01-15T14:30:45.123Z", "14:30:45.123", "afternoon time"], + ["2024-01-15T09:15:30.456Z", "09:15:30.456", "morning time"], + ["2024-01-15T00:00:00.000Z", "00:00:00.000", "midnight"], + ["2024-01-15T12:00:00.000Z", "12:00:00.000", "noon"], + ["2024-01-15T01:05:08.100Z", "01:05:08.100", "single-digit hours"], + ["2024-01-15T14:05:45.123Z", "14:05:45.123", "single-digit minutes"], + ["2024-01-15T14:30:05.123Z", "14:30:05.123", "single-digit seconds"], + ["2024-01-15T14:30:45.001Z", "14:30:45.001", "single-digit milliseconds"], + ["2024-01-15T14:30:45.010Z", "14:30:45.010", "double-digit milliseconds"], + ["2024-01-15T14:30:45.100Z", "14:30:45.100", "triple-digit milliseconds"], + ["2024-01-15T23:59:59.999Z", "23:59:59.999", "maximum time values"], + ])("formatTimestamp(%s) returns %s (%s)", (input, expected) => { expect(formatTimestamp(new Date(input))).toBe(expected); }); - it('handles Date object at the start of epoch', () => { + it("handles Date object at the start of epoch", () => { const date = new Date(0); // 1970-01-01T00:00:00.000Z - expect(formatTimestamp(date)).toBe('00:00:00.000'); + expect(formatTimestamp(date)).toBe("00:00:00.000"); }); }); -describe('formatRelativeTime', () => { - const now = new Date('2024-01-15T14:30:45.000Z'); +describe("formatRelativeTime", () => { + const now = new Date("2024-01-15T14:30:45.000Z"); - describe('seconds range', () => { + describe("seconds range", () => { it.each([ - [0, 'just now', 'current time'], - [4 * 1000, 'just now', 'less than 5 seconds ago'], - [5 * 1000, '5 seconds ago', '5 seconds ago'], - [30 * 1000, '30 seconds ago', '30 seconds ago'], - [59 * 1000, '59 seconds ago', '59 seconds ago'], + [0, "just now", "current time"], + [4 * 1000, "just now", "less than 5 seconds ago"], + [5 * 1000, "5 seconds ago", "5 seconds ago"], + [30 * 1000, "30 seconds ago", "30 seconds ago"], + [59 * 1000, "59 seconds ago", "59 seconds ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); expect(formatRelativeTime(date, now)).toBe(expected); }); }); - describe('minutes range', () => { + describe("minutes range", () => { it.each([ - [60 * 1000, '1 minute ago', '60 seconds ago'], - [2 * 60 * 1000, '2 minutes ago', '2 minutes ago'], - [15 * 60 * 1000, '15 minutes ago', '15 minutes ago'], - [59 * 60 * 1000, '59 minutes ago', '59 minutes ago'], + [60 * 1000, "1 minute ago", "60 seconds ago"], + [2 * 60 * 1000, "2 minutes ago", "2 minutes ago"], + [15 * 60 * 1000, "15 minutes ago", "15 minutes ago"], + [59 * 60 * 1000, "59 minutes ago", "59 minutes ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); expect(formatRelativeTime(date, now)).toBe(expected); }); }); - describe('hours range', () => { + describe("hours range", () => { it.each([ - [60 * 60 * 1000, '1 hour ago', '60 minutes ago'], - [2 * 60 * 60 * 1000, '2 hours ago', '2 hours ago'], - [12 * 60 * 60 * 1000, '12 hours ago', '12 hours ago'], - [23 * 60 * 60 * 1000, '23 hours ago', '23 hours ago'], + [60 * 60 * 1000, "1 hour ago", "60 minutes ago"], + [2 * 60 * 60 * 1000, "2 hours ago", "2 hours ago"], + [12 * 60 * 60 * 1000, "12 hours ago", "12 hours ago"], + [23 * 60 * 60 * 1000, "23 hours ago", "23 hours ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); expect(formatRelativeTime(date, now)).toBe(expected); }); }); - describe('days range', () => { + describe("days range", () => { it.each([ - [24 * 60 * 60 * 1000, '1 day ago', '24 hours ago'], - [2 * 24 * 60 * 60 * 1000, '2 days ago', '2 days ago'], - [7 * 24 * 60 * 60 * 1000, '7 days ago', '7 days ago'], - [30 * 24 * 60 * 60 * 1000, '30 days ago', '30 days ago'], + [24 * 60 * 60 * 1000, "1 day ago", "24 hours ago"], + [2 * 24 * 60 * 60 * 1000, "2 days ago", "2 days ago"], + [7 * 24 * 60 * 60 * 1000, "7 days ago", "7 days ago"], + [30 * 24 * 60 * 60 * 1000, "30 days ago", "30 days ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); expect(formatRelativeTime(date, now)).toBe(expected); }); }); - describe('edge cases', () => { - it('handles future dates gracefully', () => { + describe("edge cases", () => { + it("handles future dates gracefully", () => { const futureDate = new Date(now.getTime() + 5 * 60 * 1000); // Should either return "just now" or a negative indication const result = formatRelativeTime(futureDate, now); expect(result).toBeDefined(); - expect(typeof result).toBe('string'); + expect(typeof result).toBe("string"); }); - it('uses current time when reference time is not provided', () => { + it("uses current time when reference time is not provided", () => { const pastDate = new Date(Date.now() - 10 * 1000); const result = formatRelativeTime(pastDate); expect(result).toBeDefined(); - expect(typeof result).toBe('string'); + expect(typeof result).toBe("string"); }); }); }); -describe('getTimeRangeStart', () => { - const now = new Date('2024-01-15T14:30:45.123Z'); +describe("getTimeRangeStart", () => { + const now = new Date("2024-01-15T14:30:45.123Z"); it.each([ - ['15m', 15 * 60 * 1000, '15 minutes'], - ['1h', 60 * 60 * 1000, '1 hour'], - ['24h', 24 * 60 * 60 * 1000, '24 hours'], - ['7d', 7 * 24 * 60 * 60 * 1000, '7 days'], - ])('getTimeRangeStart(%s) returns Date %i ms before reference time (%s)', (range, offset) => { - const result = getTimeRangeStart(range as '15m' | '1h' | '24h' | '7d', now); + ["15m", 15 * 60 * 1000, "15 minutes"], + ["1h", 60 * 60 * 1000, "1 hour"], + ["24h", 24 * 60 * 60 * 1000, "24 hours"], + ["7d", 7 * 24 * 60 * 60 * 1000, "7 days"], + ])("getTimeRangeStart(%s) returns Date %i ms before reference time (%s)", (range, offset) => { + const result = getTimeRangeStart(range as "15m" | "1h" | "24h" | "7d", now); const expected = new Date(now.getTime() - offset); expect(result).toEqual(expected); expect(result.getTime()).toBe(now.getTime() - offset); }); - it('uses current time when reference time is not provided', () => { + it("uses current time when reference time is not provided", () => { const before = Date.now(); - const result = getTimeRangeStart('1h'); + const result = getTimeRangeStart("1h"); const after = Date.now(); // Result should be approximately 1 hour before now @@ -118,37 +118,37 @@ describe('getTimeRangeStart', () => { expect(result.getTime()).toBeLessThanOrEqual(after - 60 * 60 * 1000); }); - it('returns a Date object for all valid ranges', () => { - expect(getTimeRangeStart('15m', now)).toBeInstanceOf(Date); - expect(getTimeRangeStart('1h', now)).toBeInstanceOf(Date); - expect(getTimeRangeStart('24h', now)).toBeInstanceOf(Date); - expect(getTimeRangeStart('7d', now)).toBeInstanceOf(Date); + it("returns a Date object for all valid ranges", () => { + expect(getTimeRangeStart("15m", now)).toBeInstanceOf(Date); + expect(getTimeRangeStart("1h", now)).toBeInstanceOf(Date); + expect(getTimeRangeStart("24h", now)).toBeInstanceOf(Date); + expect(getTimeRangeStart("7d", now)).toBeInstanceOf(Date); }); - it('preserves millisecond precision', () => { - const result = getTimeRangeStart('1h', now); + it("preserves millisecond precision", () => { + const result = getTimeRangeStart("1h", now); // If now has milliseconds, the result should maintain them expect(result.getMilliseconds()).toBe(now.getMilliseconds()); }); }); -describe('formatFullDate', () => { +describe("formatFullDate", () => { it.each([ - ['2024-01-15T14:30:45.123Z', '2024-01-15 14:30:45.123 UTC', 'afternoon time'], - ['2024-06-20T08:15:30.456Z', '2024-06-20 08:15:30.456 UTC', 'morning time'], - ['2024-01-01T00:00:00.000Z', '2024-01-01 00:00:00.000 UTC', 'midnight'], - ['2024-12-31T23:59:59.999Z', '2024-12-31 23:59:59.999 UTC', 'end of year'], - ['2024-01-15T14:30:45.123Z', '2024-01-15 14:30:45.123 UTC', 'single-digit month'], - ['2024-01-05T14:30:45.123Z', '2024-01-05 14:30:45.123 UTC', 'single-digit day'], - ['2024-11-20T14:30:45.123Z', '2024-11-20 14:30:45.123 UTC', 'double-digit month'], - ['2024-01-15T14:30:45.001Z', '2024-01-15 14:30:45.001 UTC', 'single-digit milliseconds'], - ['2024-01-15T14:30:45.010Z', '2024-01-15 14:30:45.010 UTC', 'double-digit milliseconds'], - ])('formatFullDate(%s) returns %s (%s)', (input, expected) => { + ["2024-01-15T14:30:45.123Z", "2024-01-15 14:30:45.123 UTC", "afternoon time"], + ["2024-06-20T08:15:30.456Z", "2024-06-20 08:15:30.456 UTC", "morning time"], + ["2024-01-01T00:00:00.000Z", "2024-01-01 00:00:00.000 UTC", "midnight"], + ["2024-12-31T23:59:59.999Z", "2024-12-31 23:59:59.999 UTC", "end of year"], + ["2024-01-15T14:30:45.123Z", "2024-01-15 14:30:45.123 UTC", "single-digit month"], + ["2024-01-05T14:30:45.123Z", "2024-01-05 14:30:45.123 UTC", "single-digit day"], + ["2024-11-20T14:30:45.123Z", "2024-11-20 14:30:45.123 UTC", "double-digit month"], + ["2024-01-15T14:30:45.001Z", "2024-01-15 14:30:45.001 UTC", "single-digit milliseconds"], + ["2024-01-15T14:30:45.010Z", "2024-01-15 14:30:45.010 UTC", "double-digit milliseconds"], + ])("formatFullDate(%s) returns %s (%s)", (input, expected) => { expect(formatFullDate(new Date(input))).toBe(expected); }); - it('handles epoch start', () => { + it("handles epoch start", () => { const date = new Date(0); // 1970-01-01T00:00:00.000Z - expect(formatFullDate(date)).toBe('1970-01-01 00:00:00.000 UTC'); + expect(formatFullDate(date)).toBe("1970-01-01 00:00:00.000 UTC"); }); }); diff --git a/src/lib/utils/keyboard.ts b/src/lib/utils/keyboard.ts index 9774616..c09f848 100644 --- a/src/lib/utils/keyboard.ts +++ b/src/lib/utils/keyboard.ts @@ -6,7 +6,7 @@ /** * Form elements that should block keyboard shortcuts when focused. */ -export const FORM_ELEMENTS = ['INPUT', 'TEXTAREA', 'SELECT'] as const; +export const FORM_ELEMENTS = ["INPUT", "TEXTAREA", "SELECT"] as const; /** * Shortcut definition for the help modal. @@ -14,7 +14,7 @@ export const FORM_ELEMENTS = ['INPUT', 'TEXTAREA', 'SELECT'] as const; interface ShortcutDefinition { key: string; description: string; - group: 'navigation' | 'search' | 'other'; + group: "navigation" | "search" | "other"; } /** @@ -23,17 +23,17 @@ interface ShortcutDefinition { */ export const SHORTCUTS: ShortcutDefinition[] = [ // Navigation shortcuts - { key: 'j', description: 'Select next log', group: 'navigation' }, - { key: 'k', description: 'Select previous log', group: 'navigation' }, - { key: 'Enter', description: 'Open log details', group: 'navigation' }, + { key: "j", description: "Select next log", group: "navigation" }, + { key: "k", description: "Select previous log", group: "navigation" }, + { key: "Enter", description: "Open log details", group: "navigation" }, // Search shortcuts - { key: '/', description: 'Focus search', group: 'search' }, - { key: 'Esc', description: 'Blur search / Close modal', group: 'search' }, + { key: "/", description: "Focus search", group: "search" }, + { key: "Esc", description: "Blur search / Close modal", group: "search" }, // Other shortcuts - { key: 'l', description: 'Toggle live mode', group: 'other' }, - { key: '?', description: 'Show keyboard shortcuts', group: 'other' }, + { key: "l", description: "Toggle live mode", group: "other" }, + { key: "?", description: "Show keyboard shortcuts", group: "other" }, ]; /** diff --git a/src/lib/utils/keyboard.unit.test.ts b/src/lib/utils/keyboard.unit.test.ts index dc24361..b0ee4d4 100644 --- a/src/lib/utils/keyboard.unit.test.ts +++ b/src/lib/utils/keyboard.unit.test.ts @@ -1,4 +1,4 @@ -import { FORM_ELEMENTS, SHORTCUTS, shouldBlockShortcut } from './keyboard'; +import { FORM_ELEMENTS, SHORTCUTS, shouldBlockShortcut } from "./keyboard"; /** * Helper to create a mock KeyboardEvent with customizable properties. @@ -13,7 +13,7 @@ function createMockKeyboardEvent(options: { metaKey?: boolean; }): KeyboardEvent { const { - targetTagName = 'DIV', + targetTagName = "DIV", isComposing = false, ctrlKey = false, altKey = false, @@ -32,77 +32,77 @@ function createMockKeyboardEvent(options: { } as unknown as KeyboardEvent; } -describe('shouldBlockShortcut', () => { - describe('returns true for form elements', () => { - it('blocks shortcuts when target is INPUT element', () => { - const event = createMockKeyboardEvent({ targetTagName: 'INPUT' }); +describe("shouldBlockShortcut", () => { + describe("returns true for form elements", () => { + it("blocks shortcuts when target is INPUT element", () => { + const event = createMockKeyboardEvent({ targetTagName: "INPUT" }); expect(shouldBlockShortcut(event)).toBe(true); }); - it('blocks shortcuts when target is TEXTAREA element', () => { - const event = createMockKeyboardEvent({ targetTagName: 'TEXTAREA' }); + it("blocks shortcuts when target is TEXTAREA element", () => { + const event = createMockKeyboardEvent({ targetTagName: "TEXTAREA" }); expect(shouldBlockShortcut(event)).toBe(true); }); - it('blocks shortcuts when target is SELECT element', () => { - const event = createMockKeyboardEvent({ targetTagName: 'SELECT' }); + it("blocks shortcuts when target is SELECT element", () => { + const event = createMockKeyboardEvent({ targetTagName: "SELECT" }); expect(shouldBlockShortcut(event)).toBe(true); }); }); - describe('returns true for IME composition', () => { - it('blocks shortcuts when event.isComposing is true', () => { + describe("returns true for IME composition", () => { + it("blocks shortcuts when event.isComposing is true", () => { const event = createMockKeyboardEvent({ isComposing: true }); expect(shouldBlockShortcut(event)).toBe(true); }); }); - describe('returns true for modifier keys', () => { - it('blocks shortcuts when ctrlKey is pressed', () => { + describe("returns true for modifier keys", () => { + it("blocks shortcuts when ctrlKey is pressed", () => { const event = createMockKeyboardEvent({ ctrlKey: true }); expect(shouldBlockShortcut(event)).toBe(true); }); - it('blocks shortcuts when altKey is pressed', () => { + it("blocks shortcuts when altKey is pressed", () => { const event = createMockKeyboardEvent({ altKey: true }); expect(shouldBlockShortcut(event)).toBe(true); }); - it('blocks shortcuts when metaKey is pressed', () => { + it("blocks shortcuts when metaKey is pressed", () => { const event = createMockKeyboardEvent({ metaKey: true }); expect(shouldBlockShortcut(event)).toBe(true); }); - it('blocks shortcuts when multiple modifier keys are pressed', () => { + it("blocks shortcuts when multiple modifier keys are pressed", () => { const event = createMockKeyboardEvent({ ctrlKey: true, altKey: true }); expect(shouldBlockShortcut(event)).toBe(true); }); }); - describe('returns false for regular elements without modifiers', () => { - it('allows shortcuts for regular DIV target', () => { - const event = createMockKeyboardEvent({ targetTagName: 'DIV' }); + describe("returns false for regular elements without modifiers", () => { + it("allows shortcuts for regular DIV target", () => { + const event = createMockKeyboardEvent({ targetTagName: "DIV" }); expect(shouldBlockShortcut(event)).toBe(false); }); - it('allows shortcuts for TABLE target', () => { - const event = createMockKeyboardEvent({ targetTagName: 'TABLE' }); + it("allows shortcuts for TABLE target", () => { + const event = createMockKeyboardEvent({ targetTagName: "TABLE" }); expect(shouldBlockShortcut(event)).toBe(false); }); - it('allows shortcuts for BUTTON target', () => { - const event = createMockKeyboardEvent({ targetTagName: 'BUTTON' }); + it("allows shortcuts for BUTTON target", () => { + const event = createMockKeyboardEvent({ targetTagName: "BUTTON" }); expect(shouldBlockShortcut(event)).toBe(false); }); - it('allows shortcuts for BODY target', () => { - const event = createMockKeyboardEvent({ targetTagName: 'BODY' }); + it("allows shortcuts for BODY target", () => { + const event = createMockKeyboardEvent({ targetTagName: "BODY" }); expect(shouldBlockShortcut(event)).toBe(false); }); }); - describe('edge cases', () => { - it('handles null target gracefully', () => { + describe("edge cases", () => { + it("handles null target gracefully", () => { const event = { target: null, isComposing: false, @@ -115,57 +115,57 @@ describe('shouldBlockShortcut', () => { }); }); -describe('FORM_ELEMENTS', () => { - it('contains INPUT, TEXTAREA, and SELECT', () => { - expect(FORM_ELEMENTS).toContain('INPUT'); - expect(FORM_ELEMENTS).toContain('TEXTAREA'); - expect(FORM_ELEMENTS).toContain('SELECT'); +describe("FORM_ELEMENTS", () => { + it("contains INPUT, TEXTAREA, and SELECT", () => { + expect(FORM_ELEMENTS).toContain("INPUT"); + expect(FORM_ELEMENTS).toContain("TEXTAREA"); + expect(FORM_ELEMENTS).toContain("SELECT"); }); - it('has exactly 3 elements', () => { + it("has exactly 3 elements", () => { expect(FORM_ELEMENTS).toHaveLength(3); }); }); -describe('SHORTCUTS', () => { - it('is an array', () => { +describe("SHORTCUTS", () => { + it("is an array", () => { expect(Array.isArray(SHORTCUTS)).toBe(true); }); - it('contains navigation shortcuts (j, k, Enter)', () => { + it("contains navigation shortcuts (j, k, Enter)", () => { const keys = SHORTCUTS.map((s) => s.key); - expect(keys).toContain('j'); - expect(keys).toContain('k'); - expect(keys).toContain('Enter'); + expect(keys).toContain("j"); + expect(keys).toContain("k"); + expect(keys).toContain("Enter"); }); - it('contains search shortcuts (/, Esc)', () => { + it("contains search shortcuts (/, Esc)", () => { const keys = SHORTCUTS.map((s) => s.key); - expect(keys).toContain('/'); - expect(keys).toContain('Esc'); + expect(keys).toContain("/"); + expect(keys).toContain("Esc"); }); - it('contains other shortcuts (l, ?)', () => { + it("contains other shortcuts (l, ?)", () => { const keys = SHORTCUTS.map((s) => s.key); - expect(keys).toContain('l'); - expect(keys).toContain('?'); + expect(keys).toContain("l"); + expect(keys).toContain("?"); }); - it('all shortcuts have required properties', () => { + it("all shortcuts have required properties", () => { for (const shortcut of SHORTCUTS) { - expect(shortcut).toHaveProperty('key'); - expect(shortcut).toHaveProperty('description'); - expect(shortcut).toHaveProperty('group'); - expect(typeof shortcut.key).toBe('string'); - expect(typeof shortcut.description).toBe('string'); - expect(['navigation', 'search', 'other']).toContain(shortcut.group); + expect(shortcut).toHaveProperty("key"); + expect(shortcut).toHaveProperty("description"); + expect(shortcut).toHaveProperty("group"); + expect(typeof shortcut.key).toBe("string"); + expect(typeof shortcut.description).toBe("string"); + expect(["navigation", "search", "other"]).toContain(shortcut.group); } }); - it('has shortcuts in expected groups', () => { - const navigationShortcuts = SHORTCUTS.filter((s) => s.group === 'navigation'); - const searchShortcuts = SHORTCUTS.filter((s) => s.group === 'search'); - const otherShortcuts = SHORTCUTS.filter((s) => s.group === 'other'); + it("has shortcuts in expected groups", () => { + const navigationShortcuts = SHORTCUTS.filter((s) => s.group === "navigation"); + const searchShortcuts = SHORTCUTS.filter((s) => s.group === "search"); + const otherShortcuts = SHORTCUTS.filter((s) => s.group === "other"); expect(navigationShortcuts.length).toBeGreaterThan(0); expect(searchShortcuts.length).toBeGreaterThan(0); diff --git a/src/lib/utils/timeseries.ts b/src/lib/utils/timeseries.ts index dac02ef..bea3521 100644 --- a/src/lib/utils/timeseries.ts +++ b/src/lib/utils/timeseries.ts @@ -1,4 +1,4 @@ -import type { TimeRange } from '$lib/components/time-range-picker.svelte'; +import type { TimeRange } from "$lib/components/time-range-picker.svelte"; export interface TimeBucketConfig { intervalMs: number; @@ -15,13 +15,13 @@ export interface TimeSeriesBucket { */ export function getTimeBucketConfig(range: TimeRange): TimeBucketConfig { switch (range) { - case '15m': + case "15m": return { intervalMs: 60 * 1000, expectedBuckets: 15 }; - case '1h': + case "1h": return { intervalMs: 5 * 60 * 1000, expectedBuckets: 12 }; - case '24h': + case "24h": return { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; - case '7d': + case "7d": return { intervalMs: 6 * 60 * 60 * 1000, expectedBuckets: 28 }; } } diff --git a/src/lib/utils/timeseries.unit.test.ts b/src/lib/utils/timeseries.unit.test.ts index f09d62a..a4a93b5 100644 --- a/src/lib/utils/timeseries.unit.test.ts +++ b/src/lib/utils/timeseries.unit.test.ts @@ -1,41 +1,41 @@ -import { describe, expect, it } from 'vitest'; -import { bucketTimestamps, fillMissingBuckets, getTimeBucketConfig } from './timeseries'; +import { describe, expect, it } from "vite-plus/test"; +import { bucketTimestamps, fillMissingBuckets, getTimeBucketConfig } from "./timeseries"; -describe('getTimeBucketConfig', () => { - it('returns 60000ms interval for 15m range', () => { - const config = getTimeBucketConfig('15m'); +describe("getTimeBucketConfig", () => { + it("returns 60000ms interval for 15m range", () => { + const config = getTimeBucketConfig("15m"); expect(config.intervalMs).toBe(60 * 1000); expect(config.expectedBuckets).toBe(15); }); - it('returns 300000ms interval for 1h range', () => { - const config = getTimeBucketConfig('1h'); + it("returns 300000ms interval for 1h range", () => { + const config = getTimeBucketConfig("1h"); expect(config.intervalMs).toBe(5 * 60 * 1000); expect(config.expectedBuckets).toBe(12); }); - it('returns 3600000ms interval for 24h range', () => { - const config = getTimeBucketConfig('24h'); + it("returns 3600000ms interval for 24h range", () => { + const config = getTimeBucketConfig("24h"); expect(config.intervalMs).toBe(60 * 60 * 1000); expect(config.expectedBuckets).toBe(24); }); - it('returns 21600000ms interval for 7d range', () => { - const config = getTimeBucketConfig('7d'); + it("returns 21600000ms interval for 7d range", () => { + const config = getTimeBucketConfig("7d"); expect(config.intervalMs).toBe(6 * 60 * 60 * 1000); expect(config.expectedBuckets).toBe(28); }); }); -describe('bucketTimestamps', () => { - it('groups timestamps into correct buckets', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); +describe("bucketTimestamps", () => { + it("groups timestamps into correct buckets", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; // 1 hour buckets const timestamps = [ - new Date('2024-01-15T10:15:00.000Z'), // bucket 0 - new Date('2024-01-15T10:45:00.000Z'), // bucket 0 - new Date('2024-01-15T11:30:00.000Z'), // bucket 1 + new Date("2024-01-15T10:15:00.000Z"), // bucket 0 + new Date("2024-01-15T10:45:00.000Z"), // bucket 0 + new Date("2024-01-15T11:30:00.000Z"), // bucket 1 ]; const buckets = bucketTimestamps(timestamps, config, rangeStart); @@ -44,13 +44,13 @@ describe('bucketTimestamps', () => { expect(buckets[1]).toBe(1); // One log in second hour }); - it('handles timestamps exactly on bucket boundaries', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); + it("handles timestamps exactly on bucket boundaries", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; const timestamps = [ - new Date('2024-01-15T10:00:00.000Z'), // exactly on boundary - new Date('2024-01-15T11:00:00.000Z'), // exactly on next boundary + new Date("2024-01-15T10:00:00.000Z"), // exactly on boundary + new Date("2024-01-15T11:00:00.000Z"), // exactly on next boundary ]; const buckets = bucketTimestamps(timestamps, config, rangeStart); @@ -59,8 +59,8 @@ describe('bucketTimestamps', () => { expect(buckets[1]).toBe(1); }); - it('returns empty object for empty input', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); + it("returns empty object for empty input", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; const buckets = bucketTimestamps([], config, rangeStart); @@ -68,14 +68,14 @@ describe('bucketTimestamps', () => { expect(buckets).toEqual({}); }); - it('ignores timestamps outside the expected bucket range', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); + it("ignores timestamps outside the expected bucket range", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 3 }; // Only 3 buckets const timestamps = [ - new Date('2024-01-15T09:00:00.000Z'), // before range (bucket -1) - new Date('2024-01-15T10:30:00.000Z'), // bucket 0 - new Date('2024-01-15T15:00:00.000Z'), // bucket 5 (beyond expectedBuckets) + new Date("2024-01-15T09:00:00.000Z"), // before range (bucket -1) + new Date("2024-01-15T10:30:00.000Z"), // bucket 0 + new Date("2024-01-15T15:00:00.000Z"), // bucket 5 (beyond expectedBuckets) ]; const buckets = bucketTimestamps(timestamps, config, rangeStart); @@ -86,10 +86,10 @@ describe('bucketTimestamps', () => { }); }); -describe('fillMissingBuckets', () => { - it('fills gaps between buckets with zero count', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); - const rangeEnd = new Date('2024-01-15T13:00:00.000Z'); +describe("fillMissingBuckets", () => { + it("fills gaps between buckets with zero count", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); + const rangeEnd = new Date("2024-01-15T13:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 3 }; // Only bucket 0 and 2 have data @@ -103,9 +103,9 @@ describe('fillMissingBuckets', () => { expect(result[2]!.count).toBe(3); }); - it('generates all buckets for completely empty input', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); - const rangeEnd = new Date('2024-01-15T13:00:00.000Z'); + it("generates all buckets for completely empty input", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); + const rangeEnd = new Date("2024-01-15T13:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 3 }; const result = fillMissingBuckets({}, config, rangeStart, rangeEnd); @@ -114,9 +114,9 @@ describe('fillMissingBuckets', () => { expect(result.every((b) => b.count === 0)).toBe(true); }); - it('preserves existing bucket counts', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); - const rangeEnd = new Date('2024-01-15T12:00:00.000Z'); + it("preserves existing bucket counts", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); + const rangeEnd = new Date("2024-01-15T12:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 2 }; const bucketCounts = { 0: 10, 1: 20 }; @@ -127,14 +127,14 @@ describe('fillMissingBuckets', () => { expect(result[1]!.count).toBe(20); }); - it('returns buckets with valid ISO timestamps', () => { - const rangeStart = new Date('2024-01-15T10:00:00.000Z'); - const rangeEnd = new Date('2024-01-15T12:00:00.000Z'); + it("returns buckets with valid ISO timestamps", () => { + const rangeStart = new Date("2024-01-15T10:00:00.000Z"); + const rangeEnd = new Date("2024-01-15T12:00:00.000Z"); const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 2 }; const result = fillMissingBuckets({}, config, rangeStart, rangeEnd); - expect(result[0]!.timestamp).toBe('2024-01-15T10:00:00.000Z'); - expect(result[1]!.timestamp).toBe('2024-01-15T11:00:00.000Z'); + expect(result[0]!.timestamp).toBe("2024-01-15T10:00:00.000Z"); + expect(result[1]!.timestamp).toBe("2024-01-15T11:00:00.000Z"); }); }); diff --git a/src/lib/utils/toast.ts b/src/lib/utils/toast.ts index 52a6fbb..2bd5606 100644 --- a/src/lib/utils/toast.ts +++ b/src/lib/utils/toast.ts @@ -1,4 +1,4 @@ -import { type ExternalToast, toast } from 'svelte-sonner'; +import { type ExternalToast, toast } from "svelte-sonner"; /** * Show a success toast notification @@ -14,12 +14,12 @@ export function toastSuccess(message: string, options?: ExternalToast): void { export function toastError(error: string | Error | unknown, options?: ExternalToast): void { let message: string; - if (typeof error === 'string') { + if (typeof error === "string") { message = error; } else if (error instanceof Error) { message = error.message; } else { - message = 'An unexpected error occurred'; + message = "An unexpected error occurred"; } toast.error(message, options); diff --git a/src/lib/utils/toast.unit.test.ts b/src/lib/utils/toast.unit.test.ts index 48abba2..b37b3a1 100644 --- a/src/lib/utils/toast.unit.test.ts +++ b/src/lib/utils/toast.unit.test.ts @@ -1,8 +1,8 @@ -import * as sonner from 'svelte-sonner'; -import { describe, expect, it, vi } from 'vitest'; +import * as sonner from "svelte-sonner"; +import { describe, expect, it, vi } from "vite-plus/test"; // Mock svelte-sonner -vi.mock('svelte-sonner', () => ({ +vi.mock("svelte-sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), @@ -14,35 +14,35 @@ vi.mock('svelte-sonner', () => ({ })); // Import after mocking -import { toastError, toastSuccess } from './toast'; +import { toastError, toastSuccess } from "./toast"; -describe('Toast Utility', () => { - describe('toastSuccess', () => { - it('calls toast.success with message', () => { - toastSuccess('Operation completed'); - expect(sonner.toast.success).toHaveBeenCalledWith('Operation completed', undefined); +describe("Toast Utility", () => { + describe("toastSuccess", () => { + it("calls toast.success with message", () => { + toastSuccess("Operation completed"); + expect(sonner.toast.success).toHaveBeenCalledWith("Operation completed", undefined); }); - it('passes options to toast.success', () => { - toastSuccess('Done', { duration: 3000 }); - expect(sonner.toast.success).toHaveBeenCalledWith('Done', { duration: 3000 }); + it("passes options to toast.success", () => { + toastSuccess("Done", { duration: 3000 }); + expect(sonner.toast.success).toHaveBeenCalledWith("Done", { duration: 3000 }); }); }); - describe('toastError', () => { - it('calls toast.error with message', () => { - toastError('Something went wrong'); - expect(sonner.toast.error).toHaveBeenCalledWith('Something went wrong', undefined); + describe("toastError", () => { + it("calls toast.error with message", () => { + toastError("Something went wrong"); + expect(sonner.toast.error).toHaveBeenCalledWith("Something went wrong", undefined); }); - it('extracts message from Error object', () => { - toastError(new Error('Database connection failed')); - expect(sonner.toast.error).toHaveBeenCalledWith('Database connection failed', undefined); + it("extracts message from Error object", () => { + toastError(new Error("Database connection failed")); + expect(sonner.toast.error).toHaveBeenCalledWith("Database connection failed", undefined); }); - it('uses fallback message for unknown error types', () => { - toastError({ foo: 'bar' }); - expect(sonner.toast.error).toHaveBeenCalledWith('An unexpected error occurred', undefined); + it("uses fallback message for unknown error types", () => { + toastError({ foo: "bar" }); + expect(sonner.toast.error).toHaveBeenCalledWith("An unexpected error occurred", undefined); }); }); }); diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index b00e581..b3cb974 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,5 +1,5 @@ -import { requireAuth } from '$lib/server/utils/auth-guard'; -import type { LayoutServerLoad } from './$types'; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import type { LayoutServerLoad } from "./$types"; /** * Server-side layout load function for the protected (app) route group. diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts index bcc95b4..820f2ec 100644 --- a/src/routes/(app)/+page.server.ts +++ b/src/routes/(app)/+page.server.ts @@ -1,8 +1,8 @@ -import { count, desc, eq, max } from 'drizzle-orm'; -import { db } from '$lib/server/db'; -import { log, project } from '$lib/server/db/schema'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import type { PageServerLoad } from './$types'; +import { count, desc, eq, max } from "drizzle-orm"; +import { db } from "$lib/server/db"; +import { log, project } from "$lib/server/db/schema"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import type { PageServerLoad } from "./$types"; /** * Dashboard page server load function. diff --git a/src/routes/(app)/projects/[id]/+page.server.ts b/src/routes/(app)/projects/[id]/+page.server.ts index d93c0d9..57697f0 100644 --- a/src/routes/(app)/projects/[id]/+page.server.ts +++ b/src/routes/(app)/projects/[id]/+page.server.ts @@ -1,12 +1,12 @@ -import { error } from '@sveltejs/kit'; -import { and, count, desc, eq, gte, inArray, lt, or, type SQL, sql } from 'drizzle-orm'; -import { env } from '$lib/server/config'; -import { log, project } from '$lib/server/db/schema'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import { decodeCursor, encodeCursor } from '$lib/server/utils/cursor'; -import { buildSearchQuery } from '$lib/server/utils/search'; -import { LOG_LEVELS, type LogLevel } from '$lib/shared/types'; -import type { PageServerLoad } from './$types'; +import { error } from "@sveltejs/kit"; +import { and, count, desc, eq, gte, inArray, lt, or, type SQL, sql } from "drizzle-orm"; +import { env } from "$lib/server/config"; +import { log, project } from "$lib/server/db/schema"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import { decodeCursor, encodeCursor } from "$lib/server/utils/cursor"; +import { buildSearchQuery } from "$lib/server/utils/search"; +import { LOG_LEVELS, type LogLevel } from "$lib/shared/types"; +import type { PageServerLoad } from "./$types"; // Constants for pagination const DEFAULT_LIMIT = 100; @@ -25,7 +25,7 @@ function parseLevelFilter(levelParam: string | null): LogLevel[] | null { if (!levelParam) return null; const levels = levelParam - .split(',') + .split(",") .map((l) => l.trim().toLowerCase()) .filter((l): l is LogLevel => LOG_LEVELS.includes(l as LogLevel)); @@ -38,13 +38,13 @@ function getTimeRangeStart(range: string | null): Date | null { const now = Date.now(); switch (range) { - case '15m': + case "15m": return new Date(now - 15 * 60 * 1000); - case '1h': + case "1h": return new Date(now - 60 * 60 * 1000); - case '24h': + case "24h": return new Date(now - 24 * 60 * 60 * 1000); - case '7d': + case "7d": return new Date(now - 7 * 24 * 60 * 60 * 1000); default: return null; @@ -55,7 +55,7 @@ export const load: PageServerLoad = async (event) => { // Require session authentication const { user } = await requireAuth(event); - const { db } = await import('$lib/server/db'); + const { db } = await import("$lib/server/db"); const projectId = event.params.id; // Fetch project data - verify ownership @@ -65,17 +65,17 @@ export const load: PageServerLoad = async (event) => { .where(and(eq(project.id, projectId), eq(project.ownerId, user.id))); if (!projectData) { - throw error(404, { message: 'Project not found' }); + throw error(404, { message: "Project not found" }); } // Parse query parameters const url = event.url; - const limitParam = url.searchParams.get('limit'); - const offsetParam = url.searchParams.get('offset'); - const cursorParam = url.searchParams.get('cursor'); - const levelParam = url.searchParams.get('level'); - const searchParam = url.searchParams.get('search'); - const rangeParam = url.searchParams.get('range') || '1h'; + const limitParam = url.searchParams.get("limit"); + const offsetParam = url.searchParams.get("offset"); + const cursorParam = url.searchParams.get("cursor"); + const levelParam = url.searchParams.get("level"); + const searchParam = url.searchParams.get("search"); + const rangeParam = url.searchParams.get("range") || "1h"; // Parse pagination const limit = clamp( @@ -106,7 +106,7 @@ export const load: PageServerLoad = async (event) => { ); } catch (err) { // Invalid cursor - log and fall back to first page (consistent with API behavior) - console.error('[page/logs] invalid cursor, falling back to first page:', err); + console.error("[page/logs] invalid cursor, falling back to first page:", err); } } @@ -192,7 +192,7 @@ export const load: PageServerLoad = async (event) => { }, filters: { levels: levels ?? [], - search: searchParam ?? '', + search: searchParam ?? "", range: rangeParam, }, appUrl: env.ORIGIN || event.url.origin, diff --git a/src/routes/(app)/projects/[id]/incidents/+page.server.ts b/src/routes/(app)/projects/[id]/incidents/+page.server.ts index ff7c4d7..2bdbac3 100644 --- a/src/routes/(app)/projects/[id]/incidents/+page.server.ts +++ b/src/routes/(app)/projects/[id]/incidents/+page.server.ts @@ -1,18 +1,18 @@ -import { error } from '@sveltejs/kit'; -import { and, count, desc, eq, gte, lt, or, type SQL } from 'drizzle-orm'; -import { INCIDENT_CONFIG } from '$lib/server/config'; -import { incident, project } from '$lib/server/db/schema'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import { decodeCursor, encodeCursor } from '$lib/server/utils/cursor'; -import { getIncidentStatus } from '$lib/server/utils/incidents'; +import { error } from "@sveltejs/kit"; +import { and, count, desc, eq, gte, lt, or, type SQL } from "drizzle-orm"; +import { INCIDENT_CONFIG } from "$lib/server/config"; +import { incident, project } from "$lib/server/db/schema"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import { decodeCursor, encodeCursor } from "$lib/server/utils/cursor"; +import { getIncidentStatus } from "$lib/server/utils/incidents"; import { INCIDENT_RANGES, INCIDENT_STATUSES, type IncidentRange, type IncidentStatus, -} from '$lib/shared/types'; -import { getTimeRangeStart } from '$lib/utils/format'; -import type { PageServerLoad } from './$types'; +} from "$lib/shared/types"; +import { getTimeRangeStart } from "$lib/utils/format"; +import type { PageServerLoad } from "./$types"; const DEFAULT_LIMIT = 50; const MIN_LIMIT = 20; @@ -24,7 +24,7 @@ function clamp(value: number, min: number, max: number): number { export const load: PageServerLoad = async (event) => { const { user } = await requireAuth(event); - const { db } = await import('$lib/server/db'); + const { db } = await import("$lib/server/db"); const projectId = event.params.id; const [projectData] = await db @@ -33,34 +33,34 @@ export const load: PageServerLoad = async (event) => { .where(and(eq(project.id, projectId), eq(project.ownerId, user.id))); if (!projectData) { - throw error(404, { message: 'Project not found' }); + throw error(404, { message: "Project not found" }); } const params = event.url.searchParams; const limit = clamp( - params.get('limit') - ? Number.parseInt(params.get('limit') || '', 10) || DEFAULT_LIMIT + params.get("limit") + ? Number.parseInt(params.get("limit") || "", 10) || DEFAULT_LIMIT : DEFAULT_LIMIT, MIN_LIMIT, MAX_LIMIT, ); - const cursorParam = params.get('cursor'); - const statusParam = params.get('status') || 'open'; + const cursorParam = params.get("cursor"); + const statusParam = params.get("status") || "open"; const status: IncidentStatus = INCIDENT_STATUSES.includes(statusParam as IncidentStatus) ? (statusParam as IncidentStatus) - : 'open'; - const rangeParam = params.get('range') || '24h'; + : "open"; + const rangeParam = params.get("range") || "24h"; const range: IncidentRange = INCIDENT_RANGES.includes(rangeParam as IncidentRange) ? (rangeParam as IncidentRange) - : '24h'; + : "24h"; const rangeStart = getTimeRangeStart(range); const resolvedThreshold = new Date(Date.now() - INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES * 60 * 1000); const conditions: SQL[] = [eq(incident.projectId, projectId), gte(incident.lastSeen, rangeStart)]; - if (status === 'open') { + if (status === "open") { conditions.push(gte(incident.lastSeen, resolvedThreshold)); - } else if (status === 'resolved') { + } else if (status === "resolved") { conditions.push(lt(incident.lastSeen, resolvedThreshold)); } @@ -75,7 +75,7 @@ export const load: PageServerLoad = async (event) => { ); } catch (err) { // Invalid cursor - log and fall back to first page (consistent with API behavior) - console.error('[page/incidents] invalid cursor, falling back to first page:', err); + console.error("[page/incidents] invalid cursor, falling back to first page:", err); } } @@ -127,7 +127,7 @@ export const load: PageServerLoad = async (event) => { filters: { status, range, - selectedIncidentId: params.get('incident'), + selectedIncidentId: params.get("incident"), }, }; }; diff --git a/src/routes/(app)/projects/[id]/settings/+page.server.ts b/src/routes/(app)/projects/[id]/settings/+page.server.ts index 91ce39b..3d91908 100644 --- a/src/routes/(app)/projects/[id]/settings/+page.server.ts +++ b/src/routes/(app)/projects/[id]/settings/+page.server.ts @@ -1,15 +1,15 @@ -import { error } from '@sveltejs/kit'; -import { and, count, eq, min } from 'drizzle-orm'; -import { RETENTION_CONFIG } from '$lib/server/config'; -import { log, project } from '$lib/server/db/schema'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import type { PageServerLoad } from './$types'; +import { error } from "@sveltejs/kit"; +import { and, count, eq, min } from "drizzle-orm"; +import { RETENTION_CONFIG } from "$lib/server/config"; +import { log, project } from "$lib/server/db/schema"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async (event) => { // Require session authentication const { user } = await requireAuth(event); - const { db } = await import('$lib/server/db'); + const { db } = await import("$lib/server/db"); const projectId = event.params.id; // Fetch project data - verify ownership @@ -19,7 +19,7 @@ export const load: PageServerLoad = async (event) => { .where(and(eq(project.id, projectId), eq(project.ownerId, user.id))); if (!projectData) { - throw error(404, { message: 'Project not found' }); + throw error(404, { message: "Project not found" }); } // Get log stats: total count and oldest log date diff --git a/src/routes/(app)/projects/[id]/stats/+page.server.ts b/src/routes/(app)/projects/[id]/stats/+page.server.ts index afd11b4..cb0dded 100644 --- a/src/routes/(app)/projects/[id]/stats/+page.server.ts +++ b/src/routes/(app)/projects/[id]/stats/+page.server.ts @@ -1,8 +1,8 @@ -import { error } from '@sveltejs/kit'; -import { and, count, eq, gte, type SQL } from 'drizzle-orm'; -import { log, project } from '$lib/server/db/schema'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import type { PageServerLoad } from './$types'; +import { error } from "@sveltejs/kit"; +import { and, count, eq, gte, type SQL } from "drizzle-orm"; +import { log, project } from "$lib/server/db/schema"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import type { PageServerLoad } from "./$types"; // TODO(RT-10): deduplicate with $lib/utils/format getTimeRangeStart function getTimeRangeStart(range: string | null): Date | null { @@ -10,13 +10,13 @@ function getTimeRangeStart(range: string | null): Date | null { const now = Date.now(); switch (range) { - case '15m': + case "15m": return new Date(now - 15 * 60 * 1000); - case '1h': + case "1h": return new Date(now - 60 * 60 * 1000); - case '24h': + case "24h": return new Date(now - 24 * 60 * 60 * 1000); - case '7d': + case "7d": return new Date(now - 7 * 24 * 60 * 60 * 1000); default: return null; @@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => { // Require session authentication const { user } = await requireAuth(event); - const { db } = await import('$lib/server/db'); + const { db } = await import("$lib/server/db"); const projectId = event.params.id; // Fetch project data - verify ownership @@ -37,12 +37,12 @@ export const load: PageServerLoad = async (event) => { .where(and(eq(project.id, projectId), eq(project.ownerId, user.id))); if (!projectData) { - throw error(404, { message: 'Project not found' }); + throw error(404, { message: "Project not found" }); } // Parse query parameters - default to 24h for stats overview const url = event.url; - const rangeParam = url.searchParams.get('range') || '24h'; + const rangeParam = url.searchParams.get("range") || "24h"; // Calculate time range const fromDate = getTimeRangeStart(rangeParam); diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index a69de3b..10c342f 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,7 +1,7 @@ -import { json } from '@sveltejs/kit'; -import { sql } from 'drizzle-orm'; -import { type DatabaseClient, getDbClient } from '$lib/server/db/db'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { sql } from "drizzle-orm"; +import { type DatabaseClient, getDbClient } from "$lib/server/db/db"; +import type { RequestEvent } from "./$types"; // Track server start time for uptime calculation const serverStartTime = Date.now(); @@ -14,14 +14,14 @@ async function checkDatabase( db: DatabaseClient | null, ): Promise<{ connected: boolean; error?: string }> { if (!db) { - return { connected: false, error: 'Database client not available' }; + return { connected: false, error: "Database client not available" }; } try { // Execute a simple query to verify connectivity await db.execute(sql`SELECT 1`); return { connected: true }; } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown database error'; + const message = error instanceof Error ? error.message : "Unknown database error"; return { connected: false, error: message }; } } @@ -30,8 +30,8 @@ async function checkDatabase( * Health check response type */ interface HealthResponse { - status: 'healthy' | 'unhealthy'; - database: 'connected' | 'disconnected'; + status: "healthy" | "unhealthy"; + database: "connected" | "disconnected"; timestamp: string; uptime: number; version: string; @@ -71,8 +71,8 @@ export async function GET(event: RequestEvent): Promise { const uptimeSeconds = Math.floor((Date.now() - serverStartTime) / 1000); const responseBody: HealthResponse = { - status: isHealthy ? 'healthy' : 'unhealthy', - database: dbStatus.connected ? 'connected' : 'disconnected', + status: isHealthy ? "healthy" : "unhealthy", + database: dbStatus.connected ? "connected" : "disconnected", timestamp: new Date().toISOString(), uptime: uptimeSeconds, version: __APP_VERSION__, @@ -80,13 +80,13 @@ export async function GET(event: RequestEvent): Promise { // Include generic error message when unhealthy (log the real error server-side) if (!isHealthy && dbStatus.error) { - console.error('[health] Database connectivity check failed:', dbStatus.error); - responseBody.error = 'database unavailable'; + console.error("[health] Database connectivity check failed:", dbStatus.error); + responseBody.error = "database unavailable"; } const headers = new Headers({ - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store, must-revalidate', + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", }); return json(responseBody, { diff --git a/src/routes/api/projects/+server.ts b/src/routes/api/projects/+server.ts index de8775d..2e8c6da 100644 --- a/src/routes/api/projects/+server.ts +++ b/src/routes/api/projects/+server.ts @@ -1,15 +1,15 @@ -import { json } from '@sveltejs/kit'; -import { and, count, desc, eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import { getDbClient } from '$lib/server/db/db'; -import { log, project } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { generateApiKey, hashApiKey } from '$lib/server/utils/api-key'; -import { requireAuth } from '$lib/server/utils/auth-guard'; -import { requireJsonContentType } from '$lib/server/utils/content-type'; -import { checkCsrfOrigin } from '$lib/server/utils/csrf'; -import { projectCreatePayloadSchema } from '$lib/shared/schemas/project'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, count, desc, eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { getDbClient } from "$lib/server/db/db"; +import { log, project } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { generateApiKey, hashApiKey } from "$lib/server/utils/api-key"; +import { requireAuth } from "$lib/server/utils/auth-guard"; +import { requireJsonContentType } from "$lib/server/utils/content-type"; +import { checkCsrfOrigin } from "$lib/server/utils/csrf"; +import { projectCreatePayloadSchema } from "$lib/shared/schemas/project"; +import type { RequestEvent } from "./$types"; /** * GET /api/projects @@ -113,7 +113,7 @@ export async function POST(event: RequestEvent): Promise { try { body = await event.request.json(); } catch { - return apiError(400, 'invalid_json', 'Invalid JSON body'); + return apiError(400, "invalid_json", "Invalid JSON body"); } // Validate request body @@ -121,10 +121,10 @@ export async function POST(event: RequestEvent): Promise { if (!validation.success) { const issues = validation.error.issues ?? []; const firstError = issues[0]; - const field = firstError?.path.join('.') || 'name'; - const message = firstError?.message || 'Validation failed'; + const field = firstError?.path.join(".") || "name"; + const message = firstError?.message || "Validation failed"; - return apiError(400, 'validation_error', `${field}: ${message}`); + return apiError(400, "validation_error", `${field}: ${message}`); } const { name } = validation.data; @@ -136,7 +136,7 @@ export async function POST(event: RequestEvent): Promise { .where(and(eq(project.name, name), eq(project.ownerId, user.id))); if (existing) { - return apiError(400, 'duplicate_name', 'A project with this name already exists'); + return apiError(400, "duplicate_name", "A project with this name already exists"); } // Generate new project with current user as owner. The plaintext key is @@ -151,7 +151,7 @@ export async function POST(event: RequestEvent): Promise { // Insert project const [created] = await db.insert(project).values(newProject).returning(); - if (!created) return apiError(500, 'internal_error', 'Failed to create project'); + if (!created) return apiError(500, "internal_error", "Failed to create project"); return json( { diff --git a/src/routes/api/projects/[id]/+server.ts b/src/routes/api/projects/[id]/+server.ts index 57149c7..7964194 100644 --- a/src/routes/api/projects/[id]/+server.ts +++ b/src/routes/api/projects/[id]/+server.ts @@ -1,13 +1,13 @@ -import { json } from '@sveltejs/kit'; -import { and, count, eq, ne } from 'drizzle-orm'; -import { getDbClient } from '$lib/server/db/db'; -import { log, project } from '$lib/server/db/schema'; -import { invalidateApiKeyCacheByHash } from '$lib/server/utils/api-key'; -import { requireJsonContentType } from '$lib/server/utils/content-type'; -import { checkCsrfOrigin } from '$lib/server/utils/csrf'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { projectUpdatePayloadSchema } from '$lib/shared/schemas/project'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, count, eq, ne } from "drizzle-orm"; +import { getDbClient } from "$lib/server/db/db"; +import { log, project } from "$lib/server/db/schema"; +import { invalidateApiKeyCacheByHash } from "$lib/server/utils/api-key"; +import { requireJsonContentType } from "$lib/server/utils/content-type"; +import { checkCsrfOrigin } from "$lib/server/utils/csrf"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { projectUpdatePayloadSchema } from "$lib/shared/schemas/project"; +import type { RequestEvent } from "./$types"; /** * GET /api/projects/[id] @@ -130,14 +130,14 @@ export async function PATCH(event: RequestEvent): Promise { try { body = await event.request.json(); } catch { - return json({ error: 'invalid_json', message: 'Invalid JSON body' }, { status: 400 }); + return json({ error: "invalid_json", message: "Invalid JSON body" }, { status: 400 }); } // Validate request body const result = projectUpdatePayloadSchema.safeParse(body); if (!result.success) { - const errorMessage = result.error.issues?.[0]?.message || 'Validation failed'; - return json({ code: 'validation_error', message: errorMessage }, { status: 400 }); + const errorMessage = result.error.issues?.[0]?.message || "Validation failed"; + return json({ code: "validation_error", message: errorMessage }, { status: 400 }); } const { name, retentionDays } = result.data; @@ -154,7 +154,7 @@ export async function PATCH(event: RequestEvent): Promise { }); if (existing) { return json( - { code: 'duplicate_name', message: 'A project with this name already exists' }, + { code: "duplicate_name", message: "A project with this name already exists" }, { status: 400 }, ); } @@ -194,7 +194,7 @@ export async function PATCH(event: RequestEvent): Promise { .returning(); if (!updated) { - return json({ code: 'not_found', message: 'Project not found' }, { status: 404 }); + return json({ code: "not_found", message: "Project not found" }, { status: 404 }); } return json({ diff --git a/src/routes/api/projects/[id]/incidents/+server.ts b/src/routes/api/projects/[id]/incidents/+server.ts index a2b9633..27add7e 100644 --- a/src/routes/api/projects/[id]/incidents/+server.ts +++ b/src/routes/api/projects/[id]/incidents/+server.ts @@ -1,15 +1,15 @@ -import { json } from '@sveltejs/kit'; -import { and, count, desc, eq, gte, lt, or, type SQL } from 'drizzle-orm'; -import { INCIDENT_CONFIG } from '$lib/server/config'; -import { getDbClient } from '$lib/server/db/db'; -import { incident } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { decodeCursor, encodeCursor } from '$lib/server/utils/cursor'; -import { getIncidentStatus } from '$lib/server/utils/incidents'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { INCIDENT_RANGES, INCIDENT_STATUSES, type IncidentRange } from '$lib/shared/types'; -import { getTimeRangeStart } from '$lib/utils/format'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, count, desc, eq, gte, lt, or, type SQL } from "drizzle-orm"; +import { INCIDENT_CONFIG } from "$lib/server/config"; +import { getDbClient } from "$lib/server/db/db"; +import { incident } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { decodeCursor, encodeCursor } from "$lib/server/utils/cursor"; +import { getIncidentStatus } from "$lib/server/utils/incidents"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { INCIDENT_RANGES, INCIDENT_STATUSES, type IncidentRange } from "$lib/shared/types"; +import { getTimeRangeStart } from "$lib/utils/format"; +import type { RequestEvent } from "./$types"; const DEFAULT_LIMIT = 50; const MIN_LIMIT = 20; @@ -31,30 +31,30 @@ export async function GET(event: RequestEvent): Promise { const params = event.url.searchParams; const limit = clamp( - params.get('limit') - ? Number.parseInt(params.get('limit') || '', 10) || DEFAULT_LIMIT + params.get("limit") + ? Number.parseInt(params.get("limit") || "", 10) || DEFAULT_LIMIT : DEFAULT_LIMIT, MIN_LIMIT, MAX_LIMIT, ); - const cursorParam = params.get('cursor'); - const statusParam = params.get('status') || 'open'; + const cursorParam = params.get("cursor"); + const statusParam = params.get("status") || "open"; const status = INCIDENT_STATUSES.includes(statusParam as (typeof INCIDENT_STATUSES)[number]) ? (statusParam as (typeof INCIDENT_STATUSES)[number]) - : 'open'; + : "open"; - const rangeParam = params.get('range') || '24h'; + const rangeParam = params.get("range") || "24h"; const range: IncidentRange = INCIDENT_RANGES.includes(rangeParam as IncidentRange) ? (rangeParam as IncidentRange) - : '24h'; + : "24h"; const rangeStart = getTimeRangeStart(range); const resolvedThreshold = new Date(Date.now() - INCIDENT_CONFIG.AUTO_RESOLVE_MINUTES * 60 * 1000); const conditions: SQL[] = [eq(incident.projectId, projectId), gte(incident.lastSeen, rangeStart)]; - if (status === 'open') { + if (status === "open") { conditions.push(gte(incident.lastSeen, resolvedThreshold)); - } else if (status === 'resolved') { + } else if (status === "resolved") { conditions.push(lt(incident.lastSeen, resolvedThreshold)); } @@ -70,8 +70,8 @@ export async function GET(event: RequestEvent): Promise { } catch (error) { return apiError( 400, - 'invalid_cursor', - error instanceof Error ? error.message : 'Invalid cursor', + "invalid_cursor", + error instanceof Error ? error.message : "Invalid cursor", ); } } diff --git a/src/routes/api/projects/[id]/incidents/[incidentId]/+server.ts b/src/routes/api/projects/[id]/incidents/[incidentId]/+server.ts index 7e4ca39..163711c 100644 --- a/src/routes/api/projects/[id]/incidents/[incidentId]/+server.ts +++ b/src/routes/api/projects/[id]/incidents/[incidentId]/+server.ts @@ -1,11 +1,11 @@ -import { json } from '@sveltejs/kit'; -import { and, eq, sql } from 'drizzle-orm'; -import { getDbClient, getQueryRows } from '$lib/server/db/db'; -import { incident, log } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { getIncidentStatus } from '$lib/server/utils/incidents'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, eq, sql } from "drizzle-orm"; +import { getDbClient, getQueryRows } from "$lib/server/db/db"; +import { incident, log } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { getIncidentStatus } from "$lib/server/utils/incidents"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import type { RequestEvent } from "./$types"; type SourceFrequencyRow = { sourceFile: string | null; @@ -40,7 +40,7 @@ export async function GET(event: RequestEvent): Promise { .where(and(eq(incident.projectId, projectId), eq(incident.id, incidentId))); if (!incidentRow) { - return apiError(404, 'not_found', 'Incident not found'); + return apiError(404, "not_found", "Incident not found"); } const logWhereClause = and(eq(log.projectId, projectId), eq(log.incidentId, incidentId)); diff --git a/src/routes/api/projects/[id]/incidents/[incidentId]/timeline/+server.ts b/src/routes/api/projects/[id]/incidents/[incidentId]/timeline/+server.ts index 52d48e7..c02dd16 100644 --- a/src/routes/api/projects/[id]/incidents/[incidentId]/timeline/+server.ts +++ b/src/routes/api/projects/[id]/incidents/[incidentId]/timeline/+server.ts @@ -1,13 +1,13 @@ -import { json } from '@sveltejs/kit'; -import { and, eq, gte, lte, type SQL, sql } from 'drizzle-orm'; -import { type BucketCountRow, getDbClient, getQueryRows } from '$lib/server/db/db'; -import { incident, log } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { INCIDENT_RANGES, type IncidentRange } from '$lib/shared/types'; -import { getTimeRangeStart } from '$lib/utils/format'; -import { fillMissingBuckets, getTimeBucketConfig } from '$lib/utils/timeseries'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, eq, gte, lte, type SQL, sql } from "drizzle-orm"; +import { type BucketCountRow, getDbClient, getQueryRows } from "$lib/server/db/db"; +import { incident, log } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { INCIDENT_RANGES, type IncidentRange } from "$lib/shared/types"; +import { getTimeRangeStart } from "$lib/utils/format"; +import { fillMissingBuckets, getTimeBucketConfig } from "$lib/utils/timeseries"; +import type { RequestEvent } from "./$types"; /** * GET /api/projects/[id]/incidents/[incidentId]/timeline @@ -30,13 +30,13 @@ export async function GET(event: RequestEvent): Promise { .where(and(eq(incident.projectId, projectId), eq(incident.id, incidentId))); if (!incidentRow) { - return apiError(404, 'not_found', 'Incident not found'); + return apiError(404, "not_found", "Incident not found"); } - const rangeParam = event.url.searchParams.get('range') || '24h'; + const rangeParam = event.url.searchParams.get("range") || "24h"; const range: IncidentRange = INCIDENT_RANGES.includes(rangeParam as IncidentRange) ? (rangeParam as IncidentRange) - : '24h'; + : "24h"; const rangeEnd = new Date(); const rangeStart = getTimeRangeStart(range, rangeEnd); diff --git a/src/routes/api/projects/[id]/incidents/stream/+server.ts b/src/routes/api/projects/[id]/incidents/stream/+server.ts index 4620001..fec5989 100644 --- a/src/routes/api/projects/[id]/incidents/stream/+server.ts +++ b/src/routes/api/projects/[id]/incidents/stream/+server.ts @@ -1,9 +1,9 @@ -import { SSE_CONFIG } from '$lib/server/config/performance'; -import type { Incident } from '$lib/server/db/schema'; -import { logEventBus } from '$lib/server/events'; -import { checkCsrfOrigin } from '$lib/server/utils/csrf'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import type { RequestEvent } from './$types'; +import { SSE_CONFIG } from "$lib/server/config/performance"; +import type { Incident } from "$lib/server/db/schema"; +import { logEventBus } from "$lib/server/events"; +import { checkCsrfOrigin } from "$lib/server/utils/csrf"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import type { RequestEvent } from "./$types"; const { BATCH_WINDOW_MS, MAX_BATCH_SIZE, HEARTBEAT_INTERVAL_MS } = SSE_CONFIG; @@ -35,26 +35,26 @@ export async function POST(event: RequestEvent): Promise { let flushTimeout: ReturnType | null = null; let isClosed = false; - const sendEvent = (eventName: string, data: string): 'sent' | 'backpressure' | 'closed' => { - if (isClosed) return 'closed'; + const sendEvent = (eventName: string, data: string): "sent" | "backpressure" | "closed" => { + if (isClosed) return "closed"; try { const size = (controller as ReadableStreamDefaultController).desiredSize; if (size !== null && size <= 0) { // Stream backpressure: slow consumer — drop this event but keep the stream open - return 'backpressure'; + return "backpressure"; } controller.enqueue(encoder.encode(formatSSEEvent(eventName, data))); - return 'sent'; + return "sent"; } catch { // Controller closed - return 'closed'; + return "closed"; } }; const flushBatch = () => { if (batch.length > 0) { // Only a closed controller is terminal; backpressure just drops this event. - if (sendEvent('incidents', JSON.stringify(batch)) === 'closed') cleanup(); + if (sendEvent("incidents", JSON.stringify(batch)) === "closed") cleanup(); batch = []; } flushTimeout = null; @@ -79,7 +79,7 @@ export async function POST(event: RequestEvent): Promise { const unsubscribe = logEventBus.onIncident(projectId, handleIncident); const heartbeatInterval = setInterval(() => { - if (sendEvent('heartbeat', JSON.stringify({ ts: Date.now() })) === 'closed') cleanup(); + if (sendEvent("heartbeat", JSON.stringify({ ts: Date.now() })) === "closed") cleanup(); }, HEARTBEAT_INTERVAL_MS); const cleanup = () => { @@ -105,9 +105,9 @@ export async function POST(event: RequestEvent): Promise { return new Response(stream, { status: 200, headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", }, }); } diff --git a/src/routes/api/projects/[id]/logs/+server.ts b/src/routes/api/projects/[id]/logs/+server.ts index 178cf2a..1f7e9dc 100644 --- a/src/routes/api/projects/[id]/logs/+server.ts +++ b/src/routes/api/projects/[id]/logs/+server.ts @@ -1,13 +1,13 @@ -import { json } from '@sveltejs/kit'; -import { and, count, desc, eq, gte, inArray, lt, lte, or, type SQL, sql } from 'drizzle-orm'; -import { getDbClient } from '$lib/server/db/db'; -import { log } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { decodeCursor, encodeCursor } from '$lib/server/utils/cursor'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { buildSearchQuery } from '$lib/server/utils/search'; -import { LOG_LEVELS, type LogLevel } from '$lib/shared/types'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, count, desc, eq, gte, inArray, lt, lte, or, type SQL, sql } from "drizzle-orm"; +import { getDbClient } from "$lib/server/db/db"; +import { log } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { decodeCursor, encodeCursor } from "$lib/server/utils/cursor"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { buildSearchQuery } from "$lib/server/utils/search"; +import { LOG_LEVELS, type LogLevel } from "$lib/shared/types"; +import type { RequestEvent } from "./$types"; // Constants for pagination limits const DEFAULT_LIMIT = 100; @@ -29,7 +29,7 @@ function parseLevelFilter(levelParam: string | null): LogLevel[] | null { if (!levelParam) return null; const levels = levelParam - .split(',') + .split(",") .map((l) => l.trim().toLowerCase()) .filter((l): l is LogLevel => LOG_LEVELS.includes(l as LogLevel)); @@ -74,13 +74,13 @@ export async function GET(event: RequestEvent): Promise { // Parse query parameters const url = event.url; - const limitParam = url.searchParams.get('limit'); - const offsetParam = url.searchParams.get('offset'); - const cursorParam = url.searchParams.get('cursor'); - const levelParam = url.searchParams.get('level'); - const searchParam = url.searchParams.get('search'); - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); + const limitParam = url.searchParams.get("limit"); + const offsetParam = url.searchParams.get("offset"); + const cursorParam = url.searchParams.get("cursor"); + const levelParam = url.searchParams.get("level"); + const searchParam = url.searchParams.get("search"); + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); // Parse and clamp limit const limit = clamp( @@ -118,8 +118,8 @@ export async function GET(event: RequestEvent): Promise { } catch (error) { return apiError( 400, - 'invalid_cursor', - error instanceof Error ? error.message : 'Invalid cursor', + "invalid_cursor", + error instanceof Error ? error.message : "Invalid cursor", ); } } diff --git a/src/routes/api/projects/[id]/logs/export/+server.ts b/src/routes/api/projects/[id]/logs/export/+server.ts index 3828526..a13fdc8 100644 --- a/src/routes/api/projects/[id]/logs/export/+server.ts +++ b/src/routes/api/projects/[id]/logs/export/+server.ts @@ -1,28 +1,28 @@ -import { and, count, desc, eq, gte, inArray, lt, lte, or, type SQL, sql } from 'drizzle-orm'; -import { EXPORT_CONFIG } from '$lib/server/config/performance'; -import { getDbClient } from '$lib/server/db/db'; -import { log } from '$lib/server/db/schema'; -import { apiError } from '$lib/server/utils/api-error'; -import { escapeCSVField } from '$lib/server/utils/csv-serializer'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { buildSearchQuery } from '$lib/server/utils/search'; -import { LOG_LEVELS, type LogLevel } from '$lib/shared/types'; -import type { ExportFormat } from '$lib/types/export'; -import type { RequestEvent } from './$types'; +import { and, count, desc, eq, gte, inArray, lt, lte, or, type SQL, sql } from "drizzle-orm"; +import { EXPORT_CONFIG } from "$lib/server/config/performance"; +import { getDbClient } from "$lib/server/db/db"; +import { log } from "$lib/server/db/schema"; +import { apiError } from "$lib/server/utils/api-error"; +import { escapeCSVField } from "$lib/server/utils/csv-serializer"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { buildSearchQuery } from "$lib/server/utils/search"; +import { LOG_LEVELS, type LogLevel } from "$lib/shared/types"; +import type { ExportFormat } from "$lib/types/export"; +import type { RequestEvent } from "./$types"; const EXPORT_BATCH_SIZE = 500; const CSV_HEADERS = [ - 'id', - 'timestamp', - 'level', - 'message', - 'metadata', - 'sourceFile', - 'lineNumber', - 'requestId', - 'userId', - 'ipAddress', + "id", + "timestamp", + "level", + "message", + "metadata", + "sourceFile", + "lineNumber", + "requestId", + "userId", + "ipAddress", ] as const; // TODO: deduplicate with logs/+server.ts parseLevelFilter (RT-10) @@ -30,7 +30,7 @@ function parseLevelFilter(levelParam: string | null): LogLevel[] | null { if (!levelParam) return null; const levels = levelParam - .split(',') + .split(",") .map((l) => l.trim().toLowerCase()) .filter((l): l is LogLevel => LOG_LEVELS.includes(l as LogLevel)); @@ -41,10 +41,10 @@ function parseLevelFilter(levelParam: string | null): LogLevel[] | null { * Validate export format parameter */ function validateFormat(formatParam: string | null): ExportFormat | null { - if (!formatParam) return 'json'; // Default to JSON + if (!formatParam) return "json"; // Default to JSON const format = formatParam.toLowerCase(); - if (format === 'csv' || format === 'json') { + if (format === "csv" || format === "json") { return format as ExportFormat; } @@ -55,8 +55,8 @@ function validateFormat(formatParam: string | null): ExportFormat | null { * Generate filename for export with timestamp */ function generateFilename(projectName: string, format: ExportFormat): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; - const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, '-'); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0]; + const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-"); return `logs-${sanitizedName}-${timestamp}.${format}`; } @@ -94,16 +94,16 @@ export async function GET(event: RequestEvent): Promise { // Parse query parameters const url = event.url; - const formatParam = url.searchParams.get('format'); - const levelParam = url.searchParams.get('level'); - const searchParam = url.searchParams.get('search'); - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); + const formatParam = url.searchParams.get("format"); + const levelParam = url.searchParams.get("level"); + const searchParam = url.searchParams.get("search"); + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); // Validate format const format = validateFormat(formatParam); if (!format) { - return apiError(400, 'invalid_format', 'Invalid format parameter. Must be "csv" or "json".'); + return apiError(400, "invalid_format", 'Invalid format parameter. Must be "csv" or "json".'); } // Parse level filter @@ -146,7 +146,7 @@ export async function GET(event: RequestEvent): Promise { if (total > EXPORT_CONFIG.MAX_LOGS) { return apiError( 400, - 'export_too_large', + "export_too_large", `Export exceeds maximum limit of ${EXPORT_CONFIG.MAX_LOGS} logs. Please use filters to reduce the result set.`, ); } @@ -156,11 +156,11 @@ export async function GET(event: RequestEvent): Promise { const encoder = new TextEncoder(); - if (format === 'csv') { + if (format === "csv") { const stream = new ReadableStream({ async start(ctrl) { try { - ctrl.enqueue(encoder.encode(`${CSV_HEADERS.join(',')}\n`)); + ctrl.enqueue(encoder.encode(`${CSV_HEADERS.join(",")}\n`)); // Cursor-based pagination to avoid loading all rows at once let cursorTimestamp: Date | null = null; @@ -201,7 +201,7 @@ export async function GET(event: RequestEvent): Promise { for (const l of batch) { const values: unknown[] = [ l.id, - l.timestamp?.toISOString() ?? '', + l.timestamp?.toISOString() ?? "", l.level, l.message, l.metadata ? JSON.stringify(l.metadata) : null, @@ -211,7 +211,7 @@ export async function GET(event: RequestEvent): Promise { l.userId, l.ipAddress, ]; - ctrl.enqueue(encoder.encode(`${values.map(escapeCSVField).join(',')}\n`)); + ctrl.enqueue(encoder.encode(`${values.map(escapeCSVField).join(",")}\n`)); } fetched += batch.length; @@ -232,8 +232,8 @@ export async function GET(event: RequestEvent): Promise { return new Response(stream, { status: 200, headers: { - 'content-type': 'text/csv; charset=utf-8', - 'content-disposition': `attachment; filename="${filename}"`, + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="${filename}"`, }, }); } @@ -242,7 +242,7 @@ export async function GET(event: RequestEvent): Promise { const stream = new ReadableStream({ async start(ctrl) { try { - ctrl.enqueue(encoder.encode('[')); + ctrl.enqueue(encoder.encode("[")); let cursorTimestamp: Date | null = null; let cursorId: string | null = null; @@ -285,7 +285,7 @@ export async function GET(event: RequestEvent): Promise { id: l.id, level: l.level, message: l.message, - timestamp: l.timestamp?.toISOString() ?? '', + timestamp: l.timestamp?.toISOString() ?? "", metadata: l.metadata ? JSON.stringify(l.metadata) : null, sourceFile: l.sourceFile, lineNumber: l.lineNumber, @@ -293,7 +293,7 @@ export async function GET(event: RequestEvent): Promise { userId: l.userId, ipAddress: l.ipAddress, }; - ctrl.enqueue(encoder.encode(`${first ? '' : ','}${JSON.stringify(exportable)}`)); + ctrl.enqueue(encoder.encode(`${first ? "" : ","}${JSON.stringify(exportable)}`)); first = false; } @@ -305,7 +305,7 @@ export async function GET(event: RequestEvent): Promise { if (batch.length < EXPORT_BATCH_SIZE) break; } - ctrl.enqueue(encoder.encode(']')); + ctrl.enqueue(encoder.encode("]")); ctrl.close(); } catch (err) { ctrl.error(err); @@ -316,8 +316,8 @@ export async function GET(event: RequestEvent): Promise { return new Response(stream, { status: 200, headers: { - 'content-type': 'application/json', - 'content-disposition': `attachment; filename="${filename}"`, + "content-type": "application/json", + "content-disposition": `attachment; filename="${filename}"`, }, }); } diff --git a/src/routes/api/projects/[id]/logs/stream/+server.ts b/src/routes/api/projects/[id]/logs/stream/+server.ts index 375ec81..3c13507 100644 --- a/src/routes/api/projects/[id]/logs/stream/+server.ts +++ b/src/routes/api/projects/[id]/logs/stream/+server.ts @@ -1,9 +1,9 @@ -import { SSE_CONFIG } from '$lib/server/config/performance'; -import type { Log } from '$lib/server/db/schema'; -import { logEventBus } from '$lib/server/events'; -import { checkCsrfOrigin } from '$lib/server/utils/csrf'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import type { RequestEvent } from './$types'; +import { SSE_CONFIG } from "$lib/server/config/performance"; +import type { Log } from "$lib/server/db/schema"; +import { logEventBus } from "$lib/server/events"; +import { checkCsrfOrigin } from "$lib/server/utils/csrf"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import type { RequestEvent } from "./$types"; // Destructure SSE configuration for cleaner access const { BATCH_WINDOW_MS, MAX_BATCH_SIZE, HEARTBEAT_INTERVAL_MS } = SSE_CONFIG; @@ -60,20 +60,20 @@ export async function POST(event: RequestEvent): Promise { * Backpressure (slow consumer) drops the event but keeps the stream open. * 'closed' means the controller has errored and the stream should be torn down. */ - const sendEvent = (eventName: string, data: string): 'sent' | 'backpressure' | 'closed' => { - if (isClosed) return 'closed'; + const sendEvent = (eventName: string, data: string): "sent" | "backpressure" | "closed" => { + if (isClosed) return "closed"; try { const size = (controller as ReadableStreamDefaultController).desiredSize; if (size !== null && size <= 0) { // Stream backpressure: slow consumer, drop the event but keep the stream alive - console.debug('[logs/stream] backpressure detected, dropping batch'); - return 'backpressure'; + console.debug("[logs/stream] backpressure detected, dropping batch"); + return "backpressure"; } controller.enqueue(encoder.encode(formatSSEEvent(eventName, data))); - return 'sent'; + return "sent"; } catch { // Controller closed or errored - return 'closed'; + return "closed"; } }; @@ -82,8 +82,8 @@ export async function POST(event: RequestEvent): Promise { */ const flushBatch = () => { if (batch.length > 0) { - const result = sendEvent('logs', JSON.stringify(batch)); - if (result === 'closed') { + const result = sendEvent("logs", JSON.stringify(batch)); + if (result === "closed") { cleanup(); } // On 'backpressure', drop the batch but don't close the stream @@ -119,8 +119,8 @@ export async function POST(event: RequestEvent): Promise { // Set up heartbeat to keep connection alive const heartbeatInterval = setInterval(() => { - const result = sendEvent('heartbeat', JSON.stringify({ ts: Date.now() })); - if (result === 'closed') { + const result = sendEvent("heartbeat", JSON.stringify({ ts: Date.now() })); + if (result === "closed") { cleanup(); } }, HEARTBEAT_INTERVAL_MS); @@ -157,9 +157,9 @@ export async function POST(event: RequestEvent): Promise { return new Response(stream, { status: 200, headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", }, }); } diff --git a/src/routes/api/projects/[id]/regenerate/+server.ts b/src/routes/api/projects/[id]/regenerate/+server.ts index 05d0f50..d739d0b 100644 --- a/src/routes/api/projects/[id]/regenerate/+server.ts +++ b/src/routes/api/projects/[id]/regenerate/+server.ts @@ -1,11 +1,11 @@ -import { json } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { getDbClient } from '$lib/server/db/db'; -import { project } from '$lib/server/db/schema'; -import { generateApiKey, hashApiKey, invalidateApiKeyCacheByHash } from '$lib/server/utils/api-key'; -import { checkCsrfOrigin } from '$lib/server/utils/csrf'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { eq } from "drizzle-orm"; +import { getDbClient } from "$lib/server/db/db"; +import { project } from "$lib/server/db/schema"; +import { generateApiKey, hashApiKey, invalidateApiKeyCacheByHash } from "$lib/server/utils/api-key"; +import { checkCsrfOrigin } from "$lib/server/utils/csrf"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import type { RequestEvent } from "./$types"; /** * POST /api/projects/[id]/regenerate diff --git a/src/routes/api/projects/[id]/stats/+server.ts b/src/routes/api/projects/[id]/stats/+server.ts index 2d6084e..e19bc61 100644 --- a/src/routes/api/projects/[id]/stats/+server.ts +++ b/src/routes/api/projects/[id]/stats/+server.ts @@ -1,9 +1,9 @@ -import { json } from '@sveltejs/kit'; -import { and, count, eq, gte, lte, type SQL } from 'drizzle-orm'; -import { getDbClient } from '$lib/server/db/db'; -import { log } from '$lib/server/db/schema'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, count, eq, gte, lte, type SQL } from "drizzle-orm"; +import { getDbClient } from "$lib/server/db/db"; +import { log } from "$lib/server/db/schema"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import type { RequestEvent } from "./$types"; /** * GET /api/projects/[id]/stats @@ -48,8 +48,8 @@ export async function GET(event: RequestEvent): Promise { // Parse query parameters for time range const url = event.url; - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); // Parse time range const fromDate = fromParam ? new Date(fromParam) : null; diff --git a/src/routes/api/projects/[id]/stats/timeseries/+server.ts b/src/routes/api/projects/[id]/stats/timeseries/+server.ts index a5a5d88..6d6e191 100644 --- a/src/routes/api/projects/[id]/stats/timeseries/+server.ts +++ b/src/routes/api/projects/[id]/stats/timeseries/+server.ts @@ -1,14 +1,14 @@ -import { json } from '@sveltejs/kit'; -import { and, eq, gte, lte, type SQL, sql } from 'drizzle-orm'; -import type { TimeRange } from '$lib/components/time-range-picker.svelte'; -import { type BucketCountRow, getDbClient, getQueryRows } from '$lib/server/db/db'; -import { log } from '$lib/server/db/schema'; -import { isErrorResponse, requireProjectOwnership } from '$lib/server/utils/project-guard'; -import { getTimeRangeStart } from '$lib/utils/format'; -import { fillMissingBuckets, getTimeBucketConfig } from '$lib/utils/timeseries'; -import type { RequestEvent } from './$types'; +import { json } from "@sveltejs/kit"; +import { and, eq, gte, lte, type SQL, sql } from "drizzle-orm"; +import type { TimeRange } from "$lib/components/time-range-picker.svelte"; +import { type BucketCountRow, getDbClient, getQueryRows } from "$lib/server/db/db"; +import { log } from "$lib/server/db/schema"; +import { isErrorResponse, requireProjectOwnership } from "$lib/server/utils/project-guard"; +import { getTimeRangeStart } from "$lib/utils/format"; +import { fillMissingBuckets, getTimeBucketConfig } from "$lib/utils/timeseries"; +import type { RequestEvent } from "./$types"; -const VALID_RANGES: TimeRange[] = ['15m', '1h', '24h', '7d']; +const VALID_RANGES: TimeRange[] = ["15m", "1h", "24h", "7d"]; /** * GET /api/projects/[id]/stats/timeseries @@ -43,13 +43,13 @@ export async function GET(event: RequestEvent): Promise { const projectId = event.params.id; // Parse range parameter (default to 24h) - const rangeParam = event.url.searchParams.get('range') || '24h'; + const rangeParam = event.url.searchParams.get("range") || "24h"; const range: TimeRange = VALID_RANGES.includes(rangeParam as TimeRange) ? (rangeParam as TimeRange) - : '24h'; + : "24h"; // Parse optional from parameter (to sync with page server's time range) - const fromParam = event.url.searchParams.get('from'); + const fromParam = event.url.searchParams.get("from"); // Calculate time boundaries // If 'from' is provided, use it to ensure consistency with page server diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index a008d8d..60f5d06 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,5 +1,5 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; /** * Login page server load function @@ -8,7 +8,7 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { // If user is already authenticated, redirect to dashboard if (locals.user && locals.session) { - throw redirect(303, '/'); + throw redirect(303, "/"); } return {}; diff --git a/src/routes/login/__tests__/login-navigation.component.test.ts b/src/routes/login/__tests__/login-navigation.component.test.ts index aa4a708..7a10f1c 100644 --- a/src/routes/login/__tests__/login-navigation.component.test.ts +++ b/src/routes/login/__tests__/login-navigation.component.test.ts @@ -1,6 +1,6 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; // Use vi.hoisted to ensure mock functions are hoisted with the vi.mock calls const { mockGoto, mockInvalidateAll } = vi.hoisted(() => ({ @@ -13,19 +13,19 @@ const { mockGoto, mockInvalidateAll } = vi.hoisted(() => ({ let capturedOnSuccess: ((context?: any) => void | Promise) | undefined; // Mock $app/navigation module -vi.mock('$app/navigation', () => ({ +vi.mock("$app/navigation", () => ({ goto: mockGoto, invalidateAll: mockInvalidateAll, })); // Mock authClient with username-based authentication -vi.mock('$lib/auth-client', () => ({ +vi.mock("$lib/auth-client", () => ({ authClient: { signIn: { username: vi.fn().mockImplementation((_credentials, callbacks) => { capturedOnSuccess = callbacks?.onSuccess; return Promise.resolve({ - data: { user: { id: '1', username: 'admin' } }, + data: { user: { id: "1", username: "admin" } }, error: null, }); }), @@ -34,10 +34,10 @@ vi.mock('$lib/auth-client', () => ({ })); // Import component after mocks are set up -import { authClient } from '$lib/auth-client'; -import LoginPage from '../+page.svelte'; +import { authClient } from "$lib/auth-client"; +import LoginPage from "../+page.svelte"; -describe('Login Page Navigation', () => { +describe("Login Page Navigation", () => { const user = userEvent.setup(); beforeEach(() => { @@ -49,18 +49,18 @@ describe('Login Page Navigation', () => { cleanup(); }); - describe('onSuccess callback navigation', () => { - it('should call invalidateAll before goto on successful login', async () => { + describe("onSuccess callback navigation", () => { + it("should call invalidateAll before goto on successful login", async () => { render(LoginPage); // Fill in the form with username (not email) const usernameInput = screen.getByLabelText(/username/i); const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.clear(usernameInput); - await user.type(usernameInput, 'admin'); - await user.type(passwordInput, 'password123'); + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password123"); await user.click(submitButton); // Wait for the sign in to be called @@ -78,7 +78,7 @@ describe('Login Page Navigation', () => { expect(mockInvalidateAll).toHaveBeenCalled(); // Verify goto is called with '/' - expect(mockGoto).toHaveBeenCalledWith('/'); + expect(mockGoto).toHaveBeenCalledWith("/"); // Verify the order: invalidateAll should complete before goto const invalidateCallOrder = mockInvalidateAll.mock.invocationCallOrder[0]!; @@ -86,18 +86,18 @@ describe('Login Page Navigation', () => { expect(invalidateCallOrder).toBeLessThan(gotoCallOrder); }); - it('should use goto instead of window.location.href for navigation', async () => { + it("should use goto instead of window.location.href for navigation", async () => { // This test verifies that goto is used instead of window.location.href render(LoginPage); // Fill in the form with username const usernameInput = screen.getByLabelText(/username/i); const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.clear(usernameInput); - await user.type(usernameInput, 'admin'); - await user.type(passwordInput, 'password123'); + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password123"); await user.click(submitButton); // Wait for the sign in to be called @@ -109,19 +109,19 @@ describe('Login Page Navigation', () => { await capturedOnSuccess?.(); // Verify goto is called - expect(mockGoto).toHaveBeenCalledWith('/'); + expect(mockGoto).toHaveBeenCalledWith("/"); }); }); - describe('fallback redirect when data.user exists', () => { - it('should use invalidateAll + goto for fallback redirect', async () => { + describe("fallback redirect when data.user exists", () => { + it("should use invalidateAll + goto for fallback redirect", async () => { // Mock signIn to return user data // The fallback redirect happens when data.user exists after signIn completes vi.mocked(authClient.signIn.username).mockImplementationOnce( async (_credentials, callbacks) => { capturedOnSuccess = callbacks?.onSuccess; // Return data with user - the component checks this for fallback redirect - return { data: { user: { id: '1', username: 'admin' } }, error: null }; + return { data: { user: { id: "1", username: "admin" } }, error: null }; }, ); @@ -130,11 +130,11 @@ describe('Login Page Navigation', () => { // Fill in the form with username const usernameInput = screen.getByLabelText(/username/i); const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.clear(usernameInput); - await user.type(usernameInput, 'admin'); - await user.type(passwordInput, 'password123'); + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password123"); await user.click(submitButton); // Wait for navigation to be triggered from the fallback path @@ -145,33 +145,33 @@ describe('Login Page Navigation', () => { { timeout: 2000 }, ); - expect(mockGoto).toHaveBeenCalledWith('/'); + expect(mockGoto).toHaveBeenCalledWith("/"); }); }); - describe('Enter key submission', () => { - it('should submit via form submission and not keydown handler', async () => { + describe("Enter key submission", () => { + it("should submit via form submission and not keydown handler", async () => { render(LoginPage); const usernameInput = screen.getByLabelText(/username/i); const passwordInput = screen.getByLabelText(/password/i); - const form = passwordInput.closest('form'); + const form = passwordInput.closest("form"); expect(form).toBeTruthy(); if (!form) { - throw new Error('Login form not found'); + throw new Error("Login form not found"); } await user.clear(usernameInput); - await user.type(usernameInput, 'admin'); - await user.type(passwordInput, 'password123'); + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password123"); passwordInput.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), ); expect(authClient.signIn.username).not.toHaveBeenCalled(); - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); await waitFor(() => { expect(authClient.signIn.username).toHaveBeenCalledTimes(1); diff --git a/src/routes/v1/ingest/+server.ts b/src/routes/v1/ingest/+server.ts index bf0f5f6..64891de 100644 --- a/src/routes/v1/ingest/+server.ts +++ b/src/routes/v1/ingest/+server.ts @@ -1,19 +1,19 @@ -import { json, type RequestHandler } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import { API_CONFIG } from '$lib/server/config/performance'; -import { getDbClient } from '$lib/server/db/db'; -import { log, project } from '$lib/server/db/schema'; -import { logEventBus } from '$lib/server/events'; -import { ApiKeyError, validateApiKey } from '$lib/server/utils/api-key'; -import { requireJsonContentType } from '$lib/server/utils/content-type'; +import { json, type RequestHandler } from "@sveltejs/kit"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { API_CONFIG } from "$lib/server/config/performance"; +import { getDbClient } from "$lib/server/db/db"; +import { log, project } from "$lib/server/db/schema"; +import { logEventBus } from "$lib/server/events"; +import { ApiKeyError, validateApiKey } from "$lib/server/utils/api-key"; +import { requireJsonContentType } from "$lib/server/utils/content-type"; import { assignIncidentIds, prepareLogsForIncidents, upsertIncidentsForPreparedLogs, -} from '$lib/server/utils/incidents'; -import { checkRateLimit, INGEST_RPM } from '$lib/server/utils/rate-limit'; -import { parseSimpleIngestRequest, SimpleIngestError } from '$lib/server/utils/simple-ingest'; +} from "$lib/server/utils/incidents"; +import { checkRateLimit, INGEST_RPM } from "$lib/server/utils/rate-limit"; +import { parseSimpleIngestRequest, SimpleIngestError } from "$lib/server/utils/simple-ingest"; /** * POST /v1/ingest (Simple JSON API) @@ -46,11 +46,11 @@ export const POST: RequestHandler = async ({ request, locals }) => { .from(project) .where(eq(project.id, projectId)); if (!projectRow) { - throw new ApiKeyError(401, 'Invalid API key'); + throw new ApiKeyError(401, "Invalid API key"); } } catch (err) { if (err instanceof ApiKeyError) { - return json({ error: 'unauthorized', message: err.message }, { status: err.status }); + return json({ error: "unauthorized", message: err.message }, { status: err.status }); } throw err; } @@ -58,8 +58,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Apply rate limiting per project if (!checkRateLimit(`ingest:${projectId}`, INGEST_RPM)) { return json( - { error: 'rate_limited', message: 'Rate limit exceeded. Retry in 60 seconds.' }, - { status: 429, headers: { 'Retry-After': '60' } }, + { error: "rate_limited", message: "Rate limit exceeded. Retry in 60 seconds." }, + { status: 429, headers: { "Retry-After": "60" } }, ); } @@ -69,7 +69,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { body = await request.json(); } catch { return json( - { error: 'invalid_json', message: 'Request body must be valid JSON' }, + { error: "invalid_json", message: "Request body must be valid JSON" }, { status: 400 }, ); } @@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (Array.isArray(body) && body.length > API_CONFIG.BATCH_INSERT_LIMIT) { return json( { - error: 'batch_too_large', + error: "batch_too_large", message: `Batch exceeds maximum limit of ${API_CONFIG.BATCH_INSERT_LIMIT} logs. Received ${body.length} logs.`, }, { status: 400 }, @@ -91,7 +91,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { result = parseSimpleIngestRequest(body); } catch (err) { if (err instanceof SimpleIngestError) { - return json({ error: 'validation_error', message: err.message }, { status: 400 }); + return json({ error: "validation_error", message: err.message }, { status: 400 }); } throw err; } @@ -101,7 +101,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (records.length > API_CONFIG.BATCH_INSERT_LIMIT) { return json( { - error: 'batch_too_large', + error: "batch_too_large", message: `Batch exceeds maximum limit of ${API_CONFIG.BATCH_INSERT_LIMIT} logs. Received ${records.length} logs.`, }, { status: 400 }, diff --git a/src/routes/v1/logs/+server.ts b/src/routes/v1/logs/+server.ts index 7e70cc7..3473d5f 100644 --- a/src/routes/v1/logs/+server.ts +++ b/src/routes/v1/logs/+server.ts @@ -1,24 +1,24 @@ -import { json, type RequestHandler } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import { API_CONFIG } from '$lib/server/config/performance'; -import { getDbClient } from '$lib/server/db/db'; -import { log, project } from '$lib/server/db/schema'; -import { logEventBus } from '$lib/server/events'; -import { ApiKeyError, validateApiKey } from '$lib/server/utils/api-key'; -import { requireJsonContentType } from '$lib/server/utils/content-type'; +import { json, type RequestHandler } from "@sveltejs/kit"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { API_CONFIG } from "$lib/server/config/performance"; +import { getDbClient } from "$lib/server/db/db"; +import { log, project } from "$lib/server/db/schema"; +import { logEventBus } from "$lib/server/events"; +import { ApiKeyError, validateApiKey } from "$lib/server/utils/api-key"; +import { requireJsonContentType } from "$lib/server/utils/content-type"; import { assignIncidentIds, prepareLogsForIncidents, upsertIncidentsForPreparedLogs, -} from '$lib/server/utils/incidents'; +} from "$lib/server/utils/incidents"; import { mapOtlpAttributesToLogColumns, type NormalizedOtlpLogsResult, normalizeOtlpLogsRequest, OtlpValidationError, -} from '$lib/server/utils/otlp'; -import { checkRateLimit, INGEST_RPM } from '$lib/server/utils/rate-limit'; +} from "$lib/server/utils/otlp"; +import { checkRateLimit, INGEST_RPM } from "$lib/server/utils/rate-limit"; function buildIngestResponse(accepted: number, rejected: number, errors: string[]) { const response: { accepted: number; rejected?: number; errors?: string[] } = { accepted }; @@ -53,20 +53,20 @@ export const POST: RequestHandler = async ({ request, locals }) => { .from(project) .where(eq(project.id, projectId)); if (!projectRow) { - throw new ApiKeyError(401, 'Invalid API key'); + throw new ApiKeyError(401, "Invalid API key"); } } catch (err) { if (err instanceof ApiKeyError) { - return json({ error: 'unauthorized', message: err.message }, { status: err.status }); + return json({ error: "unauthorized", message: err.message }, { status: err.status }); } throw err; } // Apply rate limiting per project if (!checkRateLimit(`ingest:${projectId}`, INGEST_RPM)) { - return new Response(JSON.stringify({ error: 'rate_limited' }), { + return new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, - headers: { 'Content-Type': 'application/json', 'Retry-After': '60' }, + headers: { "Content-Type": "application/json", "Retry-After": "60" }, }); } @@ -75,7 +75,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { body = await request.json(); } catch { return json( - { error: 'invalid_json', message: 'Request body must be valid JSON' }, + { error: "invalid_json", message: "Request body must be valid JSON" }, { status: 400 }, ); } @@ -88,7 +88,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { normalized = normalizeOtlpLogsRequest(body); } catch (err) { if (err instanceof OtlpValidationError) { - return json({ error: 'validation_error', message: err.message }, { status: 400 }); + return json({ error: "validation_error", message: err.message }, { status: 400 }); } throw err; } @@ -98,7 +98,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (records.length > API_CONFIG.BATCH_INSERT_LIMIT) { return json( { - error: 'batch_too_large', + error: "batch_too_large", message: `Batch exceeds maximum limit of ${API_CONFIG.BATCH_INSERT_LIMIT} logs. Received ${records.length} logs.`, }, { status: 400 }, diff --git a/svelte.config.js b/svelte.config.js index 8d8cee4..469f7e0 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,5 +1,5 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; -import adapter from 'svelte-adapter-bun'; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import adapter from "svelte-adapter-bun"; /** @type {import('@sveltejs/kit').Config} */ const config = { diff --git a/tests/README.md b/tests/README.md index 50d4234..a68448f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,6 +5,7 @@ This project follows the Testing Trophy methodology, prioritizing integration te ## Test Structure ### Unit Tests (`.unit.test.ts`) + Located alongside source files in `src/`. Tests pure functions and utilities in isolation. ```bash @@ -12,6 +13,7 @@ bun run test:unit ``` ### Integration Tests (`.integration.test.ts`) + Located in `tests/integration/`. Tests server-side code with database interactions using PGlite. ```bash @@ -19,6 +21,7 @@ bun run test:integration ``` ### Browser Tests (`.browser.test.ts`) + Tests Svelte components in a real browser environment using Playwright. ```bash @@ -26,6 +29,7 @@ bun run test:browser ``` ### E2E Tests + Located in `tests/e2e/`. Full end-to-end tests using Playwright across multiple browsers. ```bash @@ -66,12 +70,12 @@ Integration tests use PGlite, an in-memory PostgreSQL database. Test utilities a ### Example Integration Test ```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { setupTestDatabase } from '../../src/lib/server/db/test-utils'; -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import * as schema from '../../src/lib/server/db/schema'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { setupTestDatabase } from "../../src/lib/server/db/test-utils"; +import type { PgliteDatabase } from "drizzle-orm/pglite"; +import * as schema from "../../src/lib/server/db/schema"; -describe('My Integration Test', () => { +describe("My Integration Test", () => { let db: PgliteDatabase; let cleanup: () => Promise; @@ -85,7 +89,7 @@ describe('My Integration Test', () => { await cleanup(); }); - it('should test database interaction', async () => { + it("should test database interaction", async () => { // Your test here }); }); @@ -94,6 +98,7 @@ describe('My Integration Test', () => { ## Coverage Thresholds The project maintains the following coverage thresholds: + - Lines: 75% - Functions: 75% - Branches: 65% diff --git a/tests/e2e/auth-guard.spec.ts b/tests/e2e/auth-guard.spec.ts index b1fe484..b8d50d3 100644 --- a/tests/e2e/auth-guard.spec.ts +++ b/tests/e2e/auth-guard.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; /** * E2E tests for App Layout with Auth Guard @@ -9,16 +9,16 @@ import { expect, test } from '@playwright/test'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ -async function login(page: import('@playwright/test').Page) { - await page.goto('/login'); - await page.waitForSelector('form'); +async function login(page: import("@playwright/test").Page) { + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -34,65 +34,65 @@ async function login(page: import('@playwright/test').Page) { await expect(passwordInput).toHaveValue(TEST_USER.password); // Click sign in and wait for redirect - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } -test.describe('Auth Guard - Unauthenticated Access', () => { - test('should redirect to /login when accessing root path unauthenticated', async ({ page }) => { +test.describe("Auth Guard - Unauthenticated Access", () => { + test("should redirect to /login when accessing root path unauthenticated", async ({ page }) => { // Navigate directly to protected root path without authentication - await page.goto('/'); + await page.goto("/"); // Should be redirected to login page await expect(page).toHaveURL(/\/login/); }); - test('should redirect to /login when accessing /projects/[id] unauthenticated', async ({ + test("should redirect to /login when accessing /projects/[id] unauthenticated", async ({ page, }) => { // Navigate to a protected project route without authentication - await page.goto('/projects/some-project-id'); + await page.goto("/projects/some-project-id"); // Should be redirected to login page await expect(page).toHaveURL(/\/login/); }); }); -test.describe('App Layout - Header', () => { +test.describe("App Layout - Header", () => { test.beforeEach(async ({ page }) => { // Login before each test in this block await login(page); }); - test('should render header with application title', async ({ page }) => { + test("should render header with application title", async ({ page }) => { // Header should have the app title/logo - const header = page.locator('header'); + const header = page.locator("header"); await expect(header).toBeVisible(); await expect(header.getByText(/logwell/i)).toBeVisible(); }); - test('should render header with logout button', async ({ page }) => { + test("should render header with logout button", async ({ page }) => { // Header should have logout button - const header = page.locator('header'); + const header = page.locator("header"); await expect(header).toBeVisible(); - const logoutButton = header.getByRole('button', { name: /logout|sign out/i }); + const logoutButton = header.getByRole("button", { name: /logout|sign out/i }); await expect(logoutButton).toBeVisible(); }); - test('should render header with theme toggle', async ({ page }) => { + test("should render header with theme toggle", async ({ page }) => { // Header should have theme toggle - const header = page.locator('header'); + const header = page.locator("header"); await expect(header).toBeVisible(); // Theme toggle button (sun/moon icon) - const themeToggle = header.getByRole('button', { name: /toggle theme|theme/i }); + const themeToggle = header.getByRole("button", { name: /toggle theme|theme/i }); await expect(themeToggle).toBeVisible(); }); - test('should display user info in header', async ({ page }) => { + test("should display user info in header", async ({ page }) => { // Header should show logged in user info - const header = page.locator('header'); + const header = page.locator("header"); await expect(header).toBeVisible(); // Should show user email or name @@ -100,51 +100,51 @@ test.describe('App Layout - Header', () => { }); }); -test.describe('Logout Functionality', () => { +test.describe("Logout Functionality", () => { test.beforeEach(async ({ page }) => { // Login before each test in this block await login(page); }); - test('should logout and redirect to /login when clicking logout button', async ({ page }) => { + test("should logout and redirect to /login when clicking logout button", async ({ page }) => { // Find and click logout button - const header = page.locator('header'); - const logoutButton = header.getByRole('button', { name: /logout|sign out/i }); + const header = page.locator("header"); + const logoutButton = header.getByRole("button", { name: /logout|sign out/i }); await logoutButton.click(); // Should be redirected to login page await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); }); - test('should not be able to access protected routes after logout', async ({ page }) => { + test("should not be able to access protected routes after logout", async ({ page }) => { // First logout - const header = page.locator('header'); - const logoutButton = header.getByRole('button', { name: /logout|sign out/i }); + const header = page.locator("header"); + const logoutButton = header.getByRole("button", { name: /logout|sign out/i }); await logoutButton.click(); // Wait for redirect to login await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); // Try to access protected route directly - await page.goto('/'); + await page.goto("/"); // Should still be on login page (redirected) await expect(page).toHaveURL(/\/login/); }); }); -test.describe('App Layout - Navigation', () => { +test.describe("App Layout - Navigation", () => { test.beforeEach(async ({ page }) => { // Login before each test in this block await login(page); }); - test('should have navigation link to dashboard/home', async ({ page }) => { + test("should have navigation link to dashboard/home", async ({ page }) => { // Header should have a way to navigate home - const header = page.locator('header'); + const header = page.locator("header"); // Either the logo/title is clickable or there's a home link - const homeLink = header.getByRole('link', { name: /home|dashboard|logwell/i }); + const homeLink = header.getByRole("link", { name: /home|dashboard|logwell/i }); await expect(homeLink).toBeVisible(); }); }); diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 837c277..596e7f3 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from '@playwright/test'; +import { expect, type Page, test } from "@playwright/test"; /** * E2E tests for Dashboard (Project List) @@ -9,16 +9,16 @@ import { expect, type Page, test } from '@playwright/test'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -31,8 +31,8 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** @@ -40,7 +40,7 @@ async function login(page: Page) { * Returns the created project data including apiKey */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -60,7 +60,7 @@ async function deleteProject(page: Page, projectId: string) { * Helper to get all projects and delete them */ async function cleanupProjects(page: Page) { - const response = await page.request.get('/api/projects'); + const response = await page.request.get("/api/projects"); if (response.ok()) { const { projects } = await response.json(); for (const project of projects) { @@ -69,7 +69,7 @@ async function cleanupProjects(page: Page) { } } -test.describe('Dashboard - Empty State', () => { +test.describe("Dashboard - Empty State", () => { // Allow retries for this describe block due to potential cold start issues test.describe.configure({ retries: 1 }); @@ -78,10 +78,10 @@ test.describe('Dashboard - Empty State', () => { // Clean up any existing projects await cleanupProjects(page); // Refresh to see empty state - await page.goto('/'); + await page.goto("/"); }); - test('should show empty state when no projects exist', async ({ page }) => { + test("should show empty state when no projects exist", async ({ page }) => { // Dashboard should display empty state message await expect(page.getByText(/no projects/i)).toBeVisible(); @@ -89,15 +89,15 @@ test.describe('Dashboard - Empty State', () => { await expect(page.getByText(/create.*first.*project/i)).toBeVisible(); }); - test('should have create project button in empty state', async ({ page }) => { + test("should have create project button in empty state", async ({ page }) => { // In empty state, we have two Create Project buttons (header + empty state) // Both should be visible - const createButtons = page.getByRole('button', { name: /create.*project/i }); + const createButtons = page.getByRole("button", { name: /create.*project/i }); await expect(createButtons).toHaveCount(2); }); }); -test.describe('Dashboard - Project Display', () => { +test.describe("Dashboard - Project Display", () => { let createdProjects: Array<{ id: string; name: string }> = []; test.beforeEach(async ({ page }) => { @@ -107,12 +107,12 @@ test.describe('Dashboard - Project Display', () => { createdProjects = []; // Create test projects - const project1 = await createProject(page, 'test-project-1'); - const project2 = await createProject(page, 'test-project-2'); + const project1 = await createProject(page, "test-project-1"); + const project2 = await createProject(page, "test-project-2"); createdProjects.push(project1, project2); // Navigate to dashboard to see projects - await page.goto('/'); + await page.goto("/"); }); test.afterEach(async ({ page }) => { @@ -122,13 +122,13 @@ test.describe('Dashboard - Project Display', () => { } }); - test('should display project cards', async ({ page }) => { + test("should display project cards", async ({ page }) => { // Should display project cards for each project - await expect(page.getByText('test-project-1')).toBeVisible(); - await expect(page.getByText('test-project-2')).toBeVisible(); + await expect(page.getByText("test-project-1")).toBeVisible(); + await expect(page.getByText("test-project-2")).toBeVisible(); }); - test('should display project cards with log count', async ({ page }) => { + test("should display project cards with log count", async ({ page }) => { // Each project card should show log count (0 logs initially) const cards = page.locator('[data-testid="project-card"]'); await expect(cards).toHaveCount(2); @@ -137,14 +137,14 @@ test.describe('Dashboard - Project Display', () => { await expect(page.getByText(/0 logs/i).first()).toBeVisible(); }); - test('should display View Logs button on project cards', async ({ page }) => { + test("should display View Logs button on project cards", async ({ page }) => { // Each card should have View Logs button - const viewLogsButtons = page.getByRole('link', { name: /view logs/i }); + const viewLogsButtons = page.getByRole("link", { name: /view logs/i }); await expect(viewLogsButtons).toHaveCount(2); }); }); -test.describe('Dashboard - Navigation', () => { +test.describe("Dashboard - Navigation", () => { let testProject: { id: string; name: string }; test.beforeEach(async ({ page }) => { @@ -152,17 +152,17 @@ test.describe('Dashboard - Navigation', () => { await cleanupProjects(page); // Create a test project - testProject = await createProject(page, 'navigation-test-project'); - await page.goto('/'); + testProject = await createProject(page, "navigation-test-project"); + await page.goto("/"); }); test.afterEach(async ({ page }) => { await deleteProject(page, testProject.id); }); - test('should navigate to project page on View Logs click', async ({ page }) => { + test("should navigate to project page on View Logs click", async ({ page }) => { // Find and click View Logs button - const viewLogsButton = page.getByRole('link', { name: /view logs/i }); + const viewLogsButton = page.getByRole("link", { name: /view logs/i }); await expect(viewLogsButton).toBeVisible(); await viewLogsButton.click(); @@ -170,7 +170,7 @@ test.describe('Dashboard - Navigation', () => { await expect(page).toHaveURL(new RegExp(`/projects/${testProject.id}`)); }); - test('should navigate to project page on card click', async ({ page }) => { + test("should navigate to project page on card click", async ({ page }) => { // Click the project card (not just the button) const projectCard = page.locator('[data-testid="project-card"]').first(); await projectCard.click(); @@ -180,26 +180,26 @@ test.describe('Dashboard - Navigation', () => { }); }); -test.describe('Dashboard - Create Project Modal', () => { +test.describe("Dashboard - Create Project Modal", () => { test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - await page.goto('/'); + await page.goto("/"); }); - test('should open create project modal when clicking create button', async ({ page }) => { + test("should open create project modal when clicking create button", async ({ page }) => { // Click first create project button (header) - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Modal should be visible - await expect(page.getByRole('dialog')).toBeVisible(); - await expect(page.getByRole('dialog').getByText(/create.*project/i)).toBeVisible(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByRole("dialog").getByText(/create.*project/i)).toBeVisible(); }); - test('should have project name input in create modal', async ({ page }) => { + test("should have project name input in create modal", async ({ page }) => { // Open modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Should have name input @@ -207,130 +207,130 @@ test.describe('Dashboard - Create Project Modal', () => { await expect(nameInput).toBeVisible(); }); - test('should create project and show in list', async ({ page }) => { + test("should create project and show in list", async ({ page }) => { // Open create modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Fill in project name const nameInput = page.getByLabel(/name/i); - await nameInput.fill('my-new-project'); + await nameInput.fill("my-new-project"); // Submit the form - use the one in the dialog await page - .getByRole('dialog') - .getByRole('button', { name: /^create$/i }) + .getByRole("dialog") + .getByRole("button", { name: /^create$/i }) .click(); // The one-time API key reveal modal appears with the new plaintext key - await expect(page.getByTestId('api-key-reveal-content')).toBeVisible(); - await expect(page.getByTestId('api-key-reveal-value')).toContainText('lw_'); - await page.getByTestId('api-key-reveal-close').click(); - await expect(page.getByTestId('api-key-reveal-content')).not.toBeVisible(); + await expect(page.getByTestId("api-key-reveal-content")).toBeVisible(); + await expect(page.getByTestId("api-key-reveal-value")).toContainText("lw_"); + await page.getByTestId("api-key-reveal-close").click(); + await expect(page.getByTestId("api-key-reveal-content")).not.toBeVisible(); // Project should appear in the list (use testid to avoid matching toast notification) await expect( - page.locator('[data-testid="project-card"]').getByText('my-new-project'), + page.locator('[data-testid="project-card"]').getByText("my-new-project"), ).toBeVisible(); // Cleanup - const response = await page.request.get('/api/projects'); + const response = await page.request.get("/api/projects"); const { projects } = await response.json(); - const newProject = projects.find((p: { name: string }) => p.name === 'my-new-project'); + const newProject = projects.find((p: { name: string }) => p.name === "my-new-project"); if (newProject) { await deleteProject(page, newProject.id); } }); - test('should show validation error for empty project name', async ({ page }) => { + test("should show validation error for empty project name", async ({ page }) => { // Open create modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Try to submit without name - use the one in the dialog await page - .getByRole('dialog') - .getByRole('button', { name: /^create$/i }) + .getByRole("dialog") + .getByRole("button", { name: /^create$/i }) .click(); // Should show validation error await expect(page.getByText(/name.*required|required/i)).toBeVisible(); }); - test('should show error for duplicate project name', async ({ page }) => { + test("should show error for duplicate project name", async ({ page }) => { // First create a project - await createProject(page, 'duplicate-test'); + await createProject(page, "duplicate-test"); // Refresh page - await page.goto('/'); + await page.goto("/"); // Open create modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Try to create with same name const nameInput = page.getByLabel(/name/i); - await nameInput.fill('duplicate-test'); + await nameInput.fill("duplicate-test"); await page - .getByRole('dialog') - .getByRole('button', { name: /^create$/i }) + .getByRole("dialog") + .getByRole("button", { name: /^create$/i }) .click(); // Should show duplicate error - await expect(page.getByTestId('error-message')).toBeVisible(); + await expect(page.getByTestId("error-message")).toBeVisible(); // Cleanup - const response = await page.request.get('/api/projects'); + const response = await page.request.get("/api/projects"); const { projects } = await response.json(); - const testProject = projects.find((p: { name: string }) => p.name === 'duplicate-test'); + const testProject = projects.find((p: { name: string }) => p.name === "duplicate-test"); if (testProject) { await deleteProject(page, testProject.id); } }); - test('should close modal on escape key', async ({ page }) => { + test("should close modal on escape key", async ({ page }) => { // Open create modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Press escape - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); // Modal should close - await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole("dialog")).not.toBeVisible(); }); - test('should close modal on cancel button', async ({ page }) => { + test("should close modal on cancel button", async ({ page }) => { // Open create modal - const createButton = page.getByRole('button', { name: /create.*project/i }).first(); + const createButton = page.getByRole("button", { name: /create.*project/i }).first(); await createButton.click(); // Click cancel - await page.getByRole('button', { name: /cancel/i }).click(); + await page.getByRole("button", { name: /cancel/i }).click(); // Modal should close - await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole("dialog")).not.toBeVisible(); }); }); -test.describe('Dashboard - Loading State', () => { +test.describe("Dashboard - Loading State", () => { test.beforeEach(async ({ page }) => { await login(page); }); - test('should show loading state while fetching projects', async ({ page }) => { + test("should show loading state while fetching projects", async ({ page }) => { // This test verifies loading state exists // We intercept the API to delay response - await page.route('/api/projects', async (route) => { + await page.route("/api/projects", async (route) => { await new Promise((resolve) => setTimeout(resolve, 500)); await route.continue(); }); - await page.goto('/'); + await page.goto("/"); // Should show some loading indicator (skeleton or spinner) // Note: This might be flaky if the request is too fast // Just verify the page loads without error - await expect(page).toHaveURL('/'); + await expect(page).toHaveURL("/"); }); }); diff --git a/tests/e2e/export-logs.spec.ts b/tests/e2e/export-logs.spec.ts index 57725e0..df71bea 100644 --- a/tests/e2e/export-logs.spec.ts +++ b/tests/e2e/export-logs.spec.ts @@ -1,5 +1,5 @@ -import { expect, type Page, test } from '@playwright/test'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { ingestOtlpLogs } from "./helpers/otlp"; /** * E2E tests for Log Export Feature @@ -10,16 +10,16 @@ import { ingestOtlpLogs } from './helpers/otlp'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -32,8 +32,8 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** @@ -41,7 +41,7 @@ async function login(page: Page) { * Returns the created project data including apiKey */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -63,7 +63,7 @@ async function ingestLogsBatch( page: Page, apiKey: string, logs: Array<{ - level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + level: "debug" | "info" | "warn" | "error" | "fatal"; message: string; metadata?: Record; sourceFile?: string; @@ -74,13 +74,13 @@ async function ingestLogsBatch( }>, ) { const otlpLogs = logs.map((log) => { - const attributes: Record = { ...(log.metadata ?? {}) }; + const attributes: Record = { ...log.metadata }; - if (log.sourceFile) attributes['code.filepath'] = log.sourceFile; - if (log.lineNumber !== undefined) attributes['code.lineno'] = log.lineNumber; - if (log.requestId) attributes['request.id'] = log.requestId; - if (log.userId) attributes['enduser.id'] = log.userId; - if (log.ipAddress) attributes['client.address'] = log.ipAddress; + if (log.sourceFile) attributes["code.filepath"] = log.sourceFile; + if (log.lineNumber !== undefined) attributes["code.lineno"] = log.lineNumber; + if (log.requestId) attributes["request.id"] = log.requestId; + if (log.userId) attributes["enduser.id"] = log.userId; + if (log.ipAddress) attributes["client.address"] = log.ipAddress; return { level: log.level, message: log.message, attributes }; }); @@ -92,11 +92,11 @@ async function ingestLogsBatch( * Helper to parse CSV content into rows */ function parseCsv(csvContent: string): string[][] { - const lines = csvContent.trim().split('\n'); + const lines = csvContent.trim().split("\n"); return lines.map((line) => { // Simple CSV parsing - handles quoted fields const fields: string[] = []; - let current = ''; + let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { @@ -104,9 +104,9 @@ function parseCsv(csvContent: string): string[][] { if (char === '"') { inQuotes = !inQuotes; - } else if (char === ',' && !inQuotes) { + } else if (char === "," && !inQuotes) { fields.push(current.trim()); - current = ''; + current = ""; } else { current += char; } @@ -116,7 +116,7 @@ function parseCsv(csvContent: string): string[][] { }); } -test.describe('Log Export - Visibility', () => { +test.describe("Log Export - Visibility", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -132,11 +132,11 @@ test.describe('Log Export - Visibility', () => { } }); - test('should show export button when logs exist', async ({ page }) => { + test("should show export button when logs exist", async ({ page }) => { // Ingest some logs await ingestLogsBatch(page, testProject.apiKey, [ - { level: 'info', message: 'Test log for export visibility' }, - { level: 'error', message: 'Another test log' }, + { level: "info", message: "Test log for export visibility" }, + { level: "error", message: "Another test log" }, ]); await page.goto(`/projects/${testProject.id}`); @@ -146,13 +146,13 @@ test.describe('Log Export - Visibility', () => { await expect(exportButton).toBeVisible({ timeout: 5000 }); }); - test('should hide export button when no logs exist', async ({ page }) => { + test("should hide export button when no logs exist", async ({ page }) => { // Navigate to project with no logs await page.goto(`/projects/${testProject.id}`); // Wait for page to load and verify empty state (desktop table cell) await expect(page.locator('[data-testid="log-table"]')).toBeVisible(); - await expect(page.getByRole('cell', { name: 'No logs yet' })).toBeVisible(); + await expect(page.getByRole("cell", { name: "No logs yet" })).toBeVisible(); // Export button should not be visible const exportButton = page.locator('[data-testid="export-button"]'); @@ -160,7 +160,7 @@ test.describe('Log Export - Visibility', () => { }); }); -test.describe('Log Export - Format Selection', () => { +test.describe("Log Export - Format Selection", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -171,7 +171,7 @@ test.describe('Log Export - Format Selection', () => { // Ingest test logs await ingestLogsBatch(page, testProject.apiKey, [ - { level: 'info', message: 'Format selection test log' }, + { level: "info", message: "Format selection test log" }, ]); }); @@ -181,7 +181,7 @@ test.describe('Log Export - Format Selection', () => { } }); - test('should show format dropdown when clicking export button', async ({ page }) => { + test("should show format dropdown when clicking export button", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Click export button @@ -194,7 +194,7 @@ test.describe('Log Export - Format Selection', () => { await expect(page.locator('[data-testid="export-json"]')).toBeVisible(); }); - test('should close dropdown when pressing Escape', async ({ page }) => { + test("should close dropdown when pressing Escape", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open dropdown @@ -203,14 +203,14 @@ test.describe('Log Export - Format Selection', () => { await expect(page.locator('[data-testid="export-csv"]')).toBeVisible(); // Press Escape to close dropdown - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); // Dropdown should close await expect(page.locator('[data-testid="export-csv"]')).not.toBeVisible(); }); }); -test.describe('Log Export - CSV Download', () => { +test.describe("Log Export - CSV Download", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -222,18 +222,18 @@ test.describe('Log Export - CSV Download', () => { // Ingest test logs with various fields await ingestLogsBatch(page, testProject.apiKey, [ { - level: 'info', - message: 'CSV export test log', - metadata: { key: 'value' }, - sourceFile: 'test.ts', + level: "info", + message: "CSV export test log", + metadata: { key: "value" }, + sourceFile: "test.ts", lineNumber: 42, - requestId: 'req_123', - userId: 'user_456', - ipAddress: '192.168.1.1', + requestId: "req_123", + userId: "user_456", + ipAddress: "192.168.1.1", }, { - level: 'error', - message: 'Error log for CSV', + level: "error", + message: "Error log for CSV", }, ]); }); @@ -244,14 +244,14 @@ test.describe('Log Export - CSV Download', () => { } }); - test('should trigger CSV download with correct filename pattern', async ({ page }) => { + test("should trigger CSV download with correct filename pattern", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open export dropdown await page.locator('[data-testid="export-button"]').click(); // Start download listener - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); // Click CSV option await page.locator('[data-testid="export-csv"]').click(); @@ -262,15 +262,15 @@ test.describe('Log Export - CSV Download', () => { // Verify filename matches pattern: logs-*.csv expect(filename).toMatch(/^logs-.+\.csv$/); - expect(filename).toContain(testProject.name.replace(/[^a-zA-Z0-9-_]/g, '-')); + expect(filename).toContain(testProject.name.replace(/[^a-zA-Z0-9-_]/g, "-")); }); - test('should download CSV with expected headers', async ({ page }) => { + test("should download CSV with expected headers", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Trigger CSV download await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-csv"]').click(); // Get download content @@ -282,31 +282,31 @@ test.describe('Log Export - CSV Download', () => { chunks.push(Buffer.from(chunk)); } } - const csvContent = Buffer.concat(chunks).toString('utf-8'); + const csvContent = Buffer.concat(chunks).toString("utf-8"); // Parse CSV const rows = parseCsv(csvContent); const headers = rows[0]; // Verify expected headers exist - expect(headers).toContain('id'); - expect(headers).toContain('level'); - expect(headers).toContain('message'); - expect(headers).toContain('timestamp'); - expect(headers).toContain('sourceFile'); - expect(headers).toContain('lineNumber'); - expect(headers).toContain('requestId'); - expect(headers).toContain('userId'); - expect(headers).toContain('ipAddress'); - expect(headers).toContain('metadata'); + expect(headers).toContain("id"); + expect(headers).toContain("level"); + expect(headers).toContain("message"); + expect(headers).toContain("timestamp"); + expect(headers).toContain("sourceFile"); + expect(headers).toContain("lineNumber"); + expect(headers).toContain("requestId"); + expect(headers).toContain("userId"); + expect(headers).toContain("ipAddress"); + expect(headers).toContain("metadata"); }); - test('should download CSV with correct data rows', async ({ page }) => { + test("should download CSV with correct data rows", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Trigger CSV download await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-csv"]').click(); // Get download content @@ -318,17 +318,17 @@ test.describe('Log Export - CSV Download', () => { chunks.push(Buffer.from(chunk)); } } - const csvContent = Buffer.concat(chunks).toString('utf-8'); + const csvContent = Buffer.concat(chunks).toString("utf-8"); // Verify content includes test data - expect(csvContent).toContain('CSV export test log'); - expect(csvContent).toContain('Error log for CSV'); - expect(csvContent).toContain('info'); - expect(csvContent).toContain('error'); + expect(csvContent).toContain("CSV export test log"); + expect(csvContent).toContain("Error log for CSV"); + expect(csvContent).toContain("info"); + expect(csvContent).toContain("error"); }); }); -test.describe('Log Export - JSON Download', () => { +test.describe("Log Export - JSON Download", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -340,13 +340,13 @@ test.describe('Log Export - JSON Download', () => { // Ingest test logs await ingestLogsBatch(page, testProject.apiKey, [ { - level: 'warn', - message: 'JSON export test log', - metadata: { environment: 'test' }, + level: "warn", + message: "JSON export test log", + metadata: { environment: "test" }, }, { - level: 'debug', - message: 'Debug log for JSON', + level: "debug", + message: "Debug log for JSON", }, ]); }); @@ -357,14 +357,14 @@ test.describe('Log Export - JSON Download', () => { } }); - test('should trigger JSON download with correct filename pattern', async ({ page }) => { + test("should trigger JSON download with correct filename pattern", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open export dropdown await page.locator('[data-testid="export-button"]').click(); // Start download listener - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); // Click JSON option await page.locator('[data-testid="export-json"]').click(); @@ -375,15 +375,15 @@ test.describe('Log Export - JSON Download', () => { // Verify filename matches pattern: logs-*.json expect(filename).toMatch(/^logs-.+\.json$/); - expect(filename).toContain(testProject.name.replace(/[^a-zA-Z0-9-_]/g, '-')); + expect(filename).toContain(testProject.name.replace(/[^a-zA-Z0-9-_]/g, "-")); }); - test('should download valid JSON array', async ({ page }) => { + test("should download valid JSON array", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Trigger JSON download await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-json"]').click(); // Get download content @@ -395,7 +395,7 @@ test.describe('Log Export - JSON Download', () => { chunks.push(Buffer.from(chunk)); } } - const jsonContent = Buffer.concat(chunks).toString('utf-8'); + const jsonContent = Buffer.concat(chunks).toString("utf-8"); // Parse JSON and verify structure let parsedJson: unknown; @@ -406,12 +406,12 @@ test.describe('Log Export - JSON Download', () => { expect(Array.isArray(parsedJson)).toBe(true); }); - test('should download JSON with expected fields', async ({ page }) => { + test("should download JSON with expected fields", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Trigger JSON download await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-json"]').click(); // Get download content @@ -423,7 +423,7 @@ test.describe('Log Export - JSON Download', () => { chunks.push(Buffer.from(chunk)); } } - const jsonContent = Buffer.concat(chunks).toString('utf-8'); + const jsonContent = Buffer.concat(chunks).toString("utf-8"); // Parse and verify structure const logs = JSON.parse(jsonContent) as Array>; @@ -432,19 +432,19 @@ test.describe('Log Export - JSON Download', () => { const firstLog = logs[0]; // Verify expected fields - expect(firstLog).toHaveProperty('id'); - expect(firstLog).toHaveProperty('level'); - expect(firstLog).toHaveProperty('message'); - expect(firstLog).toHaveProperty('timestamp'); + expect(firstLog).toHaveProperty("id"); + expect(firstLog).toHaveProperty("level"); + expect(firstLog).toHaveProperty("message"); + expect(firstLog).toHaveProperty("timestamp"); // Verify content const messages = logs.map((l) => l.message); - expect(messages).toContain('JSON export test log'); - expect(messages).toContain('Debug log for JSON'); + expect(messages).toContain("JSON export test log"); + expect(messages).toContain("Debug log for JSON"); }); }); -test.describe('Log Export - With Filters', () => { +test.describe("Log Export - With Filters", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -455,10 +455,10 @@ test.describe('Log Export - With Filters', () => { // Ingest logs with different levels and messages await ingestLogsBatch(page, testProject.apiKey, [ - { level: 'info', message: 'Info message about database' }, - { level: 'error', message: 'Error connecting to database' }, - { level: 'warn', message: 'Warning about memory usage' }, - { level: 'error', message: 'Critical error occurred' }, + { level: "info", message: "Info message about database" }, + { level: "error", message: "Error connecting to database" }, + { level: "warn", message: "Warning about memory usage" }, + { level: "error", message: "Critical error occurred" }, ]); }); @@ -468,17 +468,17 @@ test.describe('Log Export - With Filters', () => { } }); - test('should export filtered logs when level filter is active', async ({ page }) => { + test("should export filtered logs when level filter is active", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Apply level filter - select only ERROR const levelFilter = page.locator('[data-testid="level-filter"]'); - await levelFilter.getByRole('button', { name: /error/i }).click(); + await levelFilter.getByRole("button", { name: /error/i }).click(); await page.waitForTimeout(500); // Wait for filter to apply // Trigger CSV export await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-csv"]').click(); // Get download content @@ -490,26 +490,26 @@ test.describe('Log Export - With Filters', () => { chunks.push(Buffer.from(chunk)); } } - const csvContent = Buffer.concat(chunks).toString('utf-8'); + const csvContent = Buffer.concat(chunks).toString("utf-8"); // Verify only error logs are exported - expect(csvContent).toContain('Error connecting to database'); - expect(csvContent).toContain('Critical error occurred'); - expect(csvContent).not.toContain('Info message about database'); - expect(csvContent).not.toContain('Warning about memory usage'); + expect(csvContent).toContain("Error connecting to database"); + expect(csvContent).toContain("Critical error occurred"); + expect(csvContent).not.toContain("Info message about database"); + expect(csvContent).not.toContain("Warning about memory usage"); }); - test('should export filtered logs when search filter is active', async ({ page }) => { + test("should export filtered logs when search filter is active", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Apply search filter const searchInput = page.getByPlaceholder(/search/i); - await searchInput.fill('database'); + await searchInput.fill("database"); await page.waitForTimeout(500); // Wait for debounce // Trigger JSON export await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-json"]').click(); // Get download content @@ -521,33 +521,33 @@ test.describe('Log Export - With Filters', () => { chunks.push(Buffer.from(chunk)); } } - const jsonContent = Buffer.concat(chunks).toString('utf-8'); + const jsonContent = Buffer.concat(chunks).toString("utf-8"); // Parse and verify const logs = JSON.parse(jsonContent) as Array>; const messages = logs.map((l) => l.message as string); // Should only contain logs with "database" in message - expect(messages.every((msg) => msg.toLowerCase().includes('database'))).toBe(true); - expect(messages).toContain('Info message about database'); - expect(messages).toContain('Error connecting to database'); + expect(messages.every((msg) => msg.toLowerCase().includes("database"))).toBe(true); + expect(messages).toContain("Info message about database"); + expect(messages).toContain("Error connecting to database"); }); - test('should export filtered logs with combined filters', async ({ page }) => { + test("should export filtered logs with combined filters", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Apply both level and search filters const levelFilter = page.locator('[data-testid="level-filter"]'); - await levelFilter.getByRole('button', { name: /error/i }).click(); + await levelFilter.getByRole("button", { name: /error/i }).click(); await page.waitForTimeout(300); const searchInput = page.getByPlaceholder(/search/i); - await searchInput.fill('database'); + await searchInput.fill("database"); await page.waitForTimeout(500); // Trigger CSV export await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-csv"]').click(); // Get download content @@ -559,17 +559,17 @@ test.describe('Log Export - With Filters', () => { chunks.push(Buffer.from(chunk)); } } - const csvContent = Buffer.concat(chunks).toString('utf-8'); + const csvContent = Buffer.concat(chunks).toString("utf-8"); // Should only contain error logs with "database" in message - expect(csvContent).toContain('Error connecting to database'); - expect(csvContent).not.toContain('Info message about database'); // Wrong level - expect(csvContent).not.toContain('Critical error occurred'); // Missing search term - expect(csvContent).not.toContain('Warning about memory usage'); // Wrong level and missing search + expect(csvContent).toContain("Error connecting to database"); + expect(csvContent).not.toContain("Info message about database"); // Wrong level + expect(csvContent).not.toContain("Critical error occurred"); // Missing search term + expect(csvContent).not.toContain("Warning about memory usage"); // Wrong level and missing search }); }); -test.describe('Log Export - Edge Cases', () => { +test.describe("Log Export - Edge Cases", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -585,11 +585,11 @@ test.describe('Log Export - Edge Cases', () => { } }); - test('should handle export of logs with special characters in message', async ({ page }) => { + test("should handle export of logs with special characters in message", async ({ page }) => { // Ingest log with special characters await ingestLogsBatch(page, testProject.apiKey, [ { - level: 'info', + level: "info", message: 'Log with special chars: "quotes", commas,, and\nnewlines', }, ]); @@ -598,7 +598,7 @@ test.describe('Log Export - Edge Cases', () => { // Trigger CSV export await page.locator('[data-testid="export-button"]').click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-csv"]').click(); // Should complete without error @@ -606,15 +606,15 @@ test.describe('Log Export - Edge Cases', () => { expect(download.suggestedFilename()).toMatch(/\.csv$/); }); - test('should handle export when filter results in no logs', async ({ page }) => { + test("should handle export when filter results in no logs", async ({ page }) => { // Ingest one log - await ingestLogsBatch(page, testProject.apiKey, [{ level: 'info', message: 'Single log' }]); + await ingestLogsBatch(page, testProject.apiKey, [{ level: "info", message: "Single log" }]); await page.goto(`/projects/${testProject.id}`); // Apply filter that matches nothing const searchInput = page.getByPlaceholder(/search/i); - await searchInput.fill('nonexistentterm123456'); + await searchInput.fill("nonexistentterm123456"); await page.waitForTimeout(500); // Export button might be hidden when no results @@ -624,7 +624,7 @@ test.describe('Log Export - Edge Cases', () => { if (isVisible) { // If export is still available, it should export empty results await exportButton.click(); - const downloadPromise = page.waitForEvent('download'); + const downloadPromise = page.waitForEvent("download"); await page.locator('[data-testid="export-json"]').click(); const download = await downloadPromise; @@ -635,7 +635,7 @@ test.describe('Log Export - Edge Cases', () => { chunks.push(Buffer.from(chunk)); } } - const jsonContent = Buffer.concat(chunks).toString('utf-8'); + const jsonContent = Buffer.concat(chunks).toString("utf-8"); const logs = JSON.parse(jsonContent) as Array>; expect(logs.length).toBe(0); diff --git a/tests/e2e/helpers/log-selectors.ts b/tests/e2e/helpers/log-selectors.ts index 1fd8c0a..2e191d1 100644 --- a/tests/e2e/helpers/log-selectors.ts +++ b/tests/e2e/helpers/log-selectors.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from '@playwright/test'; +import type { Locator, Page } from "@playwright/test"; /** * Log selector helpers for E2E tests @@ -36,9 +36,9 @@ export function getLogCard(page: Page, options?: { hasText?: string }): Locator export function getLogMessage( page: Page, text: string, - viewport: 'desktop' | 'mobile' = 'desktop', + viewport: "desktop" | "mobile" = "desktop", ): Locator { - if (viewport === 'mobile') { + if (viewport === "mobile") { // Mobile uses card layout return page.locator('[data-testid="log-card"]').getByText(text); } @@ -57,9 +57,9 @@ export function getLogMessage( export function getLevelBadge( page: Page, level: string, - viewport: 'desktop' | 'mobile' = 'desktop', + viewport: "desktop" | "mobile" = "desktop", ): Locator { - if (viewport === 'mobile') { + if (viewport === "mobile") { return page.locator('[data-testid="log-card"]').getByText(level); } return page.locator('[data-testid="log-table"] table').getByText(level); diff --git a/tests/e2e/helpers/otlp.ts b/tests/e2e/helpers/otlp.ts index eecbfd0..b71b5d4 100644 --- a/tests/e2e/helpers/otlp.ts +++ b/tests/e2e/helpers/otlp.ts @@ -1,6 +1,6 @@ -import type { APIRequestContext, Page } from '@playwright/test'; +import type { APIRequestContext, Page } from "@playwright/test"; -type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; const SEVERITY_NUMBER_BY_LEVEL: Record = { debug: 5, @@ -11,10 +11,10 @@ const SEVERITY_NUMBER_BY_LEVEL: Record = { }; function toOtlpAnyValue(value: unknown) { - if (typeof value === 'string') return { stringValue: value }; - if (typeof value === 'boolean') return { boolValue: value }; - if (typeof value === 'number') return { doubleValue: value }; - if (value === null || value === undefined) return { stringValue: 'null' }; + if (typeof value === "string") return { stringValue: value }; + if (typeof value === "boolean") return { boolValue: value }; + if (typeof value === "number") return { doubleValue: value }; + if (value === null || value === undefined) return { stringValue: "null" }; return { stringValue: JSON.stringify(value) }; } @@ -31,10 +31,10 @@ async function postOtlpLogs( apiKey: string, payload: unknown, ): Promise { - const response = await request.post('/v1/logs', { + const response = await request.post("/v1/logs", { headers: { Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, data: payload, }); @@ -65,7 +65,7 @@ export async function ingestOtlpLogs( { scopeLogs: [ { - scope: { name: 'logwell-e2e' }, + scope: { name: "logwell-e2e" }, logRecords, }, ], diff --git a/tests/e2e/incidents.spec.ts b/tests/e2e/incidents.spec.ts index b495755..8f8f91e 100644 --- a/tests/e2e/incidents.spec.ts +++ b/tests/e2e/incidents.spec.ts @@ -1,23 +1,23 @@ -import { expect, type Page, test } from '@playwright/test'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { ingestOtlpLogs } from "./helpers/otlp"; const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); await page.getByLabel(/username/i).fill(TEST_USER.username); await page.getByLabel(/password/i).fill(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { data: { name } }); + const response = await page.request.post("/api/projects", { data: { name } }); expect(response.ok()).toBeTruthy(); return response.json(); } @@ -26,7 +26,7 @@ async function deleteProject(page: Page, projectId: string) { await page.request.delete(`/api/projects/${projectId}`); } -test.describe('Incidents Page', () => { +test.describe("Incidents Page", () => { test.describe.configure({ retries: 1 }); let project: { id: string; name: string; apiKey: string }; @@ -42,19 +42,19 @@ test.describe('Incidents Page', () => { } }); - test('groups similar errors into a single incident and opens timeline panel', async ({ + test("groups similar errors into a single incident and opens timeline panel", async ({ page, }) => { await ingestOtlpLogs(page, project.apiKey, [ { - level: 'error', - message: 'Database timeout after 1000ms for user 123', - attributes: { 'service.name': 'api', 'code.filepath': 'src/db.ts', 'code.lineno': 42 }, + level: "error", + message: "Database timeout after 1000ms for user 123", + attributes: { "service.name": "api", "code.filepath": "src/db.ts", "code.lineno": 42 }, }, { - level: 'error', - message: 'Database timeout after 2500ms for user 999', - attributes: { 'service.name': 'api', 'code.filepath': 'src/db.ts', 'code.lineno': 42 }, + level: "error", + message: "Database timeout after 2500ms for user 999", + attributes: { "service.name": "api", "code.filepath": "src/db.ts", "code.lineno": 42 }, }, ]); @@ -71,22 +71,22 @@ test.describe('Incidents Page', () => { const timelinePanel = page.locator('[data-testid="incident-timeline-panel"]'); await expect(timelinePanel).toBeVisible(); await expect( - timelinePanel.getByRole('heading', { name: 'Root-Cause Candidates', exact: true }), + timelinePanel.getByRole("heading", { name: "Root-Cause Candidates", exact: true }), ).toBeVisible(); }); - test('updates incident list in real-time when new error arrives', async ({ page }) => { + test("updates incident list in real-time when new error arrives", async ({ page }) => { await page.goto(`/projects/${project.id}/incidents`); await expect(page.locator('[data-testid="incident-table"]')).toBeVisible(); await ingestOtlpLogs(page, project.apiKey, [ { - level: 'error', - message: 'Payment gateway unavailable for order 555', + level: "error", + message: "Payment gateway unavailable for order 555", attributes: { - 'service.name': 'billing', - 'code.filepath': 'src/payment.ts', - 'code.lineno': 88, + "service.name": "billing", + "code.filepath": "src/payment.ts", + "code.lineno": 88, }, }, ]); diff --git a/tests/e2e/live-stream.spec.ts b/tests/e2e/live-stream.spec.ts index e2246cc..2ee0630 100644 --- a/tests/e2e/live-stream.spec.ts +++ b/tests/e2e/live-stream.spec.ts @@ -1,5 +1,5 @@ -import { expect, type Page, test } from '@playwright/test'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { ingestOtlpLogs } from "./helpers/otlp"; /** * E2E tests for Live Stream SSE Integration @@ -10,16 +10,16 @@ import { ingestOtlpLogs } from './helpers/otlp'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -32,15 +32,15 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** * Helper to create a project via API */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -58,12 +58,12 @@ async function deleteProject(page: Page, projectId: string) { async function ingestLog( page: Page, apiKey: string, - log: { level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; message: string }, + log: { level: "debug" | "info" | "warn" | "error" | "fatal"; message: string }, ) { await ingestOtlpLogs(page, apiKey, [{ level: log.level, message: log.message }]); } -test.describe('Live Stream SSE Integration', () => { +test.describe("Live Stream SSE Integration", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -79,7 +79,7 @@ test.describe('Live Stream SSE Integration', () => { } }); - test('enabling live starts receiving logs', async ({ page }) => { + test("enabling live starts receiving logs", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for page to fully load and log table to be visible @@ -95,28 +95,28 @@ test.describe('Live Stream SSE Integration', () => { // Ingest a log while on the page await ingestLog(page, testProject.apiKey, { - level: 'info', - message: 'Live stream test log - should appear', + level: "info", + message: "Live stream test log - should appear", }); // The log should appear via SSE within reasonable time // Use filter({ visible: true }) since we have dual mobile/desktop layouts (sm:hidden vs hidden sm:table) // Using longer timeout to account for SSE batching (1.5s window) plus network latency await expect( - page.getByText('Live stream test log - should appear').filter({ visible: true }), + page.getByText("Live stream test log - should appear").filter({ visible: true }), ).toBeVisible({ timeout: 10000, }); }); - test('disabling live stops stream', async ({ page }) => { + test("disabling live stops stream", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for page to be ready await expect(page.locator('[data-testid="log-table"]')).toBeVisible(); // Disable live toggle - const liveSwitch = page.getByRole('switch', { name: /toggle live streaming/i }); + const liveSwitch = page.getByRole("switch", { name: /toggle live streaming/i }); await liveSwitch.click(); // Verify toggle is now off (gray pulse) @@ -125,8 +125,8 @@ test.describe('Live Stream SSE Integration', () => { // Ingest a log while live is disabled await ingestLog(page, testProject.apiKey, { - level: 'error', - message: 'Log after disabling live - should NOT appear', + level: "error", + message: "Log after disabling live - should NOT appear", }); // Wait to ensure the log would have time to appear if streaming was active @@ -134,10 +134,10 @@ test.describe('Live Stream SSE Integration', () => { // The log should NOT appear since live is disabled // Check that no element with this text exists (avoids dual layout visibility issues) - await expect(page.getByText('Log after disabling live - should NOT appear')).toHaveCount(0); + await expect(page.getByText("Log after disabling live - should NOT appear")).toHaveCount(0); }); - test('search pauses live with notice', async ({ page }) => { + test("search pauses live with notice", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Verify live is initially enabled @@ -147,18 +147,18 @@ test.describe('Live Stream SSE Integration', () => { // Type in search input (use type() to ensure input events fire) const searchInput = page.getByPlaceholder(/search/i); await searchInput.click(); - await searchInput.type('test', { delay: 50 }); + await searchInput.type("test", { delay: 50 }); // Wait for debounce (300ms) and navigation to complete await page.waitForTimeout(500); await page.waitForURL(/search=test/, { timeout: 5000 }); // Live should be paused (toggle disabled or showing paused state) - const liveSwitch = page.getByRole('switch', { name: /toggle live streaming/i }); + const liveSwitch = page.getByRole("switch", { name: /toggle live streaming/i }); await expect(liveSwitch).toBeDisabled(); // A notice should be visible explaining why live is paused - const pauseNotice = page.getByTestId('live-paused-notice'); + const pauseNotice = page.getByTestId("live-paused-notice"); await expect(pauseNotice).toBeVisible(); await expect(pauseNotice).toContainText(/paused|search/i); diff --git a/tests/e2e/log-stream.spec.ts b/tests/e2e/log-stream.spec.ts index 2c9c473..5b4b925 100644 --- a/tests/e2e/log-stream.spec.ts +++ b/tests/e2e/log-stream.spec.ts @@ -1,6 +1,6 @@ -import { expect, type Page, test } from '@playwright/test'; -import { getLevelBadge, getLogMessage } from './helpers/log-selectors'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { getLevelBadge, getLogMessage } from "./helpers/log-selectors"; +import { ingestOtlpLogs } from "./helpers/otlp"; /** * E2E tests for Project Log Stream Page @@ -11,16 +11,16 @@ import { ingestOtlpLogs } from './helpers/otlp'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -33,8 +33,8 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** @@ -42,7 +42,7 @@ async function login(page: Page) { * Returns the created project data including apiKey */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -60,7 +60,7 @@ async function deleteProject(page: Page, projectId: string) { /** * Helper to ingest a log via API */ -type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; +type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; async function ingestLog( page: Page, @@ -76,19 +76,19 @@ async function ingestLog( ip_address?: string; }, ) { - const attributes: Record = { ...(log.metadata ?? {}) }; + const attributes: Record = { ...log.metadata }; // Map fields to OTLP semantic conventions - if (log.source_file) attributes['code.filepath'] = log.source_file; - if (log.line_number !== undefined) attributes['code.lineno'] = log.line_number; - if (log.request_id) attributes['request.id'] = log.request_id; - if (log.user_id) attributes['enduser.id'] = log.user_id; - if (log.ip_address) attributes['client.address'] = log.ip_address; + if (log.source_file) attributes["code.filepath"] = log.source_file; + if (log.line_number !== undefined) attributes["code.lineno"] = log.line_number; + if (log.request_id) attributes["request.id"] = log.request_id; + if (log.user_id) attributes["enduser.id"] = log.user_id; + if (log.ip_address) attributes["client.address"] = log.ip_address; await ingestOtlpLogs(page, apiKey, [{ level: log.level, message: log.message, attributes }]); } -test.describe('Log Stream Page - Display', () => { +test.describe("Log Stream Page - Display", () => { // Allow retries due to potential cold start issues test.describe.configure({ retries: 1 }); @@ -100,10 +100,10 @@ test.describe('Log Stream Page - Display', () => { // Ingest some test logs await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'Application started successfully' }, - { level: 'warn', message: 'Deprecated API usage detected' }, - { level: 'error', message: 'Failed to connect to database' }, - { level: 'debug', message: 'Processing request payload' }, + { level: "info", message: "Application started successfully" }, + { level: "warn", message: "Deprecated API usage detected" }, + { level: "error", message: "Failed to connect to database" }, + { level: "debug", message: "Processing request payload" }, ]); }); @@ -113,38 +113,38 @@ test.describe('Log Stream Page - Display', () => { } }); - test('should display log table with entries', async ({ page }) => { + test("should display log table with entries", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Should display log table await expect(page.locator('[data-testid="log-table"]')).toBeVisible(); // Should display log entries (scoped to table layout for desktop viewport) - await expect(getLogMessage(page, 'Application started successfully')).toBeVisible(); - await expect(getLogMessage(page, 'Deprecated API usage detected')).toBeVisible(); - await expect(getLogMessage(page, 'Failed to connect to database')).toBeVisible(); - await expect(getLogMessage(page, 'Processing request payload')).toBeVisible(); + await expect(getLogMessage(page, "Application started successfully")).toBeVisible(); + await expect(getLogMessage(page, "Deprecated API usage detected")).toBeVisible(); + await expect(getLogMessage(page, "Failed to connect to database")).toBeVisible(); + await expect(getLogMessage(page, "Processing request payload")).toBeVisible(); }); - test('should display project name in header', async ({ page }) => { + test("should display project name in header", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Should display project name - await expect(page.getByRole('heading', { name: testProject.name })).toBeVisible(); + await expect(page.getByRole("heading", { name: testProject.name })).toBeVisible(); }); - test('should display level badges for each log', async ({ page }) => { + test("should display level badges for each log", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Should display level badges (scoped to table layout for desktop viewport) - await expect(getLevelBadge(page, 'INFO')).toBeVisible(); - await expect(getLevelBadge(page, 'WARN')).toBeVisible(); - await expect(getLevelBadge(page, 'ERROR')).toBeVisible(); - await expect(getLevelBadge(page, 'DEBUG')).toBeVisible(); + await expect(getLevelBadge(page, "INFO")).toBeVisible(); + await expect(getLevelBadge(page, "WARN")).toBeVisible(); + await expect(getLevelBadge(page, "ERROR")).toBeVisible(); + await expect(getLevelBadge(page, "DEBUG")).toBeVisible(); }); }); -test.describe('Log Stream Page - Live Toggle', () => { +test.describe("Log Stream Page - Live Toggle", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -160,7 +160,7 @@ test.describe('Log Stream Page - Live Toggle', () => { } }); - test('should show live toggle in enabled state by default', async ({ page }) => { + test("should show live toggle in enabled state by default", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Live toggle should be visible @@ -171,7 +171,7 @@ test.describe('Log Stream Page - Live Toggle', () => { await expect(liveToggle).toHaveClass(/bg-green-500/); }); - test('should receive new logs when live streaming is enabled', async ({ page }) => { + test("should receive new logs when live streaming is enabled", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for page to load @@ -179,21 +179,21 @@ test.describe('Log Stream Page - Live Toggle', () => { // Ingest a new log while on the page await ingestLog(page, testProject.apiKey, { - level: 'info', - message: 'New log from live stream test', + level: "info", + message: "New log from live stream test", }); // The new log should appear in the table (via SSE) - scoped to table layout - await expect(getLogMessage(page, 'New log from live stream test')).toBeVisible({ + await expect(getLogMessage(page, "New log from live stream test")).toBeVisible({ timeout: 5000, }); }); - test('should stop receiving logs when live toggle is disabled', async ({ page }) => { + test("should stop receiving logs when live toggle is disabled", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Disable live toggle - const liveSwitch = page.getByRole('switch', { name: /toggle live streaming/i }); + const liveSwitch = page.getByRole("switch", { name: /toggle live streaming/i }); await liveSwitch.click(); // Pulse should no longer be green @@ -202,19 +202,19 @@ test.describe('Log Stream Page - Live Toggle', () => { // Ingest a new log await ingestLog(page, testProject.apiKey, { - level: 'error', - message: 'Log after live disabled', + level: "error", + message: "Log after live disabled", }); // Wait a bit to ensure the log doesn't appear await page.waitForTimeout(2000); // The new log should NOT appear (live is disabled) - scoped to table layout - await expect(getLogMessage(page, 'Log after live disabled')).not.toBeVisible(); + await expect(getLogMessage(page, "Log after live disabled")).not.toBeVisible(); }); }); -test.describe('Log Stream Page - Search Filter', () => { +test.describe("Log Stream Page - Search Filter", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -225,10 +225,10 @@ test.describe('Log Stream Page - Search Filter', () => { // Ingest logs with distinct messages for search testing await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'User authentication successful' }, - { level: 'info', message: 'Payment processing completed' }, - { level: 'error', message: 'Database connection failed' }, - { level: 'warn', message: 'Memory usage high' }, + { level: "info", message: "User authentication successful" }, + { level: "info", message: "Payment processing completed" }, + { level: "error", message: "Database connection failed" }, + { level: "warn", message: "Memory usage high" }, ]); }); @@ -238,47 +238,47 @@ test.describe('Log Stream Page - Search Filter', () => { } }); - test('should filter logs by search term', async ({ page }) => { + test("should filter logs by search term", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for logs to load (scoped to table layout) - await expect(getLogMessage(page, 'User authentication successful')).toBeVisible(); + await expect(getLogMessage(page, "User authentication successful")).toBeVisible(); // Type in search input const searchInput = page.getByPlaceholder(/search/i); - await searchInput.fill('database'); + await searchInput.fill("database"); // Wait for debounce and API call await page.waitForTimeout(500); // Should show matching log (scoped to table layout) - await expect(getLogMessage(page, 'Database connection failed')).toBeVisible(); + await expect(getLogMessage(page, "Database connection failed")).toBeVisible(); // Should hide non-matching logs (scoped to table layout) - await expect(getLogMessage(page, 'User authentication successful')).not.toBeVisible(); - await expect(getLogMessage(page, 'Payment processing completed')).not.toBeVisible(); + await expect(getLogMessage(page, "User authentication successful")).not.toBeVisible(); + await expect(getLogMessage(page, "Payment processing completed")).not.toBeVisible(); }); - test('should show all logs when search is cleared', async ({ page }) => { + test("should show all logs when search is cleared", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Apply search filter const searchInput = page.getByPlaceholder(/search/i); - await searchInput.fill('payment'); + await searchInput.fill("payment"); await page.waitForTimeout(500); // Clear search - await searchInput.fill(''); + await searchInput.fill(""); await page.waitForTimeout(500); // All logs should be visible again (scoped to table layout) - await expect(getLogMessage(page, 'User authentication successful')).toBeVisible(); - await expect(getLogMessage(page, 'Payment processing completed')).toBeVisible(); - await expect(getLogMessage(page, 'Database connection failed')).toBeVisible(); + await expect(getLogMessage(page, "User authentication successful")).toBeVisible(); + await expect(getLogMessage(page, "Payment processing completed")).toBeVisible(); + await expect(getLogMessage(page, "Database connection failed")).toBeVisible(); }); }); -test.describe('Log Stream Page - Level Filter', () => { +test.describe("Log Stream Page - Level Filter", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -289,11 +289,11 @@ test.describe('Log Stream Page - Level Filter', () => { // Ingest logs with different levels await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'debug', message: 'Debug message one' }, - { level: 'info', message: 'Info message one' }, - { level: 'warn', message: 'Warning message one' }, - { level: 'error', message: 'Error message one' }, - { level: 'fatal', message: 'Fatal message one' }, + { level: "debug", message: "Debug message one" }, + { level: "info", message: "Info message one" }, + { level: "warn", message: "Warning message one" }, + { level: "error", message: "Error message one" }, + { level: "fatal", message: "Fatal message one" }, ]); }); @@ -303,55 +303,55 @@ test.describe('Log Stream Page - Level Filter', () => { } }); - test('should filter logs by level', async ({ page }) => { + test("should filter logs by level", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for logs to load (scoped to table layout) - await expect(getLogMessage(page, 'Error message one')).toBeVisible(); + await expect(getLogMessage(page, "Error message one")).toBeVisible(); // Click on error level button to select only error const levelFilter = page.locator('[data-testid="level-filter"]'); - await levelFilter.getByRole('button', { name: /error/i }).click(); + await levelFilter.getByRole("button", { name: /error/i }).click(); // Wait for filter to apply await page.waitForTimeout(500); // Should show only error logs (scoped to table layout) - await expect(getLogMessage(page, 'Error message one')).toBeVisible(); + await expect(getLogMessage(page, "Error message one")).toBeVisible(); // Should hide other level logs (scoped to table layout) - await expect(getLogMessage(page, 'Debug message one')).not.toBeVisible(); - await expect(getLogMessage(page, 'Info message one')).not.toBeVisible(); - await expect(getLogMessage(page, 'Warning message one')).not.toBeVisible(); + await expect(getLogMessage(page, "Debug message one")).not.toBeVisible(); + await expect(getLogMessage(page, "Info message one")).not.toBeVisible(); + await expect(getLogMessage(page, "Warning message one")).not.toBeVisible(); }); - test('should support multiple level selection', async ({ page }) => { + test("should support multiple level selection", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for logs to load (scoped to table layout) - await expect(getLogMessage(page, 'Error message one')).toBeVisible(); + await expect(getLogMessage(page, "Error message one")).toBeVisible(); const levelFilter = page.locator('[data-testid="level-filter"]'); // Select error level - await levelFilter.getByRole('button', { name: /error/i }).click(); + await levelFilter.getByRole("button", { name: /error/i }).click(); await page.waitForTimeout(300); // Select fatal level - await levelFilter.getByRole('button', { name: /fatal/i }).click(); + await levelFilter.getByRole("button", { name: /fatal/i }).click(); await page.waitForTimeout(500); // Should show error and fatal logs (scoped to table layout) - await expect(getLogMessage(page, 'Error message one')).toBeVisible(); - await expect(getLogMessage(page, 'Fatal message one')).toBeVisible(); + await expect(getLogMessage(page, "Error message one")).toBeVisible(); + await expect(getLogMessage(page, "Fatal message one")).toBeVisible(); // Should hide other levels (scoped to table layout) - await expect(getLogMessage(page, 'Debug message one')).not.toBeVisible(); - await expect(getLogMessage(page, 'Info message one')).not.toBeVisible(); + await expect(getLogMessage(page, "Debug message one")).not.toBeVisible(); + await expect(getLogMessage(page, "Info message one")).not.toBeVisible(); }); }); -test.describe('Log Stream Page - Time Range Filter', () => { +test.describe("Log Stream Page - Time Range Filter", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -367,50 +367,50 @@ test.describe('Log Stream Page - Time Range Filter', () => { } }); - test('should display time range picker with options', async ({ page }) => { + test("should display time range picker with options", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Time range picker should be visible with all options - await expect(page.getByRole('button', { name: /last 15 minutes/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /last hour/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /last 24 hours/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /last 7 days/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /last 15 minutes/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /last hour/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /last 24 hours/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /last 7 days/i })).toBeVisible(); }); - test('should highlight selected time range', async ({ page }) => { + test("should highlight selected time range", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Default should be 1h - const hourButton = page.getByRole('button', { name: /last hour/i }); - await expect(hourButton).toHaveAttribute('data-selected', 'true'); + const hourButton = page.getByRole("button", { name: /last hour/i }); + await expect(hourButton).toHaveAttribute("data-selected", "true"); // Click 24h - await page.getByRole('button', { name: /last 24 hours/i }).click(); + await page.getByRole("button", { name: /last 24 hours/i }).click(); // 24h should now be selected - await expect(page.getByRole('button', { name: /last 24 hours/i })).toHaveAttribute( - 'data-selected', - 'true', + await expect(page.getByRole("button", { name: /last 24 hours/i })).toHaveAttribute( + "data-selected", + "true", ); - await expect(hourButton).toHaveAttribute('data-selected', 'false'); + await expect(hourButton).toHaveAttribute("data-selected", "false"); }); - test('should filter logs by time range', async ({ page }) => { + test("should filter logs by time range", async ({ page }) => { // Ingest a log await ingestLog(page, testProject.apiKey, { - level: 'info', - message: 'Recent log message', + level: "info", + message: "Recent log message", }); await page.goto(`/projects/${testProject.id}`); // Should show recent log with 15m filter (scoped to table layout) - await page.getByRole('button', { name: /last 15 minutes/i }).click(); - await expect(getLogMessage(page, 'Recent log message')).toBeVisible(); + await page.getByRole("button", { name: /last 15 minutes/i }).click(); + await expect(getLogMessage(page, "Recent log message")).toBeVisible(); }); }); -test.describe('Log Stream Page - Log Detail Modal', () => { +test.describe("Log Stream Page - Log Detail Modal", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -421,14 +421,14 @@ test.describe('Log Stream Page - Log Detail Modal', () => { // Ingest a log with all fields await ingestLog(page, testProject.apiKey, { - level: 'error', - message: 'Detailed error for testing', - metadata: { key: 'value', nested: { foo: 'bar' } }, - source_file: 'src/test.ts', + level: "error", + message: "Detailed error for testing", + metadata: { key: "value", nested: { foo: "bar" } }, + source_file: "src/test.ts", line_number: 42, - request_id: 'req_abc123', - user_id: 'user_456', - ip_address: '192.168.1.100', + request_id: "req_abc123", + user_id: "user_456", + ip_address: "192.168.1.100", }); }); @@ -438,74 +438,74 @@ test.describe('Log Stream Page - Log Detail Modal', () => { } }); - test('should open detail modal when clicking log row', async ({ page }) => { + test("should open detail modal when clicking log row", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for log to appear (scoped to table layout) - await expect(getLogMessage(page, 'Detailed error for testing')).toBeVisible(); + await expect(getLogMessage(page, "Detailed error for testing")).toBeVisible(); // Click on the log row (scoped to table layout) - await getLogMessage(page, 'Detailed error for testing').click(); + await getLogMessage(page, "Detailed error for testing").click(); // Modal should open - await expect(page.getByRole('dialog')).toBeVisible(); - await expect(page.getByText('Log Details')).toBeVisible(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByText("Log Details")).toBeVisible(); }); - test('should display all log fields in detail modal', async ({ page }) => { + test("should display all log fields in detail modal", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Wait for log to be visible then click on the row - const logMessage = getLogMessage(page, 'Detailed error for testing'); + const logMessage = getLogMessage(page, "Detailed error for testing"); await expect(logMessage).toBeVisible(); await logMessage.click(); // Wait for modal to open - const modal = page.getByRole('dialog'); + const modal = page.getByRole("dialog"); await expect(modal).toBeVisible({ timeout: 10000 }); // Verify all fields are displayed (scoped to modal to avoid matching table/card elements) // Note: Some values appear both in dedicated fields AND in metadata JSON, so use .first() - await expect(modal.getByText('Detailed error for testing')).toBeVisible(); - await expect(modal.getByText('src/test.ts:42')).toBeVisible(); - await expect(modal.getByText('req_abc123').first()).toBeVisible(); - await expect(modal.getByText('user_456').first()).toBeVisible(); - await expect(modal.getByText('192.168.1.100').first()).toBeVisible(); + await expect(modal.getByText("Detailed error for testing")).toBeVisible(); + await expect(modal.getByText("src/test.ts:42")).toBeVisible(); + await expect(modal.getByText("req_abc123").first()).toBeVisible(); + await expect(modal.getByText("user_456").first()).toBeVisible(); + await expect(modal.getByText("192.168.1.100").first()).toBeVisible(); // Metadata should be pretty-printed await expect(page.locator('[data-testid="log-metadata"]')).toContainText('"key": "value"'); }); - test('should close modal on Escape key', async ({ page }) => { + test("should close modal on Escape key", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open modal (scoped to table layout) - await getLogMessage(page, 'Detailed error for testing').click(); - await expect(page.getByRole('dialog')).toBeVisible(); + await getLogMessage(page, "Detailed error for testing").click(); + await expect(page.getByRole("dialog")).toBeVisible(); // Press Escape - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); // Modal should close - await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole("dialog")).not.toBeVisible(); }); - test('should close modal on overlay click', async ({ page }) => { + test("should close modal on overlay click", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open modal (scoped to table layout) - await getLogMessage(page, 'Detailed error for testing').click(); - await expect(page.getByRole('dialog')).toBeVisible(); + await getLogMessage(page, "Detailed error for testing").click(); + await expect(page.getByRole("dialog")).toBeVisible(); // Click overlay await page.locator('[data-testid="modal-overlay"]').click({ position: { x: 10, y: 10 } }); // Modal should close - await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole("dialog")).not.toBeVisible(); }); }); -test.describe('Log Stream Page - Settings Navigation', () => { +test.describe("Log Stream Page - Settings Navigation", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -521,11 +521,11 @@ test.describe('Log Stream Page - Settings Navigation', () => { } }); - test('should navigate to settings page when clicking settings link', async ({ page }) => { + test("should navigate to settings page when clicking settings link", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Find and click settings link - const settingsLink = page.getByRole('link', { name: /settings/i }); + const settingsLink = page.getByRole("link", { name: /settings/i }); await expect(settingsLink).toBeVisible(); await settingsLink.click(); @@ -533,34 +533,34 @@ test.describe('Log Stream Page - Settings Navigation', () => { await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); }); - test('should not display the API key on the settings page until regenerated', async ({ + test("should not display the API key on the settings page until regenerated", async ({ page, }) => { await page.goto(`/projects/${testProject.id}`); // Navigate to settings page - await page.getByRole('link', { name: /settings/i }).click(); + await page.getByRole("link", { name: /settings/i }).click(); await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); // Keys are hashed and shown only once at creation/regeneration — not on load. - await expect(page.getByTestId('api-key-display')).toHaveCount(0); - await expect(page.getByTestId('regenerate-button')).toBeVisible(); + await expect(page.getByTestId("api-key-display")).toHaveCount(0); + await expect(page.getByTestId("regenerate-button")).toBeVisible(); }); - test('should show curl example on settings page', async ({ page }) => { + test("should show curl example on settings page", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Navigate to settings page - await page.getByRole('link', { name: /settings/i }).click(); + await page.getByRole("link", { name: /settings/i }).click(); await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); // Curl example should be visible (curl is selected by default) - await expect(page.locator('[data-testid="example-code"]')).toContainText('curl'); - await expect(page.locator('[data-testid="example-code"]')).toContainText('Authorization'); + await expect(page.locator('[data-testid="example-code"]')).toContainText("curl"); + await expect(page.locator('[data-testid="example-code"]')).toContainText("Authorization"); }); }); -test.describe('Log Stream Page - Empty State', () => { +test.describe("Log Stream Page - Empty State", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -576,16 +576,16 @@ test.describe('Log Stream Page - Empty State', () => { } }); - test('should show empty state when no logs exist', async ({ page }) => { + test("should show empty state when no logs exist", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Should show empty message - wait for log table to load, then check desktop table empty state await expect(page.locator('[data-testid="log-table"]')).toBeVisible(); - await expect(page.getByRole('cell', { name: 'No logs yet' })).toBeVisible(); + await expect(page.getByRole("cell", { name: "No logs yet" })).toBeVisible(); }); }); -test.describe('Log Stream Page - Navigation', () => { +test.describe("Log Stream Page - Navigation", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -601,15 +601,15 @@ test.describe('Log Stream Page - Navigation', () => { } }); - test('should have back button to dashboard', async ({ page }) => { + test("should have back button to dashboard", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Back button should be visible - const backButton = page.getByRole('link', { name: /back|dashboard|home/i }); + const backButton = page.getByRole("link", { name: /back|dashboard|home/i }); await expect(backButton).toBeVisible(); // Click should navigate to dashboard await backButton.click(); - await expect(page).toHaveURL('/'); + await expect(page).toHaveURL("/"); }); }); diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts index e96759d..7c0854b 100644 --- a/tests/e2e/login.spec.ts +++ b/tests/e2e/login.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; /** * E2E tests for the Login Page @@ -10,35 +10,35 @@ import { expect, test } from '@playwright/test'; // Test user credentials for E2E testing // Matches the seeded admin user from scripts/seed-admin.ts const TEST_USER = { - username: 'admin', - password: 'adminpass', // From .env ADMIN_PASSWORD - name: 'Admin', + username: "admin", + password: "adminpass", // From .env ADMIN_PASSWORD + name: "Admin", }; -test.describe('Login Page', () => { +test.describe("Login Page", () => { test.beforeEach(async ({ page }) => { // Navigate to login page before each test - await page.goto('/login'); + await page.goto("/login"); // Wait for the page to be fully hydrated - await page.waitForSelector('form'); + await page.waitForSelector("form"); }); - test('should display login form with username and password fields', async ({ page }) => { + test("should display login form with username and password fields", async ({ page }) => { // Verify login form elements are present - await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /sign in/i })).toBeVisible(); await expect(page.getByLabel(/username/i)).toBeVisible(); await expect(page.getByLabel(/password/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible(); }); - test('should focus password field on page load', async ({ page }) => { + test("should focus password field on page load", async ({ page }) => { // Per PRD: "Password field (focus on load)" // Email is pre-filled with admin, so password should be focused const passwordField = page.getByLabel(/password/i); await expect(passwordField).toBeFocused(); }); - test('should redirect to / after successful login', async ({ page }) => { + test("should redirect to / after successful login", async ({ page }) => { // Fill in credentials const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -53,23 +53,23 @@ test.describe('Login Page', () => { await expect(passwordInput).toHaveValue(TEST_USER.password); // Click sign in button - const signInButton = page.getByRole('button', { name: /sign in/i }); + const signInButton = page.getByRole("button", { name: /sign in/i }); await signInButton.click(); // Wait for redirect to dashboard - await expect(page).toHaveURL('/', { timeout: 15000 }); + await expect(page).toHaveURL("/", { timeout: 15000 }); }); - test('should show error for invalid credentials', async ({ page }) => { + test("should show error for invalid credentials", async ({ page }) => { // Fill in wrong password const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); await usernameInput.fill(TEST_USER.username); - await passwordInput.fill('WrongPassword123!'); + await passwordInput.fill("WrongPassword123!"); // Click sign in button - await page.getByRole('button', { name: /sign in/i }).click(); + await page.getByRole("button", { name: /sign in/i }).click(); // Wait for error message to appear await expect(page.getByText(/invalid|incorrect|wrong|credentials/i)).toBeVisible({ @@ -80,13 +80,13 @@ test.describe('Login Page', () => { await expect(page).toHaveURL(/\/login/); }); - test('should show error for non-existent user', async ({ page }) => { + test("should show error for non-existent user", async ({ page }) => { // Fill in non-existent username - await page.getByLabel(/username/i).fill('nonexistentuser'); - await page.getByLabel(/password/i).fill('SomePassword123!'); + await page.getByLabel(/username/i).fill("nonexistentuser"); + await page.getByLabel(/password/i).fill("SomePassword123!"); // Click sign in button - await page.getByRole('button', { name: /sign in/i }).click(); + await page.getByRole("button", { name: /sign in/i }).click(); // Wait for error message to appear (better-auth returns generic error to prevent user enumeration) await expect(page.getByText(/invalid username or password/i)).toBeVisible({ @@ -97,7 +97,7 @@ test.describe('Login Page', () => { await expect(page).toHaveURL(/\/login/); }); - test('should submit form when Enter key is pressed', async ({ page }) => { + test("should submit form when Enter key is pressed", async ({ page }) => { // Fill in credentials const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -111,19 +111,19 @@ test.describe('Login Page', () => { await expect(passwordInput).toHaveValue(TEST_USER.password); // Press Enter key instead of clicking button - await passwordInput.press('Enter'); + await passwordInput.press("Enter"); // Wait for redirect to dashboard - await expect(page).toHaveURL('/', { timeout: 15000 }); + await expect(page).toHaveURL("/", { timeout: 15000 }); }); - test('should disable form inputs during submission', async ({ page }) => { + test("should disable form inputs during submission", async ({ page }) => { // Fill in credentials await page.getByLabel(/username/i).fill(TEST_USER.username); await page.getByLabel(/password/i).fill(TEST_USER.password); // Click sign in - check for disabled state during request - const signInButton = page.getByRole('button', { name: /sign in/i }); + const signInButton = page.getByRole("button", { name: /sign in/i }); // Start the click but don't await completion const clickPromise = signInButton.click(); @@ -135,37 +135,37 @@ test.describe('Login Page', () => { await clickPromise; }); - test('should show validation error for empty username', async ({ page }) => { + test("should show validation error for empty username", async ({ page }) => { // Leave username empty, fill password await page.getByLabel(/password/i).fill(TEST_USER.password); // Click sign in button - await page.getByRole('button', { name: /sign in/i }).click(); + await page.getByRole("button", { name: /sign in/i }).click(); // Should show validation error - be specific about the error text - await expect(page.getByText('Username is required')).toBeVisible(); + await expect(page.getByText("Username is required")).toBeVisible(); }); - test('should show validation error for empty password', async ({ page }) => { + test("should show validation error for empty password", async ({ page }) => { // Fill username, leave password empty await page.getByLabel(/username/i).fill(TEST_USER.username); // Click sign in button - await page.getByRole('button', { name: /sign in/i }).click(); + await page.getByRole("button", { name: /sign in/i }).click(); // Should show validation error - be specific about the error text - await expect(page.getByText('Password is required')).toBeVisible(); + await expect(page.getByText("Password is required")).toBeVisible(); }); }); -test.describe('Login Page - Authentication State', () => { +test.describe("Login Page - Authentication State", () => { // TODO: This test is skipped pending session cookie investigation // The server-side session check works but the cookie doesn't persist // in E2E tests after client-side navigation via goto() - test.skip('should redirect authenticated users away from login page', async ({ page }) => { + test.skip("should redirect authenticated users away from login page", async ({ page }) => { // First, log in to get a session - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -176,15 +176,15 @@ test.describe('Login Page - Authentication State', () => { await passwordInput.fill(TEST_USER.password); // Click sign in button - await page.getByRole('button', { name: /sign in/i }).click(); + await page.getByRole("button", { name: /sign in/i }).click(); // Wait for redirect to complete - await expect(page).toHaveURL('/', { timeout: 15000 }); + await expect(page).toHaveURL("/", { timeout: 15000 }); // Now try to visit login page again - await page.goto('/login'); + await page.goto("/login"); // Should redirect away from login (already authenticated) - await expect(page).toHaveURL('/', { timeout: 5000 }); + await expect(page).toHaveURL("/", { timeout: 5000 }); }); }); diff --git a/tests/e2e/otlp-ingestion.spec.ts b/tests/e2e/otlp-ingestion.spec.ts index 1708339..cc57588 100644 --- a/tests/e2e/otlp-ingestion.spec.ts +++ b/tests/e2e/otlp-ingestion.spec.ts @@ -1,24 +1,24 @@ -import { expect, type Page, test } from '@playwright/test'; -import { getLogMessage } from './helpers/log-selectors'; +import { expect, type Page, test } from "@playwright/test"; +import { getLogMessage } from "./helpers/log-selectors"; const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); await page.getByLabel(/username/i).fill(TEST_USER.username); await page.getByLabel(/password/i).fill(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -39,7 +39,7 @@ async function ingestOtlpLog(page: Page, apiKey: string, message: string) { logRecords: [ { severityNumber: 9, - severityText: 'INFO', + severityText: "INFO", body: { stringValue: message }, }, ], @@ -49,10 +49,10 @@ async function ingestOtlpLog(page: Page, apiKey: string, message: string) { ], }; - const response = await page.request.post('/v1/logs', { + const response = await page.request.post("/v1/logs", { headers: { Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, data: payload, }); @@ -60,7 +60,7 @@ async function ingestOtlpLog(page: Page, apiKey: string, message: string) { expect(response.ok()).toBeTruthy(); } -test.describe('OTLP Ingestion', () => { +test.describe("OTLP Ingestion", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { @@ -74,8 +74,8 @@ test.describe('OTLP Ingestion', () => { } }); - test('ingests OTLP logs and renders them in the UI', async ({ page }) => { - const message = 'OTLP log arrives'; + test("ingests OTLP logs and renders them in the UI", async ({ page }) => { + const message = "OTLP log arrives"; await ingestOtlpLog(page, testProject.apiKey, message); await page.goto(`/projects/${testProject.id}`); diff --git a/tests/e2e/pagination.spec.ts b/tests/e2e/pagination.spec.ts index 0737d0a..c23e3cf 100644 --- a/tests/e2e/pagination.spec.ts +++ b/tests/e2e/pagination.spec.ts @@ -1,18 +1,18 @@ -import { expect, type Page, test } from '@playwright/test'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { ingestOtlpLogs } from "./helpers/otlp"; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -25,15 +25,15 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** * Helper to create a project via API */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -55,7 +55,7 @@ async function sendOTLPLogs( page: Page, apiKey: string, count: number, - level: 'debug' | 'info' | 'warn' | 'error' | 'fatal' = 'info', + level: "debug" | "info" | "warn" | "error" | "fatal" = "info", ) { const logs = []; for (let i = 0; i < count; i++) { @@ -68,7 +68,7 @@ async function sendOTLPLogs( await ingestOtlpLogs(page, apiKey, logs); } -test.describe('Cursor-based Pagination', () => { +test.describe("Cursor-based Pagination", () => { test.describe.configure({ retries: 1 }); let testProject: { id: string; name: string; apiKey: string }; @@ -84,9 +84,9 @@ test.describe('Cursor-based Pagination', () => { } }); - test('shows load more button when more logs exist', async ({ page }) => { + test("shows load more button when more logs exist", async ({ page }) => { // Create 150 logs (more than default limit of 100) - await sendOTLPLogs(page, testProject.apiKey, 150, 'info'); + await sendOTLPLogs(page, testProject.apiKey, 150, "info"); // Navigate to project page await page.goto(`/projects/${testProject.id}`); @@ -95,17 +95,17 @@ test.describe('Cursor-based Pagination', () => { await page.waitForSelector('[data-testid="log-table"]', { timeout: 10000 }); // Should show "more available" text - await expect(page.locator('text=more available')).toBeVisible(); + await expect(page.locator("text=more available")).toBeVisible(); // Load more button should be visible const loadMoreButton = page.locator('[data-testid="load-more-button"]'); await expect(loadMoreButton).toBeVisible(); - await expect(loadMoreButton).toHaveText('Load More'); + await expect(loadMoreButton).toHaveText("Load More"); }); - test('hides load more button when all logs are loaded', async ({ page }) => { + test("hides load more button when all logs are loaded", async ({ page }) => { // Create only 50 logs (less than default limit) - await sendOTLPLogs(page, testProject.apiKey, 50, 'info'); + await sendOTLPLogs(page, testProject.apiKey, 50, "info"); await page.goto(`/projects/${testProject.id}`); await page.waitForSelector('[data-testid="log-table"]', { timeout: 10000 }); @@ -115,12 +115,12 @@ test.describe('Cursor-based Pagination', () => { await expect(loadMoreButton).not.toBeVisible(); // Should NOT show "more available" text - await expect(page.locator('text=more available')).not.toBeVisible(); + await expect(page.locator("text=more available")).not.toBeVisible(); }); - test('loads more logs when clicking load more button', async ({ page }) => { + test("loads more logs when clicking load more button", async ({ page }) => { // Create 150 logs - await sendOTLPLogs(page, testProject.apiKey, 150, 'info'); + await sendOTLPLogs(page, testProject.apiKey, 150, "info"); await page.goto(`/projects/${testProject.id}`); await page.waitForSelector('[data-testid="log-table"]', { timeout: 10000 }); @@ -137,7 +137,7 @@ test.describe('Cursor-based Pagination', () => { await loadMoreButton.click(); // Should show loading state - await expect(loadMoreButton).toContainText('Loading...'); + await expect(loadMoreButton).toContainText("Loading..."); // Wait for more logs to load await page.waitForTimeout(2000); @@ -149,7 +149,7 @@ test.describe('Cursor-based Pagination', () => { // Button should return to normal state or disappear if all logs loaded const buttonVisible = await loadMoreButton.isVisible(); if (buttonVisible) { - await expect(loadMoreButton).toContainText('Load More'); + await expect(loadMoreButton).toContainText("Load More"); } }); }); diff --git a/tests/e2e/project-settings.spec.ts b/tests/e2e/project-settings.spec.ts index 02068e0..48e8d0d 100644 --- a/tests/e2e/project-settings.spec.ts +++ b/tests/e2e/project-settings.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from '@playwright/test'; +import { expect, type Page, test } from "@playwright/test"; /** * E2E tests for Project Settings Page @@ -9,16 +9,16 @@ import { expect, type Page, test } from '@playwright/test'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; /** * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -31,8 +31,8 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** @@ -40,7 +40,7 @@ async function login(page: Page) { * Returns the created project data including apiKey */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -59,7 +59,7 @@ async function deleteProject(page: Page, projectId: string) { * Helper to get all projects and delete them */ async function cleanupProjects(page: Page) { - const response = await page.request.get('/api/projects'); + const response = await page.request.get("/api/projects"); if (response.ok()) { const { projects } = await response.json(); for (const project of projects) { @@ -68,13 +68,13 @@ async function cleanupProjects(page: Page) { } } -test.describe('Project Settings - Navigation', () => { +test.describe("Project Settings - Navigation", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'settings-test-project'); + testProject = await createProject(page, "settings-test-project"); }); test.afterEach(async ({ page }) => { @@ -83,26 +83,26 @@ test.describe('Project Settings - Navigation', () => { } }); - test('should navigate to settings page from bottom nav', async ({ page }) => { + test("should navigate to settings page from bottom nav", async ({ page }) => { // Navigate to project page first await page.goto(`/projects/${testProject.id}`); // Click settings in bottom nav (visible on mobile) await page.setViewportSize({ width: 375, height: 667 }); - const settingsLink = page.getByTestId('nav-settings'); + const settingsLink = page.getByTestId("nav-settings"); await expect(settingsLink).toBeVisible(); await settingsLink.click(); // Should be on settings page await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); - await expect(page.getByRole('heading', { name: /project settings/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /project settings/i })).toBeVisible(); }); - test('should navigate back to project from settings', async ({ page }) => { + test("should navigate back to project from settings", async ({ page }) => { await page.goto(`/projects/${testProject.id}/settings`); // Click back link - const backLink = page.getByRole('link', { name: /back to project/i }); + const backLink = page.getByRole("link", { name: /back to project/i }); await expect(backLink).toBeVisible(); await backLink.click(); @@ -111,13 +111,13 @@ test.describe('Project Settings - Navigation', () => { }); }); -test.describe('Project Settings - General Section', () => { +test.describe("Project Settings - General Section", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'general-settings-test'); + testProject = await createProject(page, "general-settings-test"); await page.goto(`/projects/${testProject.id}/settings`); }); @@ -127,82 +127,82 @@ test.describe('Project Settings - General Section', () => { } }); - test('should display project name', async ({ page }) => { - await expect(page.getByTestId('project-name-display')).toHaveText(testProject.name); + test("should display project name", async ({ page }) => { + await expect(page.getByTestId("project-name-display")).toHaveText(testProject.name); }); - test('should edit project name', async ({ page }) => { + test("should edit project name", async ({ page }) => { // Click edit button - await page.getByTestId('edit-name-button').click(); + await page.getByTestId("edit-name-button").click(); // Input should appear with current name - const input = page.getByTestId('project-name-input'); + const input = page.getByTestId("project-name-input"); await expect(input).toBeVisible(); await expect(input).toHaveValue(testProject.name); // Clear and type new name await input.clear(); - await input.fill('renamed-project'); + await input.fill("renamed-project"); // Save - await page.getByTestId('save-name-button').click(); + await page.getByTestId("save-name-button").click(); // Should update display - await expect(page.getByTestId('project-name-display')).toHaveText('renamed-project'); + await expect(page.getByTestId("project-name-display")).toHaveText("renamed-project"); // Update for cleanup - testProject.name = 'renamed-project'; + testProject.name = "renamed-project"; }); - test('should cancel name editing', async ({ page }) => { - await page.getByTestId('edit-name-button').click(); + test("should cancel name editing", async ({ page }) => { + await page.getByTestId("edit-name-button").click(); - const input = page.getByTestId('project-name-input'); + const input = page.getByTestId("project-name-input"); await input.clear(); - await input.fill('should-not-save'); + await input.fill("should-not-save"); // Click cancel - await page.getByTestId('cancel-edit-button').click(); + await page.getByTestId("cancel-edit-button").click(); // Should show original name - await expect(page.getByTestId('project-name-display')).toHaveText(testProject.name); + await expect(page.getByTestId("project-name-display")).toHaveText(testProject.name); }); - test('should show validation error for empty name', async ({ page }) => { - await page.getByTestId('edit-name-button').click(); + test("should show validation error for empty name", async ({ page }) => { + await page.getByTestId("edit-name-button").click(); - const input = page.getByTestId('project-name-input'); + const input = page.getByTestId("project-name-input"); await input.clear(); // Save with empty name - await page.getByTestId('save-name-button').click(); + await page.getByTestId("save-name-button").click(); // Should show error - await expect(page.getByTestId('name-error')).toBeVisible(); - await expect(page.getByTestId('name-error')).toContainText(/cannot be empty/i); + await expect(page.getByTestId("name-error")).toBeVisible(); + await expect(page.getByTestId("name-error")).toContainText(/cannot be empty/i); }); - test('should show validation error for invalid characters', async ({ page }) => { - await page.getByTestId('edit-name-button').click(); + test("should show validation error for invalid characters", async ({ page }) => { + await page.getByTestId("edit-name-button").click(); - const input = page.getByTestId('project-name-input'); + const input = page.getByTestId("project-name-input"); await input.clear(); - await input.fill('invalid name with spaces'); + await input.fill("invalid name with spaces"); - await page.getByTestId('save-name-button').click(); + await page.getByTestId("save-name-button").click(); - await expect(page.getByTestId('name-error')).toBeVisible(); - await expect(page.getByTestId('name-error')).toContainText(/alphanumeric/i); + await expect(page.getByTestId("name-error")).toBeVisible(); + await expect(page.getByTestId("name-error")).toContainText(/alphanumeric/i); }); }); -test.describe('Project Settings - API Key Section', () => { +test.describe("Project Settings - API Key Section", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'apikey-settings-test'); + testProject = await createProject(page, "apikey-settings-test"); await page.goto(`/projects/${testProject.id}/settings`); }); @@ -212,94 +212,94 @@ test.describe('Project Settings - API Key Section', () => { } }); - test('should not display API key on load, only a regenerate button', async ({ page }) => { + test("should not display API key on load, only a regenerate button", async ({ page }) => { // Keys are hashed and shown only once at creation; the settings page no // longer surfaces the live key on load. - await expect(page.getByTestId('api-key-display')).toHaveCount(0); - await expect(page.getByTestId('api-key-once-warning')).toHaveCount(0); - await expect(page.getByTestId('regenerate-button')).toBeVisible(); + await expect(page.getByTestId("api-key-display")).toHaveCount(0); + await expect(page.getByTestId("api-key-once-warning")).toHaveCount(0); + await expect(page.getByTestId("regenerate-button")).toBeVisible(); }); - test('should reveal the new API key after regenerating', async ({ page }) => { + test("should reveal the new API key after regenerating", async ({ page }) => { // The key only appears transiently after a regenerate. - await page.getByTestId('regenerate-button').click(); - await page.getByTestId('confirm-regenerate-button').click(); + await page.getByTestId("regenerate-button").click(); + await page.getByTestId("confirm-regenerate-button").click(); - const apiKeyDisplay = page.getByTestId('api-key-display'); + const apiKeyDisplay = page.getByTestId("api-key-display"); await expect(apiKeyDisplay).toBeVisible(); await expect(apiKeyDisplay).toContainText(/^lw_[A-Za-z0-9_-]{32}$/); // The original key is never re-displayed. await expect(apiKeyDisplay).not.toContainText(testProject.apiKey); - await expect(page.getByTestId('api-key-once-warning')).toBeVisible(); + await expect(page.getByTestId("api-key-once-warning")).toBeVisible(); }); - test('should copy the regenerated API key to clipboard', async ({ + test("should copy the regenerated API key to clipboard", async ({ page, context, browserName, }) => { - test.skip(browserName !== 'chromium', 'Clipboard permissions only supported in Chromium'); + test.skip(browserName !== "chromium", "Clipboard permissions only supported in Chromium"); // Grant clipboard permissions - await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); // The key (and its copy button) only exist after a regenerate. - await page.getByTestId('regenerate-button').click(); - await page.getByTestId('confirm-regenerate-button').click(); + await page.getByTestId("regenerate-button").click(); + await page.getByTestId("confirm-regenerate-button").click(); - const newKey = (await page.getByTestId('api-key-display').textContent())?.trim() ?? ''; + const newKey = (await page.getByTestId("api-key-display").textContent())?.trim() ?? ""; expect(newKey).toMatch(/^lw_[A-Za-z0-9_-]{32}$/); - await page.getByTestId('copy-api-key-button').click(); + await page.getByTestId("copy-api-key-button").click(); // Verify clipboard content matches the freshly regenerated key const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboardText).toBe(newKey); }); - test('should show regenerate confirmation dialog', async ({ page }) => { - await page.getByTestId('regenerate-button').click(); + test("should show regenerate confirmation dialog", async ({ page }) => { + await page.getByTestId("regenerate-button").click(); // Dialog should appear - const dialog = page.getByTestId('regenerate-confirm-dialog'); + const dialog = page.getByTestId("regenerate-confirm-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByText(/regenerate api key/i)).toBeVisible(); await expect(dialog.getByText(/invalidate/i)).toBeVisible(); }); - test('should cancel regeneration', async ({ page }) => { - await page.getByTestId('regenerate-button').click(); - await page.getByTestId('cancel-regenerate-button').click(); + test("should cancel regeneration", async ({ page }) => { + await page.getByTestId("regenerate-button").click(); + await page.getByTestId("cancel-regenerate-button").click(); // Dialog should close - await expect(page.getByTestId('regenerate-confirm-dialog')).not.toBeVisible(); + await expect(page.getByTestId("regenerate-confirm-dialog")).not.toBeVisible(); // No new key was issued, so nothing is displayed. - await expect(page.getByTestId('api-key-display')).toHaveCount(0); + await expect(page.getByTestId("api-key-display")).toHaveCount(0); }); - test('should regenerate API key', async ({ page }) => { + test("should regenerate API key", async ({ page }) => { const originalApiKey = testProject.apiKey; - await page.getByTestId('regenerate-button').click(); - await page.getByTestId('confirm-regenerate-button').click(); + await page.getByTestId("regenerate-button").click(); + await page.getByTestId("confirm-regenerate-button").click(); // Dialog should close - await expect(page.getByTestId('regenerate-confirm-dialog')).not.toBeVisible(); + await expect(page.getByTestId("regenerate-confirm-dialog")).not.toBeVisible(); // API key should be different - const apiKeyDisplay = page.getByTestId('api-key-display'); + const apiKeyDisplay = page.getByTestId("api-key-display"); await expect(apiKeyDisplay).not.toContainText(originalApiKey); }); }); -test.describe('Project Settings - Log Retention Section', () => { +test.describe("Project Settings - Log Retention Section", () => { let testProject: { id: string; name: string; apiKey: string; retentionDays: number | null }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'retention-settings-test'); + testProject = await createProject(page, "retention-settings-test"); await page.goto(`/projects/${testProject.id}/settings`); }); @@ -309,139 +309,139 @@ test.describe('Project Settings - Log Retention Section', () => { } }); - test('should display retention selector with system default', async ({ page }) => { - const selector = page.getByTestId('retention-selector'); + test("should display retention selector with system default", async ({ page }) => { + const selector = page.getByTestId("retention-selector"); await expect(selector).toBeVisible(); // Default should be system default await expect(selector).toContainText(/system default/i); }); - test('should display log statistics', async ({ page }) => { + test("should display log statistics", async ({ page }) => { // Stats section should show total logs and oldest log await expect(page.getByText(/total logs/i)).toBeVisible(); await expect(page.getByText(/oldest log/i)).toBeVisible(); await expect(page.getByText(/effective retention/i)).toBeVisible(); }); - test('should change retention to 30 days', async ({ page }) => { + test("should change retention to 30 days", async ({ page }) => { // Open selector - await page.getByTestId('retention-selector').click(); + await page.getByTestId("retention-selector").click(); // Select 30 days option - await page.getByTestId('retention-option-30').click(); + await page.getByTestId("retention-option-30").click(); // Wait for update await page.waitForTimeout(500); // Selector should show 30 days - await expect(page.getByTestId('retention-selector')).toContainText('30 days'); + await expect(page.getByTestId("retention-selector")).toContainText("30 days"); }); - test('should change retention to never delete', async ({ page }) => { - await page.getByTestId('retention-selector').click(); - await page.getByTestId('retention-option-0').click(); + test("should change retention to never delete", async ({ page }) => { + await page.getByTestId("retention-selector").click(); + await page.getByTestId("retention-option-0").click(); await page.waitForTimeout(500); - await expect(page.getByTestId('retention-selector')).toContainText(/never delete/i); + await expect(page.getByTestId("retention-selector")).toContainText(/never delete/i); }); - test('should persist retention changes after page reload', async ({ page }) => { + test("should persist retention changes after page reload", async ({ page }) => { // Change retention - await page.getByTestId('retention-selector').click(); - await page.getByTestId('retention-option-90').click(); + await page.getByTestId("retention-selector").click(); + await page.getByTestId("retention-option-90").click(); await page.waitForTimeout(500); // Reload page await page.reload(); // Should still show 90 days - await expect(page.getByTestId('retention-selector')).toContainText('90 days'); + await expect(page.getByTestId("retention-selector")).toContainText("90 days"); }); }); -test.describe('Project Settings - Danger Zone', () => { +test.describe("Project Settings - Danger Zone", () => { let testProject: { id: string; name: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'delete-test-project'); + testProject = await createProject(page, "delete-test-project"); await page.goto(`/projects/${testProject.id}/settings`); }); // No afterEach cleanup needed - project should be deleted - test('should show delete button in danger zone', async ({ page }) => { - await expect(page.getByTestId('delete-project-button')).toBeVisible(); + test("should show delete button in danger zone", async ({ page }) => { + await expect(page.getByTestId("delete-project-button")).toBeVisible(); await expect(page.getByText(/danger zone/i)).toBeVisible(); }); - test('should show delete confirmation dialog', async ({ page }) => { - await page.getByTestId('delete-project-button').click(); + test("should show delete confirmation dialog", async ({ page }) => { + await page.getByTestId("delete-project-button").click(); - const dialog = page.getByTestId('delete-confirm-dialog'); + const dialog = page.getByTestId("delete-confirm-dialog"); await expect(dialog).toBeVisible(); - await expect(dialog.getByRole('heading', { name: /delete project/i })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: /delete project/i })).toBeVisible(); await expect(dialog.getByText(/cannot be undone/i)).toBeVisible(); }); - test('should require type-to-confirm before delete', async ({ page }) => { - await page.getByTestId('delete-project-button').click(); + test("should require type-to-confirm before delete", async ({ page }) => { + await page.getByTestId("delete-project-button").click(); // Delete button should be disabled initially - const confirmButton = page.getByTestId('confirm-delete-button'); + const confirmButton = page.getByTestId("confirm-delete-button"); await expect(confirmButton).toBeDisabled(); // Type wrong name - await page.getByTestId('delete-confirm-input').fill('wrong-name'); + await page.getByTestId("delete-confirm-input").fill("wrong-name"); await expect(confirmButton).toBeDisabled(); // Type correct name - await page.getByTestId('delete-confirm-input').fill(testProject.name); + await page.getByTestId("delete-confirm-input").fill(testProject.name); await expect(confirmButton).toBeEnabled(); }); - test('should cancel deletion', async ({ page }) => { - await page.getByTestId('delete-project-button').click(); - await page.getByTestId('cancel-delete-button').click(); + test("should cancel deletion", async ({ page }) => { + await page.getByTestId("delete-project-button").click(); + await page.getByTestId("cancel-delete-button").click(); // Dialog should close - await expect(page.getByTestId('delete-confirm-dialog')).not.toBeVisible(); + await expect(page.getByTestId("delete-confirm-dialog")).not.toBeVisible(); }); - test('should delete project and redirect to home', async ({ page }) => { - await page.getByTestId('delete-project-button').click(); - await page.getByTestId('delete-confirm-input').fill(testProject.name); - await page.getByTestId('confirm-delete-button').click(); + test("should delete project and redirect to home", async ({ page }) => { + await page.getByTestId("delete-project-button").click(); + await page.getByTestId("delete-confirm-input").fill(testProject.name); + await page.getByTestId("confirm-delete-button").click(); // Should redirect to home - await expect(page).toHaveURL('/', { timeout: 10000 }); + await expect(page).toHaveURL("/", { timeout: 10000 }); // Project should not exist anymore (mark as deleted) - testProject.id = ''; + testProject.id = ""; }); - test('should copy project name in delete dialog', async ({ page, context, browserName }) => { - test.skip(browserName !== 'chromium', 'Clipboard permissions only supported in Chromium'); - await context.grantPermissions(['clipboard-read', 'clipboard-write']); + test("should copy project name in delete dialog", async ({ page, context, browserName }) => { + test.skip(browserName !== "chromium", "Clipboard permissions only supported in Chromium"); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByTestId('delete-project-button').click(); - await page.getByTestId('copy-project-name-button').click(); + await page.getByTestId("delete-project-button").click(); + await page.getByTestId("copy-project-name-button").click(); const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboardText).toBe(testProject.name); }); }); -test.describe('Project Settings - Quick Start Section', () => { +test.describe("Project Settings - Quick Start Section", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'quickstart-test'); + testProject = await createProject(page, "quickstart-test"); await page.goto(`/projects/${testProject.id}/settings`); }); @@ -451,65 +451,65 @@ test.describe('Project Settings - Quick Start Section', () => { } }); - test('should display curl example by default', async ({ page }) => { - const codeBlock = page.getByTestId('example-code'); + test("should display curl example by default", async ({ page }) => { + const codeBlock = page.getByTestId("example-code"); await expect(codeBlock).toBeVisible(); - await expect(codeBlock).toContainText('curl'); + await expect(codeBlock).toContainText("curl"); // On load the live key is not shown; the example uses a placeholder. - await expect(codeBlock).toContainText('YOUR_API_KEY'); + await expect(codeBlock).toContainText("YOUR_API_KEY"); }); - test('should switch to TypeScript example', async ({ page }) => { - await page.getByTestId('example-selector').click(); - await page.getByTestId('example-option-typescript').click(); + test("should switch to TypeScript example", async ({ page }) => { + await page.getByTestId("example-selector").click(); + await page.getByTestId("example-option-typescript").click(); - const codeBlock = page.getByTestId('example-code'); - await expect(codeBlock).toContainText('import'); - await expect(codeBlock).toContainText('Logwell'); - await expect(codeBlock).toContainText('YOUR_API_KEY'); + const codeBlock = page.getByTestId("example-code"); + await expect(codeBlock).toContainText("import"); + await expect(codeBlock).toContainText("Logwell"); + await expect(codeBlock).toContainText("YOUR_API_KEY"); }); - test('should switch to JSR example', async ({ page }) => { - await page.getByTestId('example-selector').click(); - await page.getByTestId('example-option-jsr').click(); + test("should switch to JSR example", async ({ page }) => { + await page.getByTestId("example-selector").click(); + await page.getByTestId("example-option-jsr").click(); - const codeBlock = page.getByTestId('example-code'); - await expect(codeBlock).toContainText('@divkix/logwell'); - await expect(codeBlock).toContainText('YOUR_API_KEY'); + const codeBlock = page.getByTestId("example-code"); + await expect(codeBlock).toContainText("@divkix/logwell"); + await expect(codeBlock).toContainText("YOUR_API_KEY"); }); - test('should inline the live key into examples after regenerating', async ({ page }) => { + test("should inline the live key into examples after regenerating", async ({ page }) => { // After regenerating, the freshly issued key is woven into the examples. - await page.getByTestId('regenerate-button').click(); - await page.getByTestId('confirm-regenerate-button').click(); + await page.getByTestId("regenerate-button").click(); + await page.getByTestId("confirm-regenerate-button").click(); - const newKey = (await page.getByTestId('api-key-display').textContent())?.trim() ?? ''; + const newKey = (await page.getByTestId("api-key-display").textContent())?.trim() ?? ""; expect(newKey).toMatch(/^lw_[A-Za-z0-9_-]{32}$/); - const codeBlock = page.getByTestId('example-code'); + const codeBlock = page.getByTestId("example-code"); await expect(codeBlock).toContainText(newKey); - await expect(codeBlock).not.toContainText('YOUR_API_KEY'); + await expect(codeBlock).not.toContainText("YOUR_API_KEY"); }); - test('should copy example code to clipboard', async ({ page, context, browserName }) => { - test.skip(browserName !== 'chromium', 'Clipboard permissions only supported in Chromium'); - await context.grantPermissions(['clipboard-read', 'clipboard-write']); + test("should copy example code to clipboard", async ({ page, context, browserName }) => { + test.skip(browserName !== "chromium", "Clipboard permissions only supported in Chromium"); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByTestId('copy-example-button').click(); + await page.getByTestId("copy-example-button").click(); const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); - expect(clipboardText).toContain('curl'); - expect(clipboardText).toContain('YOUR_API_KEY'); + expect(clipboardText).toContain("curl"); + expect(clipboardText).toContain("YOUR_API_KEY"); }); }); -test.describe('Project Settings - Layout', () => { +test.describe("Project Settings - Layout", () => { let testProject: { id: string; name: string; apiKey: string }; test.beforeEach(async ({ page }) => { await login(page); await cleanupProjects(page); - testProject = await createProject(page, 'layout-test-project'); + testProject = await createProject(page, "layout-test-project"); await page.goto(`/projects/${testProject.id}/settings`); }); @@ -519,10 +519,10 @@ test.describe('Project Settings - Layout', () => { } }); - test('should display 2-column grid on desktop', async ({ page }) => { + test("should display 2-column grid on desktop", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - const grid = page.getByTestId('settings-grid'); + const grid = page.getByTestId("settings-grid"); await expect(grid).toBeVisible(); // Verify grid has 2 columns by checking CSS @@ -532,14 +532,14 @@ test.describe('Project Settings - Layout', () => { }); // Should have 2 equal columns (e.g., "400px 400px" or similar) - const columns = gridStyle.split(' ').filter((c) => c !== ''); + const columns = gridStyle.split(" ").filter((c) => c !== ""); expect(columns.length).toBe(2); }); - test('should stack to single column on mobile', async ({ page }) => { + test("should stack to single column on mobile", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); - const grid = page.getByTestId('settings-grid'); + const grid = page.getByTestId("settings-grid"); await expect(grid).toBeVisible(); // Verify grid has 1 column on mobile @@ -548,14 +548,14 @@ test.describe('Project Settings - Layout', () => { return computed.gridTemplateColumns; }); - const columns = gridStyle.split(' ').filter((c) => c !== ''); + const columns = gridStyle.split(" ").filter((c) => c !== ""); expect(columns.length).toBe(1); }); - test('should keep danger zone full-width outside grid', async ({ page }) => { + test("should keep danger zone full-width outside grid", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - const dangerZone = page.getByTestId('danger-zone'); + const dangerZone = page.getByTestId("danger-zone"); await expect(dangerZone).toBeVisible(); // Danger zone should NOT be inside the grid @@ -565,18 +565,18 @@ test.describe('Project Settings - Layout', () => { expect(isInsideGrid).toBe(false); }); - test('should have correct section order in grid', async ({ page }) => { - const grid = page.getByTestId('settings-grid'); - const sections = grid.locator('section'); + test("should have correct section order in grid", async ({ page }) => { + const grid = page.getByTestId("settings-grid"); + const sections = grid.locator("section"); // 4 sections in grid (General, API Key, Quick Start, Log Retention) await expect(sections).toHaveCount(4); // Verify order by checking headings - const headings = await sections.locator('h2').allTextContents(); - expect(headings[0]).toContain('General'); - expect(headings[1]).toContain('API Key'); - expect(headings[2]).toContain('Quick Start'); - expect(headings[3]).toContain('Log Retention'); + const headings = await sections.locator("h2").allTextContents(); + expect(headings[0]).toContain("General"); + expect(headings[1]).toContain("API Key"); + expect(headings[2]).toContain("Quick Start"); + expect(headings[3]).toContain("Log Retention"); }); }); diff --git a/tests/e2e/responsive.spec.ts b/tests/e2e/responsive.spec.ts index 9bb0f47..a3aa429 100644 --- a/tests/e2e/responsive.spec.ts +++ b/tests/e2e/responsive.spec.ts @@ -1,6 +1,6 @@ -import { expect, type Page, test } from '@playwright/test'; -import { getLogCard } from './helpers/log-selectors'; -import { ingestOtlpLogs } from './helpers/otlp'; +import { expect, type Page, test } from "@playwright/test"; +import { getLogCard } from "./helpers/log-selectors"; +import { ingestOtlpLogs } from "./helpers/otlp"; /** * E2E tests for Responsive Design @@ -14,8 +14,8 @@ import { ingestOtlpLogs } from './helpers/otlp'; // Test user credentials (matches seeded admin from scripts/seed-admin.ts) const TEST_USER = { - username: 'admin', - password: 'adminpass', + username: "admin", + password: "adminpass", }; // Viewport sizes for testing @@ -29,8 +29,8 @@ const VIEWPORTS = { * Helper to perform login */ async function login(page: Page) { - await page.goto('/login'); - await page.waitForSelector('form'); + await page.goto("/login"); + await page.waitForSelector("form"); const usernameInput = page.getByLabel(/username/i); const passwordInput = page.getByLabel(/password/i); @@ -43,15 +43,15 @@ async function login(page: Page) { await passwordInput.fill(TEST_USER.password); await expect(passwordInput).toHaveValue(TEST_USER.password); - await page.getByRole('button', { name: /sign in/i }).click(); - await expect(page).toHaveURL('/', { timeout: 15000 }); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL("/", { timeout: 15000 }); } /** * Helper to create a project via API */ async function createProject(page: Page, name: string) { - const response = await page.request.post('/api/projects', { + const response = await page.request.post("/api/projects", { data: { name }, }); expect(response.ok()).toBeTruthy(); @@ -66,7 +66,7 @@ async function deleteProject(page: Page, projectId: string) { return response.ok(); } -test.describe('Responsive Design - Mobile Viewport', () => { +test.describe("Responsive Design - Mobile Viewport", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.mobile }); @@ -76,9 +76,9 @@ test.describe('Responsive Design - Mobile Viewport', () => { await login(page); testProject = await createProject(page, `responsive-mobile-${Date.now()}`); await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'Test log message one' }, - { level: 'error', message: 'Test error message' }, - { level: 'warn', message: 'Test warning message' }, + { level: "info", message: "Test log message one" }, + { level: "error", message: "Test error message" }, + { level: "warn", message: "Test warning message" }, ]); }); @@ -88,7 +88,7 @@ test.describe('Responsive Design - Mobile Viewport', () => { } }); - test('should show collapsible filter toggle on mobile', async ({ page }) => { + test("should show collapsible filter toggle on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // On mobile, filters should be collapsed behind a toggle button @@ -110,7 +110,7 @@ test.describe('Responsive Design - Mobile Viewport', () => { await expect(levelFilterInPanel).toBeVisible(); }); - test('should show log cards instead of table on mobile', async ({ page }) => { + test("should show log cards instead of table on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // On mobile, logs should be displayed as cards, not table @@ -121,7 +121,7 @@ test.describe('Responsive Design - Mobile Viewport', () => { await expect(logTable).not.toBeVisible(); }); - test('should show bottom navigation on mobile', async ({ page }) => { + test("should show bottom navigation on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Bottom navigation should be visible on mobile @@ -129,30 +129,30 @@ test.describe('Responsive Design - Mobile Viewport', () => { await expect(bottomNav).toBeVisible(); // Bottom nav should contain key navigation items - await expect(bottomNav.getByRole('link', { name: /home|dashboard/i })).toBeVisible(); + await expect(bottomNav.getByRole("link", { name: /home|dashboard/i })).toBeVisible(); await expect(bottomNav.locator('[data-testid="nav-incidents"]')).toBeVisible(); // Use testid for stats link for reliability await expect(bottomNav.locator('[data-testid="nav-stats"]')).toBeVisible(); }); - test('should hide desktop header navigation on mobile', async ({ page }) => { + test("should hide desktop header navigation on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // User name/email text should be hidden on mobile (only show in bottom nav or menu) // Admin user has name: 'Admin' which takes precedence over email - const userText = page.locator('header').getByText(/admin/i); + const userText = page.locator("header").getByText(/admin/i); await expect(userText).not.toBeVisible(); // Logout text should be hidden (icon only or in menu) - const logoutText = page.locator('header').getByText('Logout'); + const logoutText = page.locator("header").getByText("Logout"); await expect(logoutText).not.toBeVisible(); }); - test('should stack project header elements on mobile', async ({ page }) => { + test("should stack project header elements on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Project name should be visible - await expect(page.getByRole('heading', { name: testProject.name })).toBeVisible(); + await expect(page.getByRole("heading", { name: testProject.name })).toBeVisible(); // Stats and Settings buttons should be in bottom nav or condensed // The header should not have buttons cramped together @@ -162,7 +162,7 @@ test.describe('Responsive Design - Mobile Viewport', () => { await expect(headerButtons).not.toBeVisible(); }); - test('should have full-width search input on mobile', async ({ page }) => { + test("should have full-width search input on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open filter panel @@ -181,7 +181,7 @@ test.describe('Responsive Design - Mobile Viewport', () => { }); }); -test.describe('Responsive Design - Tablet Viewport', () => { +test.describe("Responsive Design - Tablet Viewport", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.tablet }); @@ -191,8 +191,8 @@ test.describe('Responsive Design - Tablet Viewport', () => { await login(page); testProject = await createProject(page, `responsive-tablet-${Date.now()}`); await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'Test log message one' }, - { level: 'error', message: 'Test error message' }, + { level: "info", message: "Test log message one" }, + { level: "error", message: "Test error message" }, ]); }); @@ -202,7 +202,7 @@ test.describe('Responsive Design - Tablet Viewport', () => { } }); - test('should show log table on tablet', async ({ page }) => { + test("should show log table on tablet", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Table should be visible on tablet @@ -214,7 +214,7 @@ test.describe('Responsive Design - Tablet Viewport', () => { await expect(getLogCard(page).first()).not.toBeVisible(); }); - test('should show inline filters on tablet', async ({ page }) => { + test("should show inline filters on tablet", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Filters should be visible inline (not collapsed) @@ -226,7 +226,7 @@ test.describe('Responsive Design - Tablet Viewport', () => { await expect(filterToggle).not.toBeVisible(); }); - test('should hide bottom navigation on tablet', async ({ page }) => { + test("should hide bottom navigation on tablet", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Bottom navigation should be hidden on tablet @@ -234,17 +234,17 @@ test.describe('Responsive Design - Tablet Viewport', () => { await expect(bottomNav).not.toBeVisible(); }); - test('should show header navigation on tablet', async ({ page }) => { + test("should show header navigation on tablet", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Header nav items should be visible // User display shows name if available, otherwise email (admin user has name: 'Admin') - await expect(page.locator('header').getByText(/admin/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /logout/i })).toBeVisible(); + await expect(page.locator("header").getByText(/admin/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /logout/i })).toBeVisible(); }); }); -test.describe('Responsive Design - Desktop Viewport', () => { +test.describe("Responsive Design - Desktop Viewport", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.desktop }); @@ -254,9 +254,9 @@ test.describe('Responsive Design - Desktop Viewport', () => { await login(page); testProject = await createProject(page, `responsive-desktop-${Date.now()}`); await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'Test log message one' }, - { level: 'error', message: 'Test error message' }, - { level: 'debug', message: 'Test debug message' }, + { level: "info", message: "Test log message one" }, + { level: "error", message: "Test error message" }, + { level: "debug", message: "Test debug message" }, ]); }); @@ -266,7 +266,7 @@ test.describe('Responsive Design - Desktop Viewport', () => { } }); - test('should show full log table with all columns on desktop', async ({ page }) => { + test("should show full log table with all columns on desktop", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Table should be visible @@ -274,41 +274,41 @@ test.describe('Responsive Design - Desktop Viewport', () => { await expect(logTable).toBeVisible(); // All table columns should be visible - await expect(page.getByRole('columnheader', { name: /time/i })).toBeVisible(); - await expect(page.getByRole('columnheader', { name: /level/i })).toBeVisible(); - await expect(page.getByRole('columnheader', { name: /message/i })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: /time/i })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: /level/i })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: /message/i })).toBeVisible(); }); - test('should show all filter controls inline on desktop', async ({ page }) => { + test("should show all filter controls inline on desktop", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // All filter components should be visible await expect(page.locator('[data-testid="level-filter"]')).toBeVisible(); await expect(page.getByPlaceholder(/search/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /last 15 minutes/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /last 15 minutes/i })).toBeVisible(); await expect(page.locator('[data-testid="live-toggle"]')).toBeVisible(); }); - test('should show header actions on desktop', async ({ page }) => { + test("should show header actions on desktop", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Project action buttons in header should be visible // Stats link has aria-label="View statistics" which overrides visible text - await expect(page.getByRole('link', { name: /view statistics/i })).toBeVisible(); - await expect(page.getByRole('link', { name: /settings/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /view statistics/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /settings/i })).toBeVisible(); }); }); -test.describe('Responsive Design - Dashboard Page', () => { +test.describe("Responsive Design - Dashboard Page", () => { test.describe.configure({ retries: 1 }); test.beforeEach(async ({ page }) => { await login(page); }); - test('should show 1 column grid on mobile', async ({ page }) => { + test("should show 1 column grid on mobile", async ({ page }) => { await page.setViewportSize(VIEWPORTS.mobile); - await page.goto('/'); + await page.goto("/"); // Create a project to see the grid const project = await createProject(page, `grid-test-mobile-${Date.now()}`); @@ -323,9 +323,9 @@ test.describe('Responsive Design - Dashboard Page', () => { await deleteProject(page, project.id); }); - test('should show 2 column grid on tablet', async ({ page }) => { + test("should show 2 column grid on tablet", async ({ page }) => { await page.setViewportSize(VIEWPORTS.tablet); - await page.goto('/'); + await page.goto("/"); // Create a project to see the grid const project = await createProject(page, `grid-test-tablet-${Date.now()}`); @@ -342,9 +342,9 @@ test.describe('Responsive Design - Dashboard Page', () => { await deleteProject(page, project.id); }); - test('should show multi-column grid on desktop', async ({ page }) => { + test("should show multi-column grid on desktop", async ({ page }) => { await page.setViewportSize(VIEWPORTS.desktop); - await page.goto('/'); + await page.goto("/"); // Create a project to see the grid const project = await createProject(page, `grid-test-desktop-${Date.now()}`); @@ -361,7 +361,7 @@ test.describe('Responsive Design - Dashboard Page', () => { }); }); -test.describe('Responsive Design - Filter Collapsing Interaction', () => { +test.describe("Responsive Design - Filter Collapsing Interaction", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.mobile }); @@ -371,9 +371,9 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { await login(page); testProject = await createProject(page, `filter-collapse-${Date.now()}`); await ingestOtlpLogs(page, testProject.apiKey, [ - { level: 'info', message: 'Info message' }, - { level: 'error', message: 'Error message' }, - { level: 'warn', message: 'Warning message' }, + { level: "info", message: "Info message" }, + { level: "error", message: "Error message" }, + { level: "warn", message: "Warning message" }, ]); }); @@ -383,7 +383,7 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { } }); - test('should toggle filter visibility on mobile', async ({ page }) => { + test("should toggle filter visibility on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const filterToggle = page.locator('[data-testid="filter-toggle"]'); @@ -397,11 +397,11 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { await expect(filterPanel).toBeVisible(); // Close filters by clicking backdrop - await page.getByRole('button', { name: /close filter panel/i }).click(); + await page.getByRole("button", { name: /close filter panel/i }).click(); await expect(filterPanel).not.toBeVisible(); }); - test('should apply filters from collapsed panel', async ({ page }) => { + test("should apply filters from collapsed panel", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open filter panel @@ -410,19 +410,19 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { // Apply level filter (within the panel) const filterPanel = page.locator('[data-testid="filter-panel"]'); const levelFilter = filterPanel.locator('[data-testid="level-filter"]'); - await levelFilter.getByRole('button', { name: /error/i }).click(); + await levelFilter.getByRole("button", { name: /error/i }).click(); // Wait for filter to apply await page.waitForTimeout(500); // Should show only error logs in card view - await expect(getLogCard(page, { hasText: 'Error message' })).toBeVisible(); + await expect(getLogCard(page, { hasText: "Error message" })).toBeVisible(); // Info message should be hidden (check within visible mobile cards container) - await expect(getLogCard(page, { hasText: 'Info message' })).not.toBeVisible(); + await expect(getLogCard(page, { hasText: "Info message" })).not.toBeVisible(); }); - test('should show active filter count badge on toggle button', async ({ page }) => { + test("should show active filter count badge on toggle button", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Open filter panel @@ -434,14 +434,14 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { await filterPanel .locator('[data-testid="level-filter"]') - .getByRole('button', { name: /error/i }) + .getByRole("button", { name: /error/i }) .click(); // Wait for filter to apply await page.waitForTimeout(300); // Close filter panel by pressing Escape - await page.keyboard.press('Escape'); + await page.keyboard.press("Escape"); await expect(filterPanel).not.toBeVisible(); // Badge should show active filter count @@ -449,11 +449,11 @@ test.describe('Responsive Design - Filter Collapsing Interaction', () => { '[data-testid="filter-toggle"] [data-testid="filter-count-badge"]', ); await expect(filterBadge).toBeVisible(); - await expect(filterBadge).toContainText('1'); + await expect(filterBadge).toContainText("1"); }); }); -test.describe('Responsive Design - Log Card Layout', () => { +test.describe("Responsive Design - Log Card Layout", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.mobile }); @@ -464,9 +464,9 @@ test.describe('Responsive Design - Log Card Layout', () => { testProject = await createProject(page, `log-cards-${Date.now()}`); await ingestOtlpLogs(page, testProject.apiKey, [ { - level: 'error', - message: 'Database connection failed with timeout error', - attributes: { key: 'value' }, + level: "error", + message: "Database connection failed with timeout error", + attributes: { key: "value" }, }, ]); }); @@ -477,7 +477,7 @@ test.describe('Responsive Design - Log Card Layout', () => { } }); - test('should display log cards with all essential info on mobile', async ({ page }) => { + test("should display log cards with all essential info on mobile", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const logCard = getLogCard(page).first(); @@ -489,19 +489,19 @@ test.describe('Responsive Design - Log Card Layout', () => { await expect(logCard.locator('[data-testid="log-message-mobile"]')).toBeVisible(); }); - test('should open detail modal when clicking log card', async ({ page }) => { + test("should open detail modal when clicking log card", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); // Click on log card await getLogCard(page).first().click(); // Detail modal should open - await expect(page.getByRole('dialog')).toBeVisible(); - await expect(page.getByText('Log Details')).toBeVisible(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByText("Log Details")).toBeVisible(); }); }); -test.describe('Responsive Design - Bottom Navigation', () => { +test.describe("Responsive Design - Bottom Navigation", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.mobile }); @@ -518,16 +518,16 @@ test.describe('Responsive Design - Bottom Navigation', () => { } }); - test('should navigate to dashboard via bottom nav', async ({ page }) => { + test("should navigate to dashboard via bottom nav", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); - await bottomNav.getByRole('link', { name: /home|dashboard/i }).click(); + await bottomNav.getByRole("link", { name: /home|dashboard/i }).click(); - await expect(page).toHaveURL('/'); + await expect(page).toHaveURL("/"); }); - test('should navigate to stats via bottom nav', async ({ page }) => { + test("should navigate to stats via bottom nav", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); @@ -537,7 +537,7 @@ test.describe('Responsive Design - Bottom Navigation', () => { await expect(page).toHaveURL(`/projects/${testProject.id}/stats`); }); - test('should navigate to incidents via bottom nav', async ({ page }) => { + test("should navigate to incidents via bottom nav", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); @@ -546,7 +546,7 @@ test.describe('Responsive Design - Bottom Navigation', () => { await expect(page).toHaveURL(`/projects/${testProject.id}/incidents`); }); - test('should navigate to settings from bottom nav', async ({ page }) => { + test("should navigate to settings from bottom nav", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); @@ -556,25 +556,25 @@ test.describe('Responsive Design - Bottom Navigation', () => { await expect(page).toHaveURL(`/projects/${testProject.id}/settings`); }); - test('should highlight active navigation item', async ({ page }) => { + test("should highlight active navigation item", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); // "Logs" should be active on the log stream page const logsNavItem = bottomNav.locator('[data-testid="nav-logs"]'); - await expect(logsNavItem).toHaveAttribute('data-active', 'true'); + await expect(logsNavItem).toHaveAttribute("data-active", "true"); // Navigate to stats using testid for reliability await bottomNav.locator('[data-testid="nav-stats"]').click(); // "Stats" should now be active const statsNavItem = bottomNav.locator('[data-testid="nav-stats"]'); - await expect(statsNavItem).toHaveAttribute('data-active', 'true'); + await expect(statsNavItem).toHaveAttribute("data-active", "true"); }); }); -test.describe('Responsive Design - Accessibility', () => { +test.describe("Responsive Design - Accessibility", () => { test.describe.configure({ retries: 1 }); test.use({ viewport: VIEWPORTS.mobile }); @@ -591,12 +591,12 @@ test.describe('Responsive Design - Accessibility', () => { } }); - test('should have proper aria labels on filter toggle', async ({ page }) => { + test("should have proper aria labels on filter toggle", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const filterToggle = page.locator('[data-testid="filter-toggle"]'); - await expect(filterToggle).toHaveAttribute('aria-label', /filter|filters/i); - await expect(filterToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(filterToggle).toHaveAttribute("aria-label", /filter|filters/i); + await expect(filterToggle).toHaveAttribute("aria-expanded", "false"); await filterToggle.click(); @@ -605,32 +605,32 @@ test.describe('Responsive Design - Accessibility', () => { await expect(filterPanel).toBeVisible(); // aria-expanded should now be true - await expect(filterToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(filterToggle).toHaveAttribute("aria-expanded", "true"); }); - test('should have proper aria labels on bottom navigation', async ({ page }) => { + test("should have proper aria labels on bottom navigation", async ({ page }) => { await page.goto(`/projects/${testProject.id}`); const bottomNav = page.locator('[data-testid="bottom-nav"]'); //