From b9ef0b38d33ffc76a52d3eb7cd523d3d59208efd Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:16:26 +0000 Subject: [PATCH 1/4] ci: three-minute PR check, two previews per PR Pull requests now run lint, types, unit, Edge Function and build checks beside a 31-test desktop browser smoke set. The full browser suite runs nightly. The database gate and live provider journeys run only when their paths change, so most pushes no longer start Supabase or spend provider credit. Docs-only PRs run the docs check alone. The unit suite keeps 15 files that guard security boundaries, Current Debate isolation, project selection, stream parsing, source import and the Crossfire and Sparring rules. The coverage gate is removed. VITE_SUPABASE_ENV pins a build to one project, for a second Vercel project that builds staging previews. Edge Function CORS and the demo-code redirect accept both projects' preview hosts, which previews could not call before. db-gate.test.ts stubs docker, so the unit suite no longer removes the fake provider container another worktree's gate is using. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/browser-nightly.yml | 62 + .github/workflows/database.yml | 104 ++ .github/workflows/deploy.yml | 66 +- .github/workflows/docs.yml | 29 + .github/workflows/live-e2e.yml | 12 +- AGENTS.md | 4 +- README.md | 5 +- docs/operations.md | 26 +- docs/testing.md | 24 +- e2e/admin-announcements.spec.ts | 2 +- e2e/authed-pages.spec.ts | 3 +- e2e/crossfire.spec.ts | 2 +- e2e/first-run.spec.ts | 4 +- e2e/login.spec.ts | 2 +- e2e/public-pages.spec.ts | 2 +- e2e/sparring.spec.ts | 2 +- e2e/startup-shell.spec.ts | 10 +- package.json | 4 +- .../2026-10-01-ci-and-preview-verification.md | 70 + plans/README.md | 1 + src/components/StagingBanner.tsx | 6 +- .../features/settings/EnvironmentSwitcher.tsx | 4 +- src/lib/supabase/env.ts | 27 +- src/lib/supabase/switch-env.ts | 4 +- src/test/ai/client-analytics.test.ts | 262 ---- src/test/ai/client-branches.test.ts | 961 ------------- src/test/ai/client.test.ts | 506 ------- src/test/ai/control-payload-matrix.test.ts | 166 --- src/test/ai/crossfire-client.test.ts | 271 ---- src/test/ai/error-display.test.ts | 24 - src/test/ai/request-dispatch.test.ts | 200 --- src/test/ai/sparring.test.ts | 197 --- src/test/ai/streaming-integration.test.ts | 386 ----- src/test/announcements.test.ts | 85 -- ...pp-route-analytics-error-boundary.test.tsx | 230 --- src/test/auth/AccountQueryCache.test.tsx | 29 - src/test/auth/AuthFlow.test.tsx | 97 -- src/test/auth/LoginPage.test.tsx | 128 -- .../components/AdminAnnouncements.test.tsx | 194 --- src/test/components/AnimatedSection.test.tsx | 100 -- .../components/AnnouncementBanner.test.tsx | 39 - src/test/components/AppLayout.test.tsx | 326 ----- src/test/components/AskChat.test.tsx | 146 -- src/test/components/AttackForm.test.tsx | 66 - src/test/components/AttackResults.test.tsx | 37 - .../components/AutoSavedSourcesUndo.test.tsx | 85 -- .../components/EnvironmentSwitcher.test.tsx | 23 + src/test/components/ExportToolbar.test.tsx | 29 - src/test/components/FactoidsResults.test.tsx | 68 - .../components/GenerateFormHonesty.test.tsx | 27 - src/test/components/LanguageSwitcher.test.tsx | 36 - .../components/LimitReachedModal.test.tsx | 31 - src/test/components/Markdown.test.tsx | 100 -- src/test/components/ModelSelector.test.tsx | 256 ---- .../components/SourceQualityList.test.tsx | 97 -- src/test/components/SparringSetup.test.tsx | 68 - .../components/StreamingIndicator.test.tsx | 92 -- src/test/components/ui.test.tsx | 273 ---- src/test/crossfire/prefill.test.ts | 53 - src/test/crossfire/schemas.test.ts | 145 -- src/test/crossfire/storage.test.ts | 169 --- src/test/date-format.test.ts | 39 - src/test/debate/motion.test.ts | 38 - src/test/docs/check-docs.test.ts | 158 -- src/test/export-google-docs.test.ts | 115 -- src/test/export-telemetry.test.ts | 192 --- src/test/export-utils-extended.test.ts | 225 --- src/test/export-utils.test.ts | 164 --- src/test/helpers/downloads.ts | 28 - src/test/hooks/use-subscription-error.test.ts | 85 -- src/test/hooks/use-subscription.test.ts | 549 ------- src/test/hooks/use-toast.test.ts | 44 - src/test/i18n.test.ts | 194 --- src/test/jam-loader.test.ts | 49 - src/test/jam-metadata.test.ts | 24 - src/test/native-app-url-listener.test.ts | 53 - src/test/native-runtime.test.ts | 12 - src/test/native.test.ts | 52 - src/test/observability.test.ts | 361 ----- src/test/pages/AdminPage.test.tsx | 691 --------- src/test/pages/AttackPage.test.tsx | 180 --- src/test/pages/ConversationPages.test.tsx | 731 ---------- src/test/pages/CrossfirePage.test.tsx | 231 --- src/test/pages/FlowchartPage.test.tsx | 145 -- src/test/pages/GeneratePage.test.tsx | 356 ----- src/test/pages/HistoryPage.test.tsx | 161 --- src/test/pages/LibraryPage.test.tsx | 351 ----- .../pages/SettingsAdminNavigation.test.tsx | 116 -- src/test/pages/SettingsPage.test.tsx | 159 -- src/test/pages/SourcesPage.test.tsx | 22 - src/test/pages/SparringPage.test.tsx | 403 ------ src/test/pages/TimerPage.test.tsx | 170 --- src/test/pages/pages.test.tsx | 132 -- src/test/pdf-export-utils.test.ts | 132 -- src/test/providers.test.ts | 27 - src/test/register-service-worker.test.ts | 71 - src/test/route-modules.test.ts | 74 - src/test/route-skeleton.test.tsx | 81 -- src/test/runtime-contracts.test.ts | 27 - src/test/schemas.test.ts | 664 --------- src/test/scripts/backend-status.test.ts | 185 --- src/test/scripts/db-gate.test.ts | 10 +- src/test/scripts/smoke-jev.test.ts | 41 - src/test/sentry-event-filter.test.ts | 31 - src/test/source-file-import.test.ts | 97 -- src/test/sparring-ai.test.ts | 72 - src/test/sparring/exports.test.ts | 219 --- src/test/stores/session-store.test.ts | 1279 ----------------- src/test/stores/settings-store.test.ts | 748 ---------- src/test/stores/sparring-store.test.ts | 492 ------- src/test/stripe.test.ts | 267 ---- src/test/supabase/pinned-env.test.ts | 31 + src/test/supabase/prompt-builder.test.ts | 207 --- src/test/use-route-prefetch.test.tsx | 264 ---- src/test/use-speech-to-text.test.tsx | 97 -- src/test/validation.test.ts | 125 -- src/test/webhook-event-lifecycle.test.ts | 19 - src/test/workspace/collaboration.test.ts | 157 -- .../workspace/document-conversion.test.ts | 147 -- src/test/workspace/durable-ack.test.ts | 27 - .../workspace/fixtures/structured-speech.ts | 152 -- src/test/workspace/member-removal.test.ts | 30 - src/test/workspace/navigation.test.tsx | 201 --- src/test/workspace/review-panel.test.tsx | 67 - src/test/workspace/review.test.ts | 495 ------- src/test/workspace/tool-context.test.ts | 45 - src/vite-env.d.ts | 1 + supabase/functions/_shared/cors.test.ts | 26 +- supabase/functions/_shared/cors.ts | 17 +- supabase/functions/demo-code-login/index.ts | 8 +- .../functions/demo-code-login/index_test.ts | 21 +- vite.config.ts | 8 +- vitest.config.ts | 30 - 133 files changed, 514 insertions(+), 18885 deletions(-) create mode 100644 .github/workflows/browser-nightly.yml create mode 100644 .github/workflows/database.yml create mode 100644 .github/workflows/docs.yml create mode 100644 plans/2026-10-01-ci-and-preview-verification.md delete mode 100644 src/test/ai/client-analytics.test.ts delete mode 100644 src/test/ai/client-branches.test.ts delete mode 100644 src/test/ai/client.test.ts delete mode 100644 src/test/ai/control-payload-matrix.test.ts delete mode 100644 src/test/ai/crossfire-client.test.ts delete mode 100644 src/test/ai/error-display.test.ts delete mode 100644 src/test/ai/request-dispatch.test.ts delete mode 100644 src/test/ai/sparring.test.ts delete mode 100644 src/test/ai/streaming-integration.test.ts delete mode 100644 src/test/announcements.test.ts delete mode 100644 src/test/app-route-analytics-error-boundary.test.tsx delete mode 100644 src/test/auth/AccountQueryCache.test.tsx delete mode 100644 src/test/auth/AuthFlow.test.tsx delete mode 100644 src/test/auth/LoginPage.test.tsx delete mode 100644 src/test/components/AdminAnnouncements.test.tsx delete mode 100644 src/test/components/AnimatedSection.test.tsx delete mode 100644 src/test/components/AnnouncementBanner.test.tsx delete mode 100644 src/test/components/AppLayout.test.tsx delete mode 100644 src/test/components/AskChat.test.tsx delete mode 100644 src/test/components/AttackForm.test.tsx delete mode 100644 src/test/components/AttackResults.test.tsx delete mode 100644 src/test/components/AutoSavedSourcesUndo.test.tsx delete mode 100644 src/test/components/ExportToolbar.test.tsx delete mode 100644 src/test/components/FactoidsResults.test.tsx delete mode 100644 src/test/components/GenerateFormHonesty.test.tsx delete mode 100644 src/test/components/LanguageSwitcher.test.tsx delete mode 100644 src/test/components/LimitReachedModal.test.tsx delete mode 100644 src/test/components/Markdown.test.tsx delete mode 100644 src/test/components/ModelSelector.test.tsx delete mode 100644 src/test/components/SourceQualityList.test.tsx delete mode 100644 src/test/components/SparringSetup.test.tsx delete mode 100644 src/test/components/StreamingIndicator.test.tsx delete mode 100644 src/test/components/ui.test.tsx delete mode 100644 src/test/crossfire/prefill.test.ts delete mode 100644 src/test/crossfire/schemas.test.ts delete mode 100644 src/test/crossfire/storage.test.ts delete mode 100644 src/test/date-format.test.ts delete mode 100644 src/test/debate/motion.test.ts delete mode 100644 src/test/docs/check-docs.test.ts delete mode 100644 src/test/export-google-docs.test.ts delete mode 100644 src/test/export-telemetry.test.ts delete mode 100644 src/test/export-utils-extended.test.ts delete mode 100644 src/test/export-utils.test.ts delete mode 100644 src/test/helpers/downloads.ts delete mode 100644 src/test/hooks/use-subscription-error.test.ts delete mode 100644 src/test/hooks/use-subscription.test.ts delete mode 100644 src/test/hooks/use-toast.test.ts delete mode 100644 src/test/i18n.test.ts delete mode 100644 src/test/jam-loader.test.ts delete mode 100644 src/test/jam-metadata.test.ts delete mode 100644 src/test/native-app-url-listener.test.ts delete mode 100644 src/test/native-runtime.test.ts delete mode 100644 src/test/native.test.ts delete mode 100644 src/test/observability.test.ts delete mode 100644 src/test/pages/AdminPage.test.tsx delete mode 100644 src/test/pages/AttackPage.test.tsx delete mode 100644 src/test/pages/ConversationPages.test.tsx delete mode 100644 src/test/pages/CrossfirePage.test.tsx delete mode 100644 src/test/pages/FlowchartPage.test.tsx delete mode 100644 src/test/pages/GeneratePage.test.tsx delete mode 100644 src/test/pages/HistoryPage.test.tsx delete mode 100644 src/test/pages/LibraryPage.test.tsx delete mode 100644 src/test/pages/SettingsAdminNavigation.test.tsx delete mode 100644 src/test/pages/SettingsPage.test.tsx delete mode 100644 src/test/pages/SourcesPage.test.tsx delete mode 100644 src/test/pages/SparringPage.test.tsx delete mode 100644 src/test/pages/TimerPage.test.tsx delete mode 100644 src/test/pages/pages.test.tsx delete mode 100644 src/test/pdf-export-utils.test.ts delete mode 100644 src/test/providers.test.ts delete mode 100644 src/test/register-service-worker.test.ts delete mode 100644 src/test/route-modules.test.ts delete mode 100644 src/test/route-skeleton.test.tsx delete mode 100644 src/test/runtime-contracts.test.ts delete mode 100644 src/test/schemas.test.ts delete mode 100644 src/test/scripts/backend-status.test.ts delete mode 100644 src/test/scripts/smoke-jev.test.ts delete mode 100644 src/test/sentry-event-filter.test.ts delete mode 100644 src/test/source-file-import.test.ts delete mode 100644 src/test/sparring-ai.test.ts delete mode 100644 src/test/sparring/exports.test.ts delete mode 100644 src/test/stores/session-store.test.ts delete mode 100644 src/test/stores/settings-store.test.ts delete mode 100644 src/test/stores/sparring-store.test.ts delete mode 100644 src/test/stripe.test.ts create mode 100644 src/test/supabase/pinned-env.test.ts delete mode 100644 src/test/supabase/prompt-builder.test.ts delete mode 100644 src/test/use-route-prefetch.test.tsx delete mode 100644 src/test/use-speech-to-text.test.tsx delete mode 100644 src/test/validation.test.ts delete mode 100644 src/test/webhook-event-lifecycle.test.ts delete mode 100644 src/test/workspace/collaboration.test.ts delete mode 100644 src/test/workspace/document-conversion.test.ts delete mode 100644 src/test/workspace/durable-ack.test.ts delete mode 100644 src/test/workspace/fixtures/structured-speech.ts delete mode 100644 src/test/workspace/member-removal.test.ts delete mode 100644 src/test/workspace/navigation.test.tsx delete mode 100644 src/test/workspace/review-panel.test.tsx delete mode 100644 src/test/workspace/review.test.ts delete mode 100644 src/test/workspace/tool-context.test.ts diff --git a/.github/workflows/browser-nightly.yml b/.github/workflows/browser-nightly.yml new file mode 100644 index 000000000..a189ae59e --- /dev/null +++ b/.github/workflows/browser-nightly.yml @@ -0,0 +1,62 @@ +name: Browser nightly + +# The full credential-free browser suite on desktop and mobile Chromium. Pull +# requests run only the @smoke subset (deploy.yml), so this catches what the +# subset skips, at most a day late. +on: + schedule: + - cron: "47 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: browser-nightly + cancel-in-progress: true + +jobs: + browser-tests: + 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 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Browser tests + env: + CI: 1 + PORT: 18080 + PLAYWRIGHT_BASE_URL: http://127.0.0.1:18080 + run: npm run test:e2e + + - name: Upload browser failure artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: chromium-nightly-failure-${{ github.run_id }} + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml new file mode 100644 index 000000000..313d4c64d --- /dev/null +++ b/.github/workflows/database.yml @@ -0,0 +1,104 @@ +name: Database + +# Local Supabase, pgTAP, and the full-stack browser journeys. About 20 minutes, +# so pull requests run it only when they touch the backend, the stores and +# clients that talk to it, or the gate itself. Pushes to main use the same +# filter, and the weekly run catches frontend-only regressions. +on: + pull_request: + paths: + - "supabase/**" + - "services/collaboration/**" + - "src/lib/supabase/**" + - "src/lib/stores/**" + - "src/lib/ai/**" + - "e2e/full-stack/**" + - "e2e/workspace-collaboration.spec.ts" + - "playwright.workspace.config.ts" + - ".superset/db-gate.sh" + - ".superset/lib/**" + - ".superset/workspace-test.env" + - "scripts/test-*.sh" + - "scripts/workspace-test-env.mjs" + - "package.json" + - "package-lock.json" + - ".github/workflows/database.yml" + push: + branches: [ "main" ] + paths: + - "supabase/**" + - "services/collaboration/**" + - "src/lib/supabase/**" + - "src/lib/stores/**" + - "src/lib/ai/**" + - "e2e/full-stack/**" + - "e2e/workspace-collaboration.spec.ts" + - "playwright.workspace.config.ts" + - ".superset/db-gate.sh" + - ".superset/lib/**" + - ".superset/workspace-test.env" + - "scripts/test-*.sh" + - "scripts/workspace-test-env.mjs" + - "package.json" + - "package-lock.json" + - ".github/workflows/database.yml" + schedule: + - cron: "23 5 * * 0" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + database-tests: + runs-on: ubuntu-latest + 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.116.0 + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Database and real workspace journey + env: + # ghcr.io rate-limits shared runners; the same images are on ECR. + SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws + WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate + run: ./.superset/db-gate.sh + + - name: Upload workspace failure artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: workspace-gate-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/deploy.yml b/.github/workflows/deploy.yml index cd3210fd5..851863857 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,11 @@ on: push: branches: [ "main" ] pull_request: + # Docs-only pull requests run docs.yml instead. + paths-ignore: + - "docs/**" + - "plans/**" + - "**/*.md" workflow_dispatch: permissions: @@ -26,6 +31,7 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 + cache: npm - name: Install dependencies run: npm ci @@ -46,7 +52,7 @@ jobs: run: npm run typecheck - name: Test - run: npm test -- --coverage + run: npm test - name: Collaboration service tests run: npm run test:collaboration @@ -57,7 +63,9 @@ jobs: - name: Build run: npm run build - database-tests: + # Runs beside quality so the pull-request check takes as long as the slower + # of the two. The full desktop and mobile suite runs in browser-nightly.yml. + browser-smoke: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -70,70 +78,32 @@ jobs: 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.116.0 + cache: npm - name: Install dependencies run: npm ci - - name: Install Chromium - run: npx playwright install --with-deps chromium - - - name: Database and real workspace journey - env: - # ghcr.io rate-limits shared runners; the same images are on ECR. - SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws - WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate - run: ./.superset/db-gate.sh - - - name: Upload workspace failure artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: workspace-gate-failure-${{ github.run_id }} - path: | - ${{ runner.temp }}/workspace-gate/ - test-results/ - playwright-report/ - if-no-files-found: ignore - retention-days: 7 - - browser-tests: - if: github.event_name == 'pull_request' - needs: quality - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - persist-credentials: false - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - - - name: Install dependencies - run: npm ci + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Install Chromium run: npx playwright install --with-deps chromium - - name: Browser tests + - name: Browser smoke tests env: CI: 1 PORT: 18080 PLAYWRIGHT_BASE_URL: http://127.0.0.1:18080 - run: npm run test:e2e + run: npm run test:e2e:smoke - name: Upload browser failure artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: chromium-browser-failure-${{ github.run_id }} + name: chromium-smoke-failure-${{ github.run_id }} path: | test-results/ playwright-report/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..6808f91cd --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Docs + +# Docs-only pull requests skip the Quality workflow, so they get this check. +on: + pull_request: + paths: + - "docs/**" + - "plans/**" + - "**/*.md" + +permissions: + contents: read + +jobs: + docs: + runs-on: ubuntu-latest + 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: Documentation + run: npm run docs:check diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 5ee031644..ecb81e64f 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,9 +1,17 @@ name: Live provider E2E -# Real OpenRouter, Jev, and Exa behind the local stack. Runs on pull requests, -# nightly to catch provider drift without code changes, and on demand. +# Real OpenRouter, Jev, and Exa behind the local stack. Every run spends +# provider credit, so pull requests run it only when AI or research code +# changes. Nightly runs catch provider drift without code changes. on: pull_request: + paths: + - "supabase/functions/**" + - "src/lib/ai/**" + - "e2e/live/**" + - "e2e/full-stack/fake-openrouter.mjs" + - ".superset/db-gate.sh" + - ".github/workflows/live-e2e.yml" schedule: - cron: "17 3 * * *" workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index f01992aea..d1b8ab43c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,14 +41,14 @@ Run the smallest relevant check first: npm run docs:check npm run lint npm run typecheck -npm test -- --coverage +npm test npm run test:collaboration npm run test:edge npm run build CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e ``` -`npm run verify` runs the complete credential-free ladder, including desktop and mobile Chromium against a fresh Vite server. +`npm run verify` runs the credential-free ladder with the desktop Chromium `@smoke` browser set (about a minute). `npm run verify:full` adds the full desktop and mobile suite. 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 the database gate) and record the exact results in the PR. A local pass is not a CI pass; say which one you have. diff --git a/README.md b/README.md index 88684a941..b8ebdd52e 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,10 @@ Vite serves on port `8080` by default. In a Superset workspace or other linked w ```bash npm run lint npm run typecheck -npm test -- --coverage +npm test npm run build -npm run verify # full credential-free gate, including desktop and mobile Chromium +npm run verify # credential-free gate with the desktop Chromium smoke set +npm run verify:full # adds the full desktop and mobile browser suite ./.superset/db-gate.sh # migrations, pgTAP, and full-stack journeys on local Supabase ``` diff --git a/docs/operations.md b/docs/operations.md index 43c17957b..5ef49d290 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -10,20 +10,23 @@ Never infer authorization from access. Never use production to replace missing l ## CI workflows -[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow) owns credential-free quality plus pull-request database and browser tests. It does not deploy anything; Vercel does. +[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow) owns credential-free quality and the pull-request browser smoke set. It does not deploy anything; Vercel does. The quality job uses Node 22, `npm ci`, and Deno 2.9.6, then runs: - `npm run docs:check` - `npm run lint` - `npm run typecheck` -- `npm test -- --coverage` +- `npm test` +- `npm run test:collaboration` - `npm run test:edge` - `npm run build` -Pull requests also start isolated local Supabase for pgTAP and run desktop plus mobile Chromium on port `18080`. +On pull requests a parallel `browser-smoke` job runs the Playwright tests tagged `@smoke` (about 30) on desktop Chromium, port `18080`, with the browser download cached. [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml) runs the full desktop and mobile suite nightly and on demand. Pull requests that change only `docs/`, `plans/`, or Markdown skip `Quality` and run [`.github/workflows/docs.yml`](../.github/workflows/docs.yml), which runs `npm run docs:check` alone. -[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the local stack against real providers on pull requests and nightly. [`.github/workflows/model-matrix.yml`](../.github/workflows/model-matrix.yml) generates a case with every catalog model weekly and when AI code changes. [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) checks production after each Vercel production deployment and daily. See [testing.md](testing.md) for all three. Both Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners. +[`.github/workflows/database.yml`](../.github/workflows/database.yml) starts isolated local Supabase for pgTAP and the full-stack browser journeys. It takes about 20 minutes, so it runs on pull requests and `main` pushes only when they change the backend, the stores or clients that talk to it, or the gate itself (the `paths` list in the file), plus weekly and on demand. + +[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the local stack against real providers nightly and on pull requests that change AI or research code. [`.github/workflows/model-matrix.yml`](../.github/workflows/model-matrix.yml) generates a case with every catalog model weekly and when AI code changes. [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) checks production after each Vercel production deployment and daily. See [testing.md](testing.md) for all three. Both Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners. [`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging. It accepts eligible successful main-branch `Quality` runs or a manual dispatch from `main`. It runs only when `STAGING_E2E_ENABLED=true` and all protected values exist. @@ -33,7 +36,7 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou ## Frontend hosting on Vercel -Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests may get preview deployments. The production canary runs after each successful Vercel `Production` deployment. +Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests get two preview deployments (see [Per-PR previews](#per-pr-previews)). The production canary runs after each successful Vercel `Production` deployment. [`vercel.json`](../vercel.json) owns: @@ -90,8 +93,21 @@ Production then follows [Supabase changes](#supabase-changes) with the same list Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. +A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so each PR gets a staging preview; see [Per-PR previews](#per-pr-previews). + The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly. +## Per-PR previews + +Each pull request gets two Vercel previews, one per Supabase project, and both rebuild on every push: + +- `debate-navigator` (the production Vercel project) builds previews with no `VITE_SUPABASE_ENV`, so they use production unless the saved toggle says staging. Production's Auth redirect allowlist must include `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` for Google sign-in to return to the preview. +- `debatelab-staging` builds the same repository with `VITE_SUPABASE_ENV=staging`. Staging's Auth redirect allowlist must include `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**`. Its production deployment tracks `main`. + +Edge Function CORS and the demo-code sign-in redirect accept these hosts through `isAllowedOrigin()` in [`supabase/functions/_shared/cors.ts`](../supabase/functions/_shared/cors.ts). Previews can call functions only after both projects run a function build that includes it. + +The staging project's name must not start with `debate-navigator-`, or production's allowlist pattern would also match it. A production preview runs unmerged frontend code with real production sessions and data, so use the staging preview unless the change needs production data. A staging preview runs against whatever backend staging has; if the PR changes migrations or Edge Functions, sync staging first (see [Staging sync](#staging-sync)). + ## Backend drift check `npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. diff --git a/docs/testing.md b/docs/testing.md index d7ffcaa5b..cec30e857 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,10 +35,9 @@ Lint checks repository ESLint rules. Type checking uses `tsconfig.app.json` and ```bash npm test -npm test -- --coverage ``` -Vitest runs tests selected by [`vitest.config.ts`](../vitest.config.ts). The coverage gate is deliberately selected-scope coverage for audited runtime modules, not a claim that every maintained file is measured. +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. Run one test file while iterating: @@ -109,6 +108,8 @@ Run this after `npm run build`. It serves `dist/` through Vite preview and prove CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e ``` +Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test:e2e:smoke`, about 30 tests). Tag a test `@smoke` only when it covers a whole user path that would otherwise go unchecked until the nightly run. + Playwright starts a new Vite server on `127.0.0.1:18080` and runs: - desktop Chromium at 1280 by 800 @@ -137,12 +138,12 @@ The gate runs, in order: 1. documentation checks 2. lint 3. type checking -4. selected-scope coverage +4. unit tests 5. collaboration service tests 6. native Edge Function tests 7. production build 8. production service-worker offline smoke -9. desktop and mobile Chromium +9. the desktop Chromium `@smoke` set (`npm run verify:full` adds the full desktop and mobile suite) It does not start local Supabase, run pgTAP, run WebKit, or use protected staging. @@ -160,7 +161,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock 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. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with: +The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock 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 same gate on pull requests that touch its paths, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with: ```bash supabase db start @@ -208,7 +209,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 on pull requests, nightly at 03:17 UTC, and on demand, 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` runs this nightly at 03:17 UTC, on demand, and on pull requests that change `supabase/functions/`, `src/lib/ai/`, `e2e/live/`, the fake provider, or the gate, 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 @@ -294,13 +295,20 @@ The GitHub staging workflow runs only after an eligible main-branch quality resu A skipped staging workflow is not a pass. Missing credentials or provider access is a blocker, not permission to use production or mocks. +## Checking a change by hand + +Tests are not the proof that a change works; using it is. Two local recipes that need no shared Supabase stack: + +- Built frontend: `npx vite build --outDir /tmp/` (put build variables such as `VITE_SUPABASE_ENV=staging` in front), then `npx vite preview --outDir /tmp/ --host 127.0.0.1 --port --strictPort`, and drive it with Playwright. Abort `supabase.co` requests with `context.route()` and record their hosts to see which project the app calls without touching it; the Google button on `/login` produces a safe `/auth/v1/authorize` request. `vite preview` returns HTML for `/_vercel/insights` and `/_vercel/speed-insights`, so their `Unexpected token '<'` page errors are expected locally. +- One Edge Function over HTTP: functions that end with `if (import.meta.main) Deno.serve(handleRequest)`, such as `demo-code-login`, run alone with `DENO_SERVE_ADDRESS=tcp:127.0.0.1: SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY=test deno run --allow-net --allow-env --allow-read supabase/functions//index.ts`. Point `SUPABASE_URL` at a small fake that answers the paths in the function's `index_test.ts`, and check CORS with `curl -X OPTIONS -H "Origin: " -D -`. + ## CI evidence -[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, runs documentation, lint, type, coverage, collaboration, Edge Function, and build checks. Pull requests also run database and Chromium jobs. +[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, runs documentation, lint, type, unit, collaboration, Edge Function, and build checks. On pull requests a parallel `browser-smoke` job runs the `@smoke` set on desktop Chromium. [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml) runs the full desktop and mobile suite nightly and on demand. [`.github/workflows/database.yml`](../.github/workflows/database.yml) runs the database gate when backend-facing paths change. [`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging conditions. -[`.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. Branch protection on `main` requires `quality`, `database-tests`, `browser-tests`, and `live-e2e`. It also requires a pull request, and administrators are not exempt. +[`.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 skip therefore don't block merges. Read the PR's checks before merging. Before calling CI green: diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 3e9089dfb..812cb7ccc 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -37,7 +37,7 @@ function countAnnouncementListRequests(page: Page) { return () => count; } -test('user sees and permanently dismisses an active announcement', async ({ page }) => { +test('user sees and permanently dismisses an active announcement', { tag: '@smoke' }, async ({ page }) => { let dismissBody: Record | null = null; await page.route('**/functions/v1/announcements*', async (route) => { const request = route.request(); diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 4ef7d642e..d4d7f3741 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -19,7 +19,8 @@ const AUTH_ROUTES = [ ] as const; for (const locale of LOCALES) { - test.describe(`authed pages [${locale}]`, () => { + // English runs in the pull-request smoke set; the other locales run nightly. + test.describe(`authed pages [${locale}]`, locale === 'en' ? { tag: '@smoke' } : {}, () => { test.beforeEach(async ({ page }) => { await setLocale(page, locale); await stubSupabaseSession(page); diff --git a/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts index 4826c6cda..072c4adb5 100644 --- a/e2e/crossfire.spec.ts +++ b/e2e/crossfire.spec.ts @@ -127,7 +127,7 @@ async function prepare(page: Page, requests: CrossfireRequest[], failFirstAnswer await page.getByTestId('crossfire-argument').fill('Private cars create congestion and unsafe streets.'); } -test('Question the AI completes a grounded Crossfire debrief', async ({ page }, testInfo) => { +test('Question the AI completes a grounded Crossfire debrief', { tag: '@smoke' }, async ({ page }, testInfo) => { const requests: CrossfireRequest[] = []; await prepare(page, requests); await page.getByTestId('mode-question-ai').click(); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 6818a2a97..1292bce80 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -117,7 +117,7 @@ async function freshStart(page: Page, locale: 'en' | 'hu', tourDone: boolean) { for (const locale of ['en', 'hu'] as const) { const text = copy[locale]; - test(`[${locale}] the first tour step starts the first debate, then shows Library and Generate`, async ({ page }) => { + test(`[${locale}] the first tour step starts the first debate, then shows Library and Generate`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => { const backend = await freshStart(page, locale, false); await page.goto('/app', { waitUntil: 'domcontentloaded' }); @@ -161,7 +161,7 @@ for (const locale of ['en', 'hu'] as const) { await expect(tooltip).toHaveCount(0); }); - test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, async ({ page }) => { + test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => { const backend = await freshStart(page, locale, true); await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 19c804b73..659677ba1 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { setLocale } from './helpers/auth'; -test.describe('login passwordless flows', () => { +test.describe('login passwordless flows', { tag: '@smoke' }, () => { test.beforeEach(async ({ page }) => { await setLocale(page, 'en'); }); diff --git a/e2e/public-pages.spec.ts b/e2e/public-pages.spec.ts index de28d6681..44178595f 100644 --- a/e2e/public-pages.spec.ts +++ b/e2e/public-pages.spec.ts @@ -10,7 +10,7 @@ const PUBLIC_ROUTES = [ ] as const; for (const locale of LOCALES) { - test.describe(`public pages [${locale}]`, () => { + test.describe(`public pages [${locale}]`, locale === 'en' ? { tag: '@smoke' } : {}, () => { test.beforeEach(async ({ page }) => { await setLocale(page, locale); }); diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 97f0fe3cd..f3db31788 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -41,7 +41,7 @@ test.beforeEach(async ({ page }) => { await stubSupabaseSession(page); }); -test('completes an LD sparring round through mocked opponent turns and judging', async ({ page }) => { +test('completes an LD sparring round through mocked opponent turns and judging', { tag: '@smoke' }, async ({ page }) => { let responseNumber = 0; const actions: string[] = []; diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts index 997d7d47e..ad252945b 100644 --- a/e2e/startup-shell.spec.ts +++ b/e2e/startup-shell.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; -test('shows a dark private startup shell while the entry module is delayed', async ({ page }) => { +test('shows a dark private startup shell while the entry module is delayed', { tag: '@smoke' }, async ({ page }) => { await setLocale(page, 'en'); await page.addInitScript(() => { const settings = JSON.parse(window.localStorage.getItem('debate-trainer-settings') || '{}'); @@ -24,7 +24,7 @@ test('shows a dark private startup shell while the entry module is delayed', asy }))).toEqual({ appShell: true, dark: true }); }); -test('shows the startup shell on login while the entry module is delayed', async ({ page }) => { +test('shows the startup shell on login while the entry module is delayed', { tag: '@smoke' }, async ({ page }) => { await setLocale(page, 'en'); await page.route('**/src/bootstrap.ts*', async (route) => { await new Promise((resolve) => setTimeout(resolve, 1500)); @@ -37,7 +37,7 @@ test('shows the startup shell on login while the entry module is delayed', async await expect.poll(() => page.evaluate(() => document.documentElement.classList.contains('app-shell'))).toBe(true); }); -test('mounts the app before Jam recorder modules resolve', async ({ page }) => { +test('mounts the app before Jam recorder modules resolve', { tag: '@smoke' }, async ({ page }) => { await setLocale(page, 'en'); await stubSupabaseSession(page); await page.addInitScript(() => { @@ -59,7 +59,7 @@ test('mounts the app before Jam recorder modules resolve', async ({ page }) => { })).toBe(true); }); -test('shows a recoverable message when the static main entry fails', async ({ page }) => { +test('shows a recoverable message when the static main entry fails', { tag: '@smoke' }, async ({ page }) => { await page.route('**/src/main.tsx*', (route) => route.fulfill({ status: 503, body: 'entry unavailable' })); await page.goto('/app', { waitUntil: 'domcontentloaded' }); @@ -68,7 +68,7 @@ test('shows a recoverable message when the static main entry fails', async ({ pa await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled(); }); -test('keeps the branded shell visible before a delayed public entry module', async ({ page }) => { +test('keeps the branded shell visible before a delayed public entry module', { tag: '@smoke' }, async ({ page }) => { await setLocale(page, 'en'); await page.route('**/src/bootstrap.ts*', async (route) => { await new Promise((resolve) => setTimeout(resolve, 1500)); diff --git a/package.json b/package.json index 4fb982123..ba15b6c2d 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,12 @@ "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", + "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", "test:e2e:webkit": "playwright test --project=webkit", "test:sw": "node scripts/test-service-worker-offline.mjs", "test:e2e:staging": "playwright test --project=staging", - "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" + "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:smoke", + "verify:full": "npm run verify && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" }, "dependencies": { "@capacitor/app": "^8.1.0", diff --git a/plans/2026-10-01-ci-and-preview-verification.md b/plans/2026-10-01-ci-and-preview-verification.md new file mode 100644 index 000000000..9f4e3ceda --- /dev/null +++ b/plans/2026-10-01-ci-and-preview-verification.md @@ -0,0 +1,70 @@ +# Leaner CI and two previews per PR + +**Status:** In progress 2026-10-01. Part 1's code is done in this branch; its Vercel and Supabase settings need Adam's go-ahead. Parts 2 and 3 are proposed. Numbers come from GitHub Actions runs 36903797758 and 36903797787 (PR #83) and a read-only query of both Supabase projects' Auth settings. + +## Goal + +Every PR push should give a fast yes or no in a few minutes and two links Adam or an agent can click to try the change signed in. Slow and paid checks run only when the PR touches what they test. + +## Part 1. Two previews per PR + +Each PR gets a production preview and a staging preview, and both rebuild on every push. Vercel builds take about 40 seconds, and no Actions minutes are used. + +Why sign-in on a preview landed on the live site: the app asks Supabase to return to `/app`. Supabase only accepts return addresses on the project's redirect allowlist, and falls back to the Site URL otherwise. Production's allowlist has no `vercel.app` host and its Site URL is `https://debate.pi-stack.org`. + +Done in this branch: + +- `VITE_SUPABASE_ENV` (`production` or `staging`) pins a build to one project. A pinned build ignores the saved toggle and hides the switch controls. Documented in `docs/operations.md` under "Per-PR previews". + +- Edge Function CORS and the demo-code sign-in redirect now accept both projects' preview hosts (`isAllowedOrigin()` in `supabase/functions/_shared/cors.ts`). Before this, previews could not call any function, so demo-code sign-in and AI failed there too. + +Needs Adam's go-ahead (outside the repo): + +0. Merge, then redeploy the Edge Functions on production and staging so the CORS change is live. + +1. Create a second Vercel project, `debatelab-staging`, on the same repository with `VITE_SUPABASE_ENV=staging` for Production and Preview. Its name must not start with `debate-navigator-`, or production's allowlist pattern below would match it too. +2. Add `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` to production's redirect allowlist. +3. Add `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**` and `https://debatelab-staging.vercel.app/**` to staging's allowlist, and change staging's Site URL from `http://localhost:8080` to `https://debatelab-staging.vercel.app`. + +Open items: + +- Google sign-in on staging works only if the Google Cloud OAuth client lists `https://brifeyypjauyusmkptkx.supabase.co/auth/v1/callback`. Unchecked. Demo-code and email-code sign-in don't need it. +- Adam's staging account is not an admin until someone sets the role on staging. +- A production preview runs unmerged frontend code with real production sessions. That is the accepted trade for seeing real data. +- A staging preview of a PR that changes migrations or Edge Functions needs staging synced first. + +## Part 2. Fewer, faster CI jobs + +Today one PR push runs four jobs, about 62 runner-minutes and 26 minutes of wall time: + +- `quality`, 5 minutes: lint, typecheck, Vitest with coverage (3 minutes), edge tests, build. +- `browser-tests`, 21 minutes, after `quality`: up to 5 minutes installing Chromium, then 16 minutes of Playwright against the Vite dev server, which compiles each lazy route on first visit. +- `database-tests`, 19.5 minutes: 2 minutes starting Supabase, 26 seconds of pgTAP, 14.6 minutes of full-stack Playwright on one worker. +- `Live provider E2E`, 17 minutes: a second Supabase stack, using the real OpenRouter and Exa keys on every push. + +Changes: + +1. **Live provider E2E** runs nightly, on demand, and on PRs that touch `supabase/functions/**`, `src/lib/ai/**`, `e2e/live/**` or the fake provider, as `model-matrix.yml` already does. +2. **Browser tests** run in the same job as lint and build, with the Playwright browser cache. One job, no second `npm ci` or runner setup. Done in this branch. Serving the built app instead of the dev server was tried and dropped: locally it took 9.3 minutes against 9.6, and two specs instrument dev-server source and cannot run on a build. +3. **Database gate** runs the full-stack specs on 2 or 3 workers. Each test already uses its own demo-code user; specs that change shared rows (`admin.spec.ts` announcements, billing, daily quota) stay serial. Then it runs only on PRs that touch `supabase/**`, `e2e/full-stack/**`, `src/lib/stores/**`, `src/lib/supabase/**` or `src/lib/ai/**`, and on every push to `main`. +4. **Docs-only PRs** run `npm run docs:check` and nothing else. `main` has no required status checks, so skipped jobs don't block merges. + +Items 1, 2 and 4 are done in this branch. Item 3's path filter is done; the worker change is not, because it needs the database gate run to prove the shared-row specs stay serial. + +The browser suite itself is the remaining cost. Locally it is 1,059 seconds of test time over 2 workers, and every spec runs on both desktop and mobile Chromium. `authed-pages.spec.ts` (227 seconds) renders every page in several locales to check for runtime errors, and `navigation-performance`, `quality-backlog` and `animation-polish` add another 350 seconds. Running mobile Chromium only on specs tagged `@mobile` would roughly halve the suite. That cuts coverage, so it needs Adam's yes. + +Estimate for a frontend-only PR push after items 1 to 4: one job of about 15 minutes on CI, down from 26 minutes of wall time and about 62 runner-minutes. With the mobile cut, about 9 minutes. Estimates; measure on the first PR. + +## Part 3. Three-minute PR check + +Done in this branch, at Adam's request (2026-10-02): + +- Unit tests cut from 112 files (1,140 tests) to 15 files (150 tests, 4 seconds). The kept files guard security boundaries, Current Debate isolation, project selection, stream parsing, source import, the Crossfire and Sparring rules, and the db-gate cleanup. The coverage gate is gone. +- Pull requests run 31 Playwright tests tagged `@smoke` on desktop Chromium (36 seconds locally) in a `browser-smoke` job beside `quality`. The full desktop and mobile suite (about 10 minutes locally, 16 on CI) runs nightly in `browser-nightly.yml`. +- `npm run verify` uses the smoke set and takes about 2 minutes locally; `npm run verify:full` adds the full suite. + +Still slow: PRs that touch the backend also run the Database workflow (about 20 minutes), and PRs that touch AI code run Live provider E2E (about 17 minutes). Those run beside the 3-minute check rather than inside it. + +## Separate bug + +`Protected staging E2E` failed on `main` twice on 2026-10-01 (runs 36904139300 and 36907695367). `e2e/staging/authed-flows.spec.ts` times out on a click after 180 seconds on every retry. Not investigated. diff --git a/plans/README.md b/plans/README.md index 974dd5f27..cb99f0b8a 100644 --- a/plans/README.md +++ b/plans/README.md @@ -10,6 +10,7 @@ Every plan lives in this folder. Before executing one, recheck its paths, assump ## Backlog +- **Leaner CI and two previews per PR.** A PR push costs about 62 runner-minutes and real provider spend, and preview sign-in lands on the live site. The pinned-build code is done; the second Vercel project and the Supabase allowlist changes need Adam's go-ahead. Plan: [2026-10-01 leaner CI and previews](2026-10-01-ci-and-preview-verification.md). - **Admin staging switcher.** `src/lib/supabase/env.ts` already resolves staging from the `debate-supabase-env` localStorage key, but no admin UI sets it. Staging migration and function sync remain operations that need Adam's authorization. - **Document portability PR.** [Programcode/DebateLab#63](https://github.com/Programcode/DebateLab/pull/63) adds Workspace import and export. Workspace is on hold and the PR has open P1 review findings, so decide whether to close or park it. diff --git a/src/components/StagingBanner.tsx b/src/components/StagingBanner.tsx index 30f6ce440..3a916e39c 100644 --- a/src/components/StagingBanner.tsx +++ b/src/components/StagingBanner.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { FlaskConical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useAuth } from '@/lib/supabase/AuthProvider'; -import { getActiveEnv } from '@/lib/supabase/env'; +import { getActiveEnv, isEnvPinned } from '@/lib/supabase/env'; import { reloadPage, switchEnv, useReloadOnEnvChange } from '@/lib/supabase/switch-env'; const BANNER_HEIGHT = '2.25rem'; // h-9 @@ -37,7 +37,7 @@ export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) >