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 }) =>