From 5f0f67a800e8809319b9fcd96af1dc922a2951ed Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:31:31 +0000 Subject: [PATCH 1/3] test: add isolated browser verification loop --- .audit/agent-verification-loop.tsv | 16 + .github/workflows/deploy.yml | 21 + .github/workflows/staging-e2e.yml | 82 ++++ .gitignore | 1 + AGENTS.md | 61 +++ README.md | 66 ++-- e2e/sparring.spec.ts | 3 + e2e/staging/auth.setup.ts | 136 +++++++ e2e/staging/authed-flows.spec.ts | 78 ++++ index.html | 3 +- package.json | 6 +- plans/agent-verification-loop.md | 405 ++++++++++++-------- playwright.config.ts | 47 ++- src/jam-loader.ts | 34 ++ src/test/jam-loader.test.ts | 26 ++ src/test/vercel-deployment-config.test.ts | 7 +- supabase/functions/ai-stream/index.ts | 2 + supabase/functions/ai/index.ts | 2 + supabase/functions/demo-code-login/index.ts | 2 + 19 files changed, 781 insertions(+), 217 deletions(-) create mode 100644 .audit/agent-verification-loop.tsv create mode 100644 .github/workflows/staging-e2e.yml create mode 100644 AGENTS.md create mode 100644 e2e/staging/auth.setup.ts create mode 100644 e2e/staging/authed-flows.spec.ts create mode 100644 src/jam-loader.ts create mode 100644 src/test/jam-loader.test.ts diff --git a/.audit/agent-verification-loop.tsv b/.audit/agent-verification-loop.tsv new file mode 100644 index 000000000..3e7db7440 --- /dev/null +++ b/.audit/agent-verification-loop.tsv @@ -0,0 +1,16 @@ +ts phase decision why evidence result +2026-08-31T15:27:00Z isolation created t3code/agent-verification-loop in its own T3 worktree kept the dirty main checkout and other worktrees untouched git worktree list clean isolated branch +2026-08-31T15:35:00Z browser made port 18080 loopback-only and disabled server reuse prevented another checkout on port 8080 from producing false browser results playwright.config.ts isolated server configured +2026-08-31T15:35:30Z staging kept real auth tokens out of traces and uploaded artifacts Playwright traces and storage state can contain bearer and refresh tokens playwright.config.ts, .github/workflows/deploy.yml secret-bearing artifacts excluded +2026-08-31T15:36:00Z staging used a unique user-owned source and exact UI deletion proved persistence while limiting cleanup to the row created by the test e2e/staging/authed-flows.spec.ts bounded cleanup implemented +2026-08-31T15:38:00Z local gates ran lint, typecheck, focused sparring tests, and Playwright discovery caught compile and selector problems before the full browser matrix local command output 14 focused tests and 168 browser cases listed +2026-08-31T15:39:00Z staging proved partial and missing staging configuration fails closed never substitute production or a skipped fake session for missing protected values playwright.config.ts both negative preflights exited 1 +2026-08-31T15:44:00Z browser made the score mock briefly asynchronous after two flaky results the zero-latency mock removed the loading state before its existing assertion could observe it e2e/sparring.spec.ts preserved the loading assertion for deterministic rerun +2026-08-31T16:05:00Z webkit loaded Jam recorder scripts only when MediaRecorder exists WebKit reproduced three Jam page errors on the smallest authenticated route src/jam-loader.ts, src/test/jam-loader.test.ts minimal repro passed and 56 WebKit cases passed +2026-08-31T16:15:00Z local gates ran the full Node 22 verification command on the Jam-fixed tree needed fresh proof for the final source state npm run verify 85 files, 874 tests, coverage, build, and 112 Chromium cases passed +2026-08-31T16:22:00Z staging audited staging before any remote mutation confirmed target and identified exact synchronization and credential blockers Supabase function, secret-name, migration, and CORS reads 2 migrations and 3 function deploys stale; protected test inputs absent +2026-08-31T16:30:00Z review fixed all four Luna xhigh pre-commit findings kept auth traces out of artifacts, isolated staging concurrency, tightened AI proof, and guaranteed cleanup after insert playwright.config.ts, .github/workflows/staging-e2e.yml, e2e/staging/authed-flows.spec.ts focused lint, typecheck, tests, YAML parse, and staging discovery passed +2026-08-31T16:31:00Z ci recorded exhausted GitHub Actions minutes as an external exception Adam confirmed hosted CI cannot run and required local CI-equivalent proof user instruction local gates remain authoritative; GitHub runs must not be called green +2026-08-31T16:35:00Z integration rebased onto analytics and telemetry main changes kept the PR current before push and invalidated older verification origin/main 7bca126 clean rebase; full gates rerun +2026-08-31T16:40:00Z local gates ran fresh CI-equivalent checks after rebase proved the exact integrated source before PR creation npm ci, npm run verify 89 files, 930 tests, coverage, build, and 112 Chromium cases passed +2026-08-31T16:42:00Z webkit reran the full Safari-engine matrix after rebase main changed routing and AI code since the first WebKit proof Playwright 1.60 Noble container with Node 22 56 WebKit cases passed diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d13f94abf..0edcb15ea 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Lint run: npm run lint @@ -44,6 +47,24 @@ jobs: - name: Build run: npm run build + - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: chromium-browser-failure-${{ github.run_id }} + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 7 + - name: Set up QEMU if: env.DOCKER_PUSH == 'true' uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml new file mode 100644 index 000000000..936a8aa98 --- /dev/null +++ b/.github/workflows/staging-e2e.yml @@ -0,0 +1,82 @@ +name: Protected staging E2E + +on: + workflow_run: + workflows: ["Build and Push Docker Image"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: protected-staging-e2e-main + cancel-in-progress: false + +jobs: + staging-e2e: + if: >- + ( + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + ) && vars.STAGING_E2E_ENABLED == 'true' + runs-on: ubuntu-latest + environment: staging + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Verify protected variables are present + shell: bash + env: + STAGING_SUPABASE_URL: ${{ secrets.STAGING_SUPABASE_URL }} + STAGING_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }} + STAGING_DEMO_CODE: ${{ secrets.STAGING_DEMO_CODE }} + run: | + missing=0 + for name in STAGING_SUPABASE_URL STAGING_SUPABASE_ANON_KEY STAGING_DEMO_CODE; do + if [ -z "${!name}" ]; then + echo "::error::$name is not configured in the staging environment" + missing=1 + fi + done + exit "$missing" + + - name: Protected staging browser tests + env: + CI: 1 + PORT: 18080 + PLAYWRIGHT_BASE_URL: http://127.0.0.1:18080 + STAGING_SUPABASE_URL: ${{ secrets.STAGING_SUPABASE_URL }} + STAGING_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }} + STAGING_DEMO_CODE: ${{ secrets.STAGING_DEMO_CODE }} + run: npm run test:e2e:staging + + - name: Upload staging failure artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: staging-browser-failure-${{ github.run_id }} + path: | + test-results/**/*.png + test-results/**/*.webm + !test-results/.auth/ + !test-results/**/*.zip + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index 049ad9846..f3cf0c306 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ humanizer.md *.txt *.info !README.md +!AGENTS.md !**/README.md !docs/pricing-snapshots/*.md !plans/**/*.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c4e5a83ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# DebateLab agent guide + +## Runtime and install + +- Use Node 22 and npm. The lockfile is `package-lock.json`. +- Start a clean checkout with `npm ci`. +- The development server normally uses port 8080. Automated browser verification owns `127.0.0.1:18080` and never reuses an existing server. +- Local Supabase development uses port 54321. Do not point staging checks at the production project. + +## Verification ladder + +Run the smallest relevant reproduction first, then the complete gate before a PR: + +```bash +npm run lint +npm run typecheck +npm test -- --coverage +npm run build +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +``` + +`npm run verify` runs that full credential-free ladder. It starts an isolated Vite server and executes the desktop and mobile Chromium projects. Browser failures stay in ignored `test-results/` and `playwright-report/` directories. CI uploads those directories for seven days when the browser step fails. + +WebKit is an additional release smoke gate: + +```bash +npx playwright install --with-deps webkit +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit +``` + +## Protected staging verification + +The staging suite uses a real staging user and backend. It never uses a service-role key. Supply all three protected values together: + +- `STAGING_SUPABASE_URL` +- `STAGING_SUPABASE_ANON_KEY` +- `STAGING_DEMO_CODE` + +Then run: + +```bash +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 \ +STAGING_SUPABASE_URL= \ +STAGING_SUPABASE_ANON_KEY= \ +STAGING_DEMO_CODE= \ +npm run test:e2e:staging +``` + +The suite rejects the production project, checks that the anon key belongs to the supplied staging URL, authenticates through `demo-code-login`, proves session reload, writes and reads back one unique source, makes one bounded AI request, and deletes only that source. It saves temporary auth state under `test-results/.auth/`. Never upload that directory or a staging trace because both can contain session tokens. + +GitHub runs staging only for `main` push or a main-branch manual dispatch, through the protected `staging` environment, after the credential-free build job passes. Set the repository variable `STAGING_E2E_ENABLED=true` only after the protected environment has the three secrets, current staging functions, schema, provider secret, dedicated unlimited-use test code, and a main-only deployment rule. A skipped staging job is not a pass. + +The staging origin `http://127.0.0.1:18080` must be deployed in the CORS allowlists for `demo-code-login`, `ai`, and `ai-stream` before enabling the gate. Do not deploy those functions or apply migrations to an unverified project. + +## Working rules + +- Start bug fixes with a failing test or a repeatable browser reproduction when the repository has a suitable test layer. +- Keep stub sessions limited to render checks. Do not describe them as authenticated backend proof. +- Never put staging or production secrets in PR jobs, logs, traces, screenshots, commits, or comments. +- A local pass proves only the local commit. Inspect the current-head GitHub run before calling CI green. Account billing or spending-limit failures are external exceptions, not passing checks. +- State exactly which browser and staging checks ran. If credentials, provider access, or staging synchronization are missing, stop that gate and report it instead of substituting production or mocks. diff --git a/README.md b/README.md index 38de8da79..59d3442e1 100644 --- a/README.md +++ b/README.md @@ -1346,57 +1346,47 @@ jobs: ## Testing -### Test Setup +DebateLab uses Node 22, npm, Vitest, and Playwright. Install the locked dependency tree with `npm ci`. -```typescript -// vitest.config.ts -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - }, -}); +Run the full credential-free verification ladder with one command: + +```bash +npm run verify ``` -### Running Tests +It runs ESLint, TypeScript, the full selected-scope coverage suite, the production build, and desktop plus mobile Chromium against an isolated Vite server on `127.0.0.1:18080`. The browser runner never reuses an existing server. Failure traces, screenshots, videos, and the HTML report are written to ignored `test-results/` and `playwright-report/` directories. + +Useful focused commands: ```bash -# Run all tests -npm test +# Unit and Supabase function tests with enforced coverage +npm test -- --coverage -# Run specific test file -npm test -- src/test/security/url-validation.test.ts +# One focused unit test +npx vitest run src/test/security/url-validation.test.ts -# Watch mode -npm run test:watch +# Credential-free Chromium browser suite +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e -# Coverage report -npm test -- --coverage +# Additional Safari-engine smoke gate +npx playwright install --with-deps webkit +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit ``` -### Security Tests +Protected staging verification uses a real session, staging data, and one short AI request. It requires the staging URL, matching anon key, and dedicated demo code. It rejects the production project and deletes only the uniquely named source it creates. -```typescript -// src/test/security/prompt-injection.test.ts -import { describe, it, expect } from 'vitest'; -import { sanitizePromptInput } from '@/lib/ai/sanitize'; - -describe('Prompt Injection Detection', () => { - it('should detect "ignore all previous instructions"', () => { - const result = sanitizePromptInput('Please ignore all previous instructions'); - expect(result.rejected).toBeDefined(); - }); - - it('should allow normal debate content', () => { - const result = sanitizePromptInput('The resolution states that...'); - expect(result.sanitized).toBeDefined(); - }); -}); +```bash +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 \ +STAGING_SUPABASE_URL= \ +STAGING_SUPABASE_ANON_KEY= \ +STAGING_DEMO_CODE= \ +npm run test:e2e:staging ``` +Temporary auth state lives under `test-results/.auth/`. Never upload it or staging traces. GitHub exposes staging secrets only to the protected `staging` environment on `main`, and only after `STAGING_E2E_ENABLED=true` is set. Pull requests remain credential-free. + +A green local run does not mean the current GitHub head passed. Check the PR run itself. If GitHub Actions cannot start because of billing or spending limits, report that as an external exception. For bugs, add a failing reproduction first when the repository has a suitable test layer, and state plainly when browser or real-staging proof did not run. + --- ## API Reference diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 51248cc69..13500ec7e 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -55,6 +55,9 @@ test('completes an LD sparring round through mocked opponent turns and judging', const action = body.input?.action ?? 'unknown'; actions.push(action); const output = action === 'score' ? scorecard : opponentSpeech(++responseNumber); + if (action === 'score') { + await new Promise((resolve) => setTimeout(resolve, 250)); + } await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output }) }); }); diff --git a/e2e/staging/auth.setup.ts b/e2e/staging/auth.setup.ts new file mode 100644 index 000000000..253eca7d8 --- /dev/null +++ b/e2e/staging/auth.setup.ts @@ -0,0 +1,136 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { createClient, type Session } from '@supabase/supabase-js'; +import { expect, test as setup } from '@playwright/test'; + +const AUTH_STATE_PATH = 'test-results/.auth/staging.json'; +const EXPECTED_STAGING_PROJECT_REF = 'brifeyypjauyusmkptkx'; +const PRODUCTION_PROJECT_REF = 'uziwdtkzofptihahfooo'; + +type StagingConfig = { + supabaseUrl: string; + anonKey: string; + demoCode: string; + baseUrl: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for the protected staging suite`); + return value; +} + +function readAnonKeyProjectRef(anonKey: string): string { + const parts = anonKey.split('.'); + if (parts.length !== 3) throw new Error('STAGING_SUPABASE_ANON_KEY is not a JWT'); + + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + } catch { + throw new Error('STAGING_SUPABASE_ANON_KEY has an invalid JWT payload'); + } + + if (!isRecord(payload) || payload.role !== 'anon' || typeof payload.ref !== 'string') { + throw new Error('STAGING_SUPABASE_ANON_KEY must be an anon key with a project ref'); + } + return payload.ref; +} + +function loadStagingConfig(): StagingConfig { + const supabaseUrl = requireEnv('STAGING_SUPABASE_URL'); + const anonKey = requireEnv('STAGING_SUPABASE_ANON_KEY'); + const demoCode = requireEnv('STAGING_DEMO_CODE'); + const baseUrl = requireEnv('PLAYWRIGHT_BASE_URL'); + + const parsedSupabaseUrl = new URL(supabaseUrl); + const projectRef = parsedSupabaseUrl.hostname.split('.')[0]; + if (parsedSupabaseUrl.protocol !== 'https:' || !parsedSupabaseUrl.hostname.endsWith('.supabase.co')) { + throw new Error('STAGING_SUPABASE_URL must be an HTTPS Supabase project URL'); + } + if (projectRef === PRODUCTION_PROJECT_REF) { + throw new Error('Refusing to run the staging suite against the production project'); + } + if (projectRef !== EXPECTED_STAGING_PROJECT_REF) { + throw new Error('STAGING_SUPABASE_URL does not identify DebateLab staging'); + } + if (readAnonKeyProjectRef(anonKey) !== projectRef) { + throw new Error('The staging anon key does not belong to STAGING_SUPABASE_URL'); + } + if (!/^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(demoCode)) { + throw new Error('STAGING_DEMO_CODE has an invalid format'); + } + + const parsedBaseUrl = new URL(baseUrl); + const isLoopback = parsedBaseUrl.hostname === '127.0.0.1' || parsedBaseUrl.hostname === 'localhost'; + if (parsedBaseUrl.protocol !== 'https:' && !(parsedBaseUrl.protocol === 'http:' && isLoopback)) { + throw new Error('PLAYWRIGHT_BASE_URL must use HTTPS or an HTTP loopback address'); + } + + return { supabaseUrl, anonKey, demoCode, baseUrl }; +} + +function parseLoginResponse(value: unknown): { email: string; tokenHash: string } { + if (!isRecord(value) || typeof value.email !== 'string' || typeof value.tokenHash !== 'string') { + throw new Error('demo-code-login returned an invalid handoff payload'); + } + return { email: value.email, tokenHash: value.tokenHash }; +} + +async function installSession(page: import('@playwright/test').Page, session: Session, email: string) { + await page.addInitScript(({ authSession, testEmail }) => { + localStorage.setItem('debate-supabase-env', 'staging'); + localStorage.setItem('debate-auth-staging', JSON.stringify(authSession)); + localStorage.setItem('debate-staging-e2e-email', testEmail); + localStorage.setItem('debate-trainer-settings', JSON.stringify({ + state: { language: 'en', outputLanguage: 'en', theme: 'system' }, + version: 0, + })); + }, { authSession: session, testEmail: email }); +} + +setup('authenticate with the protected staging demo code', async ({ page }) => { + const config = loadStagingConfig(); + const response = await fetch(`${config.supabaseUrl}/functions/v1/demo-code-login`, { + method: 'POST', + headers: { + apikey: config.anonKey, + Authorization: `Bearer ${config.anonKey}`, + 'Content-Type': 'application/json', + Origin: config.baseUrl, + }, + body: JSON.stringify({ + code: config.demoCode, + browserFingerprint: `playwright-staging-${Date.now()}`, + redirectTo: `${config.baseUrl}/app`, + }), + }); + if (!response.ok) { + throw new Error(`demo-code-login failed with HTTP ${response.status}`); + } + + const handoff = parseLoginResponse(await response.json()); + const supabase = createClient(config.supabaseUrl, config.anonKey, { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + }); + const { data, error } = await supabase.auth.verifyOtp({ + email: handoff.email, + token_hash: handoff.tokenHash, + type: 'magiclink', + }); + if (error || !data.session) { + throw new Error(`Staging OTP verification failed: ${error?.message ?? 'no session returned'}`); + } + + await installSession(page, data.session, handoff.email); + await page.goto('/app/settings'); + await expect(page).toHaveURL(/\/app\/settings$/); + await expect(page.getByText(handoff.email, { exact: true })).toBeVisible(); + + await mkdir(dirname(AUTH_STATE_PATH), { recursive: true }); + await page.context().storageState({ path: AUTH_STATE_PATH }); +}); diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts new file mode 100644 index 000000000..8a72806bc --- /dev/null +++ b/e2e/staging/authed-flows.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); + +test('real staging session persists, writes and removes a source, and completes one AI request', async ({ page }) => { + test.setTimeout(180_000); + const consoleErrors: string[] = []; + const sourceTitle = `E2E staging source ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const sourceContent = 'A dedicated Playwright staging record used to verify authenticated persistence.'; + let sourceCreated = false; + + page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`)); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(`console: ${message.text()}`); + }); + + try { + await page.goto('/app/settings'); + const testEmail = await page.evaluate(() => localStorage.getItem('debate-staging-e2e-email')); + expect(testEmail).toBeTruthy(); + await expect(page.getByText(testEmail!, { exact: true })).toBeVisible(); + + await page.reload(); + await expect(page).toHaveURL(/\/app\/settings$/); + await expect(page.getByText(testEmail!, { exact: true })).toBeVisible(); + + await page.goto('/app/library'); + await page.getByRole('button', { name: 'Add Source' }).click(); + await page.getByPlaceholder('Source title...').fill(sourceTitle); + await page.getByPlaceholder('Paste source content...').fill(sourceContent); + const sourceInsert = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().includes('/rest/v1/sources') + ); + await page.getByRole('button', { name: 'Save to Library' }).click(); + expect((await sourceInsert).ok()).toBe(true); + sourceCreated = true; + await expect(page.getByText(sourceTitle, { exact: true })).toBeVisible(); + + await page.evaluate(() => localStorage.removeItem('debate-trainer-storage')); + const sourceHydration = page.waitForResponse((response) => + response.request().method() === 'GET' && response.url().includes('/rest/v1/sources?') + ); + await page.reload(); + expect((await sourceHydration).ok()).toBe(true); + await expect(page.getByText(sourceTitle, { exact: true })).toBeVisible({ timeout: 15_000 }); + await page.getByText(sourceTitle, { exact: true }).click(); + await expect(page.locator('#source-detail-content')).toHaveValue(sourceContent); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.goto('/app/ask'); + await page.getByLabel('Pros/Cons').click(); + await page.getByPlaceholder('Ask your debate coach...').fill('Reply with one short sentence confirming this staging request.'); + const aiResponse = page.waitForResponse((response) => + response.request().method() === 'POST' && response.url().includes('/functions/v1/ai') + ); + await page.getByRole('button', { name: 'Send message' }).click(); + expect((await aiResponse).ok()).toBe(true); + const saveResponseButton = page.getByRole('button', { name: 'Save message' }).last(); + await expect(saveResponseButton).toBeAttached({ timeout: 90_000 }); + const assistantBubble = saveResponseButton.locator('..'); + await expect(assistantBubble.locator(':scope > .text-sm')).toHaveText(/\S/, { timeout: 90_000 }); + } finally { + if (sourceCreated) { + await page.goto('/app/library'); + await expect(page.getByText(sourceTitle, { exact: true })).toBeVisible({ timeout: 15_000 }); + const sourceDelete = page.waitForResponse((response) => + response.request().method() === 'DELETE' && response.url().includes('/rest/v1/sources?') + ); + await page.getByRole('button', { name: `Delete ${sourceTitle}` }).click(); + expect((await sourceDelete).ok()).toBe(true); + await page.evaluate(() => localStorage.removeItem('debate-trainer-storage')); + await page.reload(); + await expect(page.getByText(sourceTitle, { exact: true })).toHaveCount(0, { timeout: 15_000 }); + } + } + + expect(consoleErrors, consoleErrors.join('\n')).toEqual([]); +}); diff --git a/index.html b/index.html index 798357471..86767dbed 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,7 @@ - - + DebateLab by Programcode | AI Debate Prep, Cases & Rebuttals diff --git a/package.json b/package.json index 3572f0058..ac2ae2630 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "test:e2e": "playwright test", + "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", + "test:e2e:webkit": "playwright test --project=webkit", + "test:e2e:staging": "playwright test --project=staging", "test:e2e:ui": "playwright test --ui", - "verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build" + "verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build && 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/agent-verification-loop.md b/plans/agent-verification-loop.md index e859b6e8a..f7d1b2979 100644 --- a/plans/agent-verification-loop.md +++ b/plans/agent-verification-loop.md @@ -1,187 +1,256 @@ -# Agent Verification Loop - -Goal: make DebateLab verifiable by agents end to end, so bugs are caught inside the development loop instead of by users on production. - -Basis: Theo's thesis that coding is solved and the verification layer is not. This repo proves the point. Agents here can already write code well. What they cannot do today is prove their work: the e2e suite uses fake JWTs, coverage thresholds never fire, four backend test files never execute, PRs gate on nothing, and there is no way for an agent to hold a real authenticated session. This plan closes those gaps in five phases. - -## Current state, verified 2026-08-26 - -What exists: - -- Commands: `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:coverage`, `npm run test:e2e`, `npm run build`. -- CI (`.github/workflows/deploy.yml`): on push to main only. Runs lint, typecheck, vitest, build, then pushes a multi-arch image to Docker Hub (`adamgaborjaniszabo/debate-trainer:latest`). VPS pickup happens off-repo. -- Vitest: ~63 test files, ~630 cases under `src/test`. Coverage thresholds (90/80/89/90) configured over 8 audited modules, enforced only when `--coverage` is passed manually. -- Playwright: two render-smoke specs, Chromium-only, auto-starts `npm run dev` on :8080. Auth is a stubbed localStorage session with a bogus token, good only for render checks. -- Supabase: 12 edge functions, manual CLI deploys, currently linked to prod project `uziwdtkzofptihahfooo`. A staging project `brifeyypjauyusmkptkx` exists and its anon creds ship in `src/lib/supabase/env.ts`, but migrations, functions, secrets, and CORS were never applied or verified. The env switcher (`setActiveEnv`) has zero callers. - -What is broken or missing, ranked by risk: - -1. `vitest.config.ts:11` includes only `src/**`, so the 28 cases in `supabase/functions/**/*.test.ts` (prompt-builder, source-quality, url-validation, native-cors) never run anywhere. -2. Coverage gates never run in CI; `deploy.yml` invokes bare `npm test`. -3. No `pull_request` trigger, so PRs merge ungated. -4. Edge function handlers (Stripe checkout, portal, webhook, redeem-promo, admin) have zero automated execution. Money paths ride on review alone. -5. No real authenticated path in e2e. `demo-code-login` works server-side but nothing in `src/` calls it, no tooling mints codes, no seed users exist. -6. Staging Supabase is created but almost certainly empty; every function's `ALLOWED_ORIGINS` lacks a staging origin. -7. No WebKit/Firefox projects although `plans/smoke-test-checklist.md` names Safari streaming as the likeliest breakage. -8. TypeScript `strict: false`; typecheck is a weak signal. ESLint has `no-unused-vars` off. -9. Observability dead in the Docker path: Sentry DSN unset at build time and nginx CSP omits the Sentry/PostHog origins that `vercel.json` allows. -10. README drift: wrong repo name, Pi/port-3001 hosting story, obsolete `VITE_SUPABASE_*` guidance. Dual lockfiles: `package-lock.json` canonical, stale `bun.lockb` tracked. - -## Target outcome - -One command an agent runs on any branch: - +# Agent verification loop + +## Goal + +Finish the missing verification path for DebateLab as of 2026-08-31. The +repository already has unit, coverage, lint, typecheck, and build gates. The +remaining work is to make the current browser suite green, run credential-free +browser checks in CI, add a protected real-session staging suite, and document +the exact verification ladder. + +This plan owns verification code, Playwright configuration, CI wiring, and +verification documentation. Supabase schema/function synchronization belongs to +`plans/supabase-staging.md`. + +## Verified current state + +- `package.json:15-23` already defines `lint`, `typecheck`, `test`, + `test:coverage`, `test:e2e`, and `verify`; it has no + `test:e2e:staging` script. `verify` currently stops after the production build + and must be extended after the browser command is reliable. +- `vitest.config.ts:10-44` already includes both `src` and + `supabase/functions` tests and enforces the selected coverage scope. A local + run on this checkout passed 84 files and 872 tests with 90.13% statements, + 82.23% branches, 91.18% functions, and 93.98% lines. +- `.github/workflows/deploy.yml:3-45` already triggers on pull requests and + runs lint, typecheck, covered Vitest, and build. It does not install + Playwright or run `test:e2e`. +- `playwright.config.ts:6-35` has desktop and mobile Chromium projects, + failure screenshots, and a local Vite server. It does not have a staging + setup project, WebKit coverage, or retained video. `e2e/helpers/auth.ts` + injects a deliberately bogus JWT, so the existing authenticated page tests + prove rendering only. `e2e/crossfire.spec.ts` and `e2e/sparring.spec.ts` + intercept backend requests instead of reaching Supabase. +- `npm run test:e2e` discovers 112 tests. A run on the default `:8080` reused + an incompatible/stale local server and produced 92 passes and 20 failures, + including false 404s for `/app/crossfire` and `/app/sparring`. The same + checkout passed all 112 tests on an isolated `:18081` Vite server. The + remaining work is to make that isolation the documented and CI-safe default, + not to delete tests or weaken their assertions. +- There is no `e2e/staging/` suite and no root `AGENTS.md`. The current + `README.md` testing section does not document `npm run verify` or the + protected staging suite. +- GitHub run `33398731147` did not start because the account has a payment or + spending-limit problem. This is an external CI exception, not evidence that + the code passed. +- Git history confirms the old Phase 0 work landed in `b3b1476`, while the + current Crossfire and sparring routes landed in `f1ce741` and `0b94c9e`. + +The Phase 0 work described in older revisions, including the Deno compatibility +shim, coverage CI, pull-request trigger, `verify` script, and removal of +`bun.lockb`, is complete and must not be planned again. + +## Preconditions and isolation + +The implementation agent must create its own feature branch in its own isolated +T3 Code worktree. Before editing, it must run: + +```bash +pwd +git branch --show-current +git worktree list --porcelain +git status --short --branch ``` -npm run verify -``` - -runs lint, typecheck, unit tests with enforced coverage, build, and e2e against a real staging environment with a real session, and produces pass/fail plus failure evidence (trace, screenshot, video). CI enforces the same on every PR. When an agent cannot verify something, the repo tells it why and what to build next, via AGENTS.md. - -## Phase 0: Make the checks that exist actually run - -Small, high-leverage, no new infrastructure. - -### Task 0.1: Include Supabase function tests in vitest - -- Modify: `vitest.config.ts:11` -- Change include to `["src/**/*.{test,spec}.{ts,tsx}", "supabase/functions/**/*.{test,spec}.{ts,tsx}"]`. -- Risk: those tests may use Deno-flavored imports. Run `npx vitest run supabase/functions` first. If a test fails on Deno globals, either shim in `src/test/setup.ts` or move the tested pure logic into `_shared/` modules importable by both. Do not weaken assertions to make them pass. -- Verify: `npx vitest run supabase/functions` exits 0 with 28+ passing cases. - -### Task 0.2: Enforce coverage in CI - -- Modify: `.github/workflows/deploy.yml` -- Change the test step to `npm test -- --coverage`. Thresholds in `vitest.config.ts` then fail the job on regression. Keep the inline TODO comment accurate afterward. -- Verify: push a branch with a deliberate assertion deletion, confirm the workflow goes red, revert. - -### Task 0.3: Gate pull requests - -- Modify: `.github/workflows/deploy.yml` -- Add `pull_request:` trigger (any branch) running lint, typecheck, tests with coverage, and build. Docker push steps stay behind the existing secrets check so PR runs never publish. -- Verify: open a draft PR with a failing test, confirm required checks fail, then fix. - -### Task 0.4: Lockfile hygiene - -- Delete `bun.lockb` (stale, unused by CI/Docker/Vercel, all npm). Optionally gitignore it. One-line decision, removes drift bait. - -### Task 0.5: Umbrella verify script - -- Modify: `package.json` scripts. -- Add `"verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build"`. E2E joins in Phase 3 once it needs no human. - -## Phase 1: Write down how to verify (AGENTS.md) -Agents need the playbook in the repo. Create `AGENTS.md` at root: +The worktree must not be the `main` checkout and must not overlap another plan +worktree. An unexpected dirty file, branch, or worktree is a stop condition. +Keep the browser artifacts under the existing ignored `test-results/` and +`playwright-report/` paths. -- Canonical commands (the ones above), node 22, npm, ports 8080/54321. -- Environment model: creds resolve in `src/lib/supabase/env.ts` from `localStorage['debate-supabase-env']`, prod vs staging refs, what IS_STAGING changes. -- Verification ladder: static checks, unit, coverage, build, e2e smoke, e2e authed flows (once Phase 3 lands), then manual browser only for visual judgment. -- Rule: if a change cannot be verified because tooling is missing, stop and propose the missing piece rather than merging blind. This encodes Theo's "agent notices it can't test, then builds the missing piece". -- Evidence rule: attach trace/screenshot output or state plainly that verification did not happen. Never claim untested work passed. -- Fix README.md drift in the same commit: repo name, Hetzner + port 8080 + cloudflared story, remove `VITE_SUPABASE_URL/ANON_KEY` guidance, correct workflow filename. +## Remaining work -Verify: fresh-agent test, give a subagent only AGENTS.md and ask it to describe how to run and verify the app. It should answer correctly without reading other files. +### 1. Make the current browser suite reproducible -## Phase 2: Finish staging so agents have a safe sandbox +Files to inspect or change: -Staging project `brifeyypjauyusmkptkx` exists. Everything below targets it, never prod. +- `playwright.config.ts` +- the existing `e2e/*.spec.ts` files only if a future isolated run finds a + real test defect -### Task 2.1: Audit staging state +Run the suite on an unoccupied port with CI mode enabled so another local +preview cannot be reused: -- `npx supabase link --project-ref brifeyypjauyusmkptkx`, then `npx supabase migration list` and a probe request to one function URL. -- Record what exists. Expect: empty. - -### Task 2.2: Apply schema and functions - -- `npx supabase db push` (all 19 migrations). -- `npx supabase functions deploy` each of the 12 functions. -- Set non-provider secrets: `SUPABASE_SERVICE_ROLE_KEY` handled by platform; set `DEMO_CODE_PEPPER` to a staging-only random value. -- Provider keys (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `EXA_API_KEY`): Adam supplies values via `supabase secrets set`; never through chat, never in files. Stripe keys deferred until payment flows get e2e coverage. - -### Task 2.3: CORS for staging callers - -- Each function hardcodes an `ALLOWED_ORIGINS` array including `localhost:8080/5173/54321`. Localhost already covers the agent loop, so no change strictly required for Phase 3. If a hosted staging domain is wanted later (e.g. `debate-staging.pi-stack.org` via cloudflared), add it to every array in the same commit. Adam owns the tunnel route. - -### Task 2.4: Deterministic env for tests - -- Modify `src/lib/supabase/env.ts`: read `import.meta.env.VITE_FORCE_SUPABASE_ENV` as initial default for the active env (localStorage still wins). Build-time override, no runtime UI needed. -- Verify: `VITE_FORCE_SUPABASE_ENV=staging npm run dev`, check network tab hits `brifeyypjauyusmkptkx.supabase.co`. - -### Task 2.5: Seed script - -- Create `scripts/seed-staging.ts`: uses service-role key from `STAGING_SERVICE_ROLE_KEY` env var (read at runtime, never committed). Creates one test user, mints a demo access code row with `sha256(pepper:code)` matching the staged `DEMO_CODE_PEPPER`, and writes the plaintext code only to `.env.e2e` (gitignored, mode 600) for local e2e use. The script never prints secrets; automated CI runs consume masked GitHub secret values instead of minting codes. -- Document in AGENTS.md. Rollback: staging is disposable; wipe and re-run. - -## Phase 3: Real authenticated e2e, the actual bug catcher - -This is the phase that catches Peter's-truncated-text-class bugs: things invisible in code review. - -### Task 3.1: Auth setup project - -- Create `e2e/auth.setup.ts`: calls `demo-code-login` on staging with the seeded code, completes `verifyOtp({ type: 'magiclink' })` via the staging anon client, saves valid storageState to `test-results/.auth/staging.json`. -- Modify `playwright.config.ts`: add a `setup` project depending on nothing, authed projects depend on it with `use: { storageState }`. Public specs stay storage-free. -- Env needed in CI/local: `STAGING_URL`, seeded demo code. Values come from the seed step; store locally in `.env.e2e` (gitignored). - -### Task 3.2: First real flow specs - -- Create `e2e/authed-flows.spec.ts` covering, in order of value: - 1. Login persists across reload; user menu shows the test identity. - 2. Ask: submit a prompt, assert streamed tokens arrive (SSE works, error boundary silent). - 3. History: conversation appears after the ask; reload keeps it. - 4. Sources: import `https://example.com`, assert it lands in the library. - 5. Share: share a session, open the public URL logged-out, assert content renders. -- Cost control: pin these specs to the cheapest provider/model via the staging `AI_DEFAULT_MODEL` secret; three short conversations per run is cents. - -### Task 3.3: Browser breadth and failure evidence - -- Add `webkit` project to `playwright.config.ts` (Safari parity, the checklist's known risk). Install deps once: `npx playwright install --with-deps webkit`. -- Config: `video: 'on'`, `trace: 'retain-on-failure'`, screenshot on failure. -- CI: upload `playwright-artifacts` on failure. Add `test:e2e` public smoke (credential-free) to every pull request. Authenticated staging specs run only when `github.ref == 'refs/heads/main'`, further gated by a protected GitHub environment with a selected-branch rule of main only; pull-request jobs and non-main dispatch runs stay credential-free because they execute PR-controlled test code that must never see staging secrets. Add an acceptance case: dispatch from a non-main branch and confirm the authenticated job skips. -- Extend `npm run verify` with e2e once green locally. - -## Phase 4: Prove it, observe it - -- Evidence convention in AGENTS.md: agents attach traces/screenshots via `gh pr comment` uploads or link CI artifacts. Never commit media to the repo. -- Observability: pass `VITE_SENTRY_DSN` as a Docker build arg in `deploy.yml`; align `nginx.conf` CSP `connect-src` with `vercel.json` (add `*.ingest.sentry.io`, `eu.i.posthog.com`). Prod errors become visible to agents fixing incidents instead of invisible to everyone. -- Commit the VPS pickup step: whatever pulls `debate-trainer:latest` on codex-remote (watchtower or systemd timer) gets a compose/unit file in-repo, making the last invisible hop observable and re-runnable. Adam confirms which mechanism runs today before we codify it. -- Optional, decide explicitly: Vercel PR previews (project still linked) would give every PR a live URL humans and agents can poke. Conflicts with Docker-as-canonical unless kept preview-only. - -## Phase 5: The autonomous loop - -Only after Phases 0-3 are green: - -- Nightly full-suite cron on codex-remote (tmux or systemd timer): `npm run verify` + authed e2e against staging, failures reported with artifacts. Fits Adam's watchdog pattern: execute safe diagnosis automatically, alert with evidence. -- Bug intake rule in AGENTS.md: every bug report becomes a failing repro test first, fix second. Mirrors Theo's claim that speced bugs plus minimal verification get solved by frontier agents. -- Later candidates, not committed here: deno check/lint of `supabase/functions` in CI, incremental `strict: true` tsconfig ramp, CodeRabbit re-run against the new baseline. +```bash +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 \ + npx playwright test e2e/crossfire.spec.ts e2e/sparring.spec.ts --project=desktop-chromium +``` -## Who does what +Make local runs use an explicit, unoccupied port and do not silently reuse an +existing server from another checkout. Set `reuseExistingServer` to `false` or +provide an equally strict explicit opt-in for an already-verified server. Keep +the stub session limited to render +tests and keep deterministic request interception limited to the tests that +explicitly need it. The acceptance result is all 112 current tests passing on +the isolated server, with no removed routes, skipped flows, or assertion +downgrades. + +### 2. Put credential-free browser proof in pull-request CI + +Files: + +- `.github/workflows/deploy.yml` +- `playwright.config.ts` +- `package.json` only if `verify` is extended after local browser proof is + green + +Install the Chromium dependency in the CI job, run the existing static/unit +gates, and run `npm run test:e2e` against the job-local Vite server. Upload +`test-results/` and `playwright-report/` on failure with a bounded retention +period. Configure Playwright to retain a trace, screenshot, and video on +failure. Keep PR jobs credential-free. They may execute PR-controlled tests and +must never receive staging or production service-role, provider, OAuth, or +demo-code secrets. + +After the standalone browser command is green locally, extend `npm run verify` +to include it. The one-command contract must be documented in `AGENTS.md` and +must run all of these gates: + +```bash +npm run lint +npm run typecheck +npm test -- --coverage +npm run build +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +``` -Agent-executable: all of Phase 0, AGENTS.md, README fixes, seed script, e2e code, config changes, docs. +Do not claim a GitHub check passed while the account-level billing blocker is +present. If it remains, record the run URL and the exact external exception +while retaining successful local evidence. + +### 3. Add a protected real-session staging suite + +Files: + +- `e2e/staging/auth.setup.ts` +- `e2e/staging/authed-flows.spec.ts` +- `playwright.config.ts` +- `.github/workflows/deploy.yml` +- `package.json` to add the dedicated `test:e2e:staging` script + +The setup must use only the staging URL and anon key. It should POST the +protected test code to `demo-code-login`, pass the returned `tokenHash` and +email to the staging anon client's `verifyOtp({ type: 'magiclink' })`, set the +existing `localStorage['debate-supabase-env']='staging'` before app scripts run, +and save the resulting storage state under `test-results/.auth/`. It must never +use a service-role key in Playwright code, print a token, or fall back to +production. + +Keep staging tests in a separate Playwright project named `staging`, and add +`"test:e2e:staging": "playwright test --project=staging"` to `package.json`. +Ordinary +`npm run test:e2e` runs the public and stubbed Chromium suite only. The staging +project is enabled only when its explicit environment variables are present. +Use names such as `STAGING_SUPABASE_URL`, `STAGING_SUPABASE_ANON_KEY`, +`STAGING_DEMO_CODE`, and `PLAYWRIGHT_BASE_URL`; values stay in a local ignored +file or a protected GitHub environment. Do not assume this script or project +exists before implementing the listed config and script changes. + +The first real flow should be bounded and reversible: + +1. Authenticate against staging and prove `/app` survives a reload with the + same user. +2. Save one uniquely named source and verify it can be read back after a + reload. +3. Make one short real AI request through the current staging function path, + then assert a non-empty response and no browser console error. +4. Clean up only records created by the dedicated staging test identity, using + an operator-approved staging-only mechanism. Never run cleanup against the + production ref. + +Run it locally only after the staging plan has established the schema, current +functions, required secrets, and test code: + +```bash +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 \ +STAGING_SUPABASE_URL= \ +STAGING_SUPABASE_ANON_KEY= \ +STAGING_DEMO_CODE= \ +npm run test:e2e:staging +``` -Adam-only: +The CI staging job may run only from a protected environment on an authorized +`main` push or explicitly authorized main-branch dispatch. It must not run on +pull requests or arbitrary branch dispatches. The job should upload the same +failure artifacts without exposing secret-bearing logs. -- Provide staging provider secret values via `supabase secrets set` (never chat, never files). -- Choose and disclose the VPS image-pickup mechanism. -- Decide: delete `bun.lockb`, enable Vercel previews or not, staging tunnel domain or localhost-only. -- Approve any GitHub settings change making PR checks required/branch protection. +### 4. Add the verification playbook -Secret handling rules: service-role and pepper values only via env vars or `supabase secrets set`; `.env*` stays gitignored; no secret ever printed in logs, commits, or PR comments; staging secrets distinct from prod. +Files: -## Acceptance test for the whole plan +- `AGENTS.md` at the repository root +- the testing and verification section of `README.md` -On a fresh clone, an agent with only AGENTS.md and `.env.e2e`: +Document Node 22, npm, the static/unit/build commands, the credential-free +browser command, the protected staging command, the localhost ports, and the +failure-artifact locations. Explain that a green local run is not a green +GitHub run while Actions billing is blocked. Require a failing reproduction for +bugs and require the agent to state when real staging or browser proof did not +run. -1. Runs `npm ci && npm run verify`, all green including coverage gates. -2. Makes a deliberate bug (break the share page render), runs verify, watches e2e fail with a saved trace naming the broken page. -3. Opens a PR; CI independently reproduces the red gate. -4. Reverts, everything green again. +## Full acceptance gates -That loop, repeated hundreds of times, is the verification layer. Bugs die in the loop instead of on production. +On the implementation branch, after all changes are complete: -## Risks and rollback +```bash +npm ci +npx vitest run src/test/ai/sparring.test.ts supabase/functions/ai/sparring-persistence.test.ts +npx playwright install --with-deps chromium +CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run verify +``` -- Flaky authed e2e erodes trust fast. Mitigation: retries only in CI, tight selectors, staging pinned cheap model, quarantine-and-fix policy for flaky specs within 48h. -- Staging drift (migrations applied out of order, secrets rotated silently). Mitigation: seed script is idempotent; AGENTS.md documents refresh procedure; staging is disposable by design. -- Provider cost runaway from scheduled e2e. Mitigation: daily-limit override caps the demo user; nightly cron is the only scheduled consumer. -- Phase 0 coverage enforcement may immediately redden CI if the 81.56% branches baseline dipped since the TODO was written. Mitigation: run `npm test -- --coverage` locally first; fix gaps before merging the workflow change, not by lowering thresholds. -- Rollback: every phase is additive config plus docs. Reverting the workflow edit restores old CI; deleting the e2e auth setup restores smoke-only; staging can be wiped without touching prod. Nothing here writes to prod except the eventual nginx/Sentry header alignment, which ships through the normal gated pipeline. +WebKit is conditional. If the implementation adds a `webkit` project to +`playwright.config.ts`, install it and run +`CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npx playwright test --project=webkit` +as an additional local/release smoke gate. Until that project is configured, +Chromium is the credential-free PR gate. The protected staging suite is also +required for a release that changes authenticated data or AI flows. +Capture the current-head CI result and artifact links. A billing-blocked CI run +is reported as an exception, never as a pass. + +## Stop conditions + +- The requested worktree is `main`, overlaps another T3 worktree, or has + unexpected dirty files. +- A current browser test fails. Stop and fix or explicitly document the root + cause; do not skip it, weaken it, or replace it with a render-only check. +- Staging variables, the dedicated test code, or the protected GitHub + environment are absent. Do not guess credentials or use production. +- The staging suite would need provider billing, data deletion, OAuth changes, + secret changes, or a hosted staging domain that Adam has not authorized. +- GitHub Actions remains unavailable for account billing reasons. Finish local + verification and record the exception, but do not report CI as passed. + +## Rollback + +All repository changes are reversible. Revert the branch commit(s) that add the +Playwright project, CI steps, staging suite, or docs. Removing the staging +project configuration from CI does not alter Supabase. Keep failure artifacts +ignored and local. + +If the staging suite has created test rows, remove only the dedicated staging +identity's rows through an explicitly approved staging-only cleanup path. Do not +run SQL, function deployment, secret changes, or cleanup against +`uziwdtkzofptihahfooo` as part of this plan. + +## Authorization boundaries + +- CI and local PR checks may read only public app inputs and mocked backends. +- Staging browser tests may use the staging anon key and dedicated test code; + they may write only dedicated staging test data. +- Adam or an explicitly authorized operator supplies protected GitHub variables, + provider values, OAuth configuration, and any staging cleanup authority. +- Applying migrations or deploying functions is not authorized by this plan; + follow `plans/supabase-staging.md` and obtain explicit staging authorization. +- Production migrations, function deploys, secrets, data, billing, and deploys + are out of scope and remain forbidden. diff --git a/playwright.config.ts b/playwright.config.ts index ec97f7798..ac118021b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,19 @@ import { defineConfig, devices } from '@playwright/test'; -const PORT = Number(process.env.PORT || 8080); -const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; +const PORT = Number(process.env.PORT || 18080); +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`; +const stagingStorageState = 'test-results/.auth/staging.json'; +const stagingValues = [ + process.env.STAGING_SUPABASE_URL, + process.env.STAGING_SUPABASE_ANON_KEY, + process.env.STAGING_DEMO_CODE, +]; +const stagingEnabled = stagingValues.every((value) => Boolean(value?.trim())); +const stagingPartiallyConfigured = stagingValues.some((value) => Boolean(value?.trim())) && !stagingEnabled; + +if (stagingPartiallyConfigured) { + throw new Error('Protected staging E2E requires STAGING_SUPABASE_URL, STAGING_SUPABASE_ANON_KEY, and STAGING_DEMO_CODE together.'); +} export default defineConfig({ testDir: './e2e', @@ -12,24 +24,49 @@ export default defineConfig({ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', use: { baseURL, - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', + video: 'retain-on-failure', locale: 'en-US', }, projects: [ { name: 'desktop-chromium', + testIgnore: /staging\//, use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, }, { name: 'mobile-chromium', + testIgnore: /staging\//, use: { ...devices['Pixel 5'] }, }, + { + name: 'webkit', + testIgnore: /staging\//, + use: { ...devices['Desktop Safari'], viewport: { width: 1280, height: 800 } }, + }, + ...(stagingEnabled ? [{ + name: 'staging-setup', + testMatch: /staging\/auth\.setup\.ts/, + use: { trace: 'off' }, + }, + { + name: 'staging', + testMatch: /staging\/authed-flows\.spec\.ts/, + dependencies: ['staging-setup'], + use: { + ...devices['Desktop Chrome'], + storageState: stagingStorageState, + // Traces contain request headers and localStorage. Do not retain a + // staging trace because it would archive the real session token. + trace: 'off', + }, + }] : []), ], webServer: { - command: `npm run dev -- --port ${PORT}`, + command: `npm run dev -- --host 127.0.0.1 --port ${PORT}`, url: baseURL, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120_000, stdout: 'ignore', stderr: 'pipe', diff --git a/src/jam-loader.ts b/src/jam-loader.ts new file mode 100644 index 000000000..b346a4af9 --- /dev/null +++ b/src/jam-loader.ts @@ -0,0 +1,34 @@ +const JAM_SCRIPT_URLS = [ + 'https://js.jam.dev/recorder.js', + 'https://js.jam.dev/capture.js', +] as const; + +interface JamLoaderOptions { + documentRef?: Document; + mediaRecorderAvailable?: boolean; +} + +export function loadJamRecorder(options: JamLoaderOptions = {}): void { + const documentRef = options.documentRef ?? document; + const mediaRecorderAvailable = options.mediaRecorderAvailable + ?? typeof globalThis.MediaRecorder !== 'undefined'; + if (!mediaRecorderAvailable) return; + + const loadedSources = new Set( + Array.from(documentRef.head.querySelectorAll('script[data-jam-recorder]')) + .map((script) => script.src), + ); + + for (const source of JAM_SCRIPT_URLS) { + if (loadedSources.has(source)) continue; + const script = documentRef.createElement('script'); + script.type = 'module'; + script.src = source; + script.dataset.jamRecorder = 'true'; + documentRef.head.append(script); + } +} + +if (typeof document !== 'undefined') { + loadJamRecorder(); +} diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts new file mode 100644 index 000000000..493bdc06a --- /dev/null +++ b/src/test/jam-loader.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { loadJamRecorder } from '@/jam-loader'; + +describe('Jam recorder loader', () => { + it('does not load recorder scripts when MediaRecorder is unavailable', () => { + document.head.querySelectorAll('[data-jam-recorder]').forEach((element) => element.remove()); + + loadJamRecorder({ documentRef: document, mediaRecorderAvailable: false }); + + expect(document.head.querySelectorAll('[data-jam-recorder]')).toHaveLength(0); + }); + + it('loads each Jam module once when recording is supported', () => { + document.head.querySelectorAll('[data-jam-recorder]').forEach((element) => element.remove()); + + loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + + const scripts = Array.from(document.head.querySelectorAll('script[data-jam-recorder]')); + expect(scripts.map((script) => script.src)).toEqual([ + 'https://js.jam.dev/recorder.js', + 'https://js.jam.dev/capture.js', + ]); + expect(scripts.every((script) => script.type === 'module')).toBe(true); + }); +}); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 14437754d..3e545ebe9 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -90,14 +90,17 @@ describe("Vercel frontend deployment configuration", () => { const config = loadConfig(); const csp = headersFor(config, "/(.*)")["Content-Security-Policy"]; const indexHtml = readProjectFile("index.html"); + const jamLoader = readProjectFile("src/jam-loader.ts"); const nginxConfig = readProjectFile("nginx.conf"); const nginxCsp = nginxConfig.match(/add_header Content-Security-Policy "([^"]+)"/)?.[1]; expect(csp).toContain("script-src 'self' https://*.jam.dev"); expect(csp).toContain("frame-src 'self' https://accounts.google.com https://*.jam.dev"); expect(nginxCsp).toBe(csp); - expect(indexHtml).toContain(''); - expect(indexHtml).toContain(''); + expect(indexHtml).toContain(''); + expect(jamLoader).toContain("typeof globalThis.MediaRecorder !== 'undefined'"); + expect(jamLoader).toContain("https://js.jam.dev/recorder.js"); + expect(jamLoader).toContain("https://js.jam.dev/capture.js"); expect(indexHtml).toContain(''); }); diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 8b88731de..7c661a367 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -5,6 +5,8 @@ const ALLOWED_ORIGINS = [ "https://debate.pi-stack.org", "http://localhost:8080", "http://127.0.0.1:8080", + "http://localhost:18080", + "http://127.0.0.1:18080", "http://localhost:5173", "http://127.0.0.1:54321", "capacitor://localhost", diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index c4adbaf30..541979c9f 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -9,6 +9,8 @@ const ALLOWED_ORIGINS = [ "https://debate.pi-stack.org", "http://localhost:8080", "http://127.0.0.1:8080", + "http://localhost:18080", + "http://127.0.0.1:18080", "http://localhost:5173", "http://127.0.0.1:54321", // Local Supabase "capacitor://localhost" diff --git a/supabase/functions/demo-code-login/index.ts b/supabase/functions/demo-code-login/index.ts index 2011d4bdd..1781f1018 100644 --- a/supabase/functions/demo-code-login/index.ts +++ b/supabase/functions/demo-code-login/index.ts @@ -4,6 +4,8 @@ const ALLOWED_ORIGINS = [ "https://debate.pi-stack.org", "http://localhost:8080", "http://127.0.0.1:8080", + "http://localhost:18080", + "http://127.0.0.1:18080", "http://localhost:5173", "http://127.0.0.1:54321", ]; From ee3845b9f04a0c060056e1282e0f2644b61c06cf Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:58:59 +0000 Subject: [PATCH 2/3] fix: harden protected browser verification --- .audit/agent-verification-loop.tsv | 2 ++ .github/workflows/staging-e2e.yml | 1 + e2e/staging/auth.setup.ts | 4 +-- index.html | 3 +- plans/agent-verification-loop.md | 9 +++--- src/bootstrap.ts | 5 ++++ src/jam-loader.ts | 34 +++++++++++++---------- src/test/jam-loader.test.ts | 32 +++++++++++++-------- src/test/vercel-deployment-config.test.ts | 6 +++- 9 files changed, 60 insertions(+), 36 deletions(-) create mode 100644 src/bootstrap.ts diff --git a/.audit/agent-verification-loop.tsv b/.audit/agent-verification-loop.tsv index 3e7db7440..136c2578e 100644 --- a/.audit/agent-verification-loop.tsv +++ b/.audit/agent-verification-loop.tsv @@ -14,3 +14,5 @@ ts phase decision why evidence result 2026-08-31T16:35:00Z integration rebased onto analytics and telemetry main changes kept the PR current before push and invalidated older verification origin/main 7bca126 clean rebase; full gates rerun 2026-08-31T16:40:00Z local gates ran fresh CI-equivalent checks after rebase proved the exact integrated source before PR creation npm ci, npm run verify 89 files, 930 tests, coverage, build, and 112 Chromium cases passed 2026-08-31T16:42:00Z webkit reran the full Safari-engine matrix after rebase main changed routing and AI code since the first WebKit proof Playwright 1.60 Noble container with Node 22 56 WebKit cases passed +2026-08-31T16:56:00Z CodeRabbit fixed all four current-head review comments restricted trusted staging triggers and loopback auth, updated plan state, and sequenced Jam before app startup PR 21 review on 5f0f67a all findings implemented or bounded with deterministic proof +2026-08-31T16:59:00Z local gates reran CI-equivalent checks after CodeRabbit fixes review changes affected workflow security and application bootstrap npm run verify, Playwright 1.60 Noble container 89 files, 930 tests, coverage, build, 112 Chromium and 56 WebKit cases passed diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 936a8aa98..7962d264d 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -19,6 +19,7 @@ jobs: ( (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' diff --git a/e2e/staging/auth.setup.ts b/e2e/staging/auth.setup.ts index 253eca7d8..a0b125623 100644 --- a/e2e/staging/auth.setup.ts +++ b/e2e/staging/auth.setup.ts @@ -67,8 +67,8 @@ function loadStagingConfig(): StagingConfig { const parsedBaseUrl = new URL(baseUrl); const isLoopback = parsedBaseUrl.hostname === '127.0.0.1' || parsedBaseUrl.hostname === 'localhost'; - if (parsedBaseUrl.protocol !== 'https:' && !(parsedBaseUrl.protocol === 'http:' && isLoopback)) { - throw new Error('PLAYWRIGHT_BASE_URL must use HTTPS or an HTTP loopback address'); + if (parsedBaseUrl.protocol !== 'http:' || !isLoopback) { + throw new Error('PLAYWRIGHT_BASE_URL must use an HTTP loopback address'); } return { supabaseUrl, anonKey, demoCode, baseUrl }; diff --git a/index.html b/index.html index 86767dbed..322587c6d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + DebateLab by Programcode | AI Debate Prep, Cases & Rebuttals @@ -98,6 +98,5 @@

diff --git a/plans/agent-verification-loop.md b/plans/agent-verification-loop.md index f7d1b2979..0e5304033 100644 --- a/plans/agent-verification-loop.md +++ b/plans/agent-verification-loop.md @@ -209,11 +209,10 @@ npx playwright install --with-deps chromium CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run verify ``` -WebKit is conditional. If the implementation adds a `webkit` project to -`playwright.config.ts`, install it and run -`CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npx playwright test --project=webkit` -as an additional local/release smoke gate. Until that project is configured, -Chromium is the credential-free PR gate. The protected staging suite is also +WebKit is configured as an explicit local and release smoke gate. Install it +with `npx playwright install --with-deps webkit`, then run +`CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit`. +Chromium remains the credential-free PR gate. The protected staging suite is also required for a release that changes authenticated data or AI flows. Capture the current-head CI result and artifact links. A billing-blocked CI run is reported as an exception, never as a pass. diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 000000000..916b1f1e6 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,5 @@ +import { loadJamRecorder } from './jam-loader'; + +void loadJamRecorder() + .catch((): undefined => undefined) + .then(() => import('./main')); diff --git a/src/jam-loader.ts b/src/jam-loader.ts index b346a4af9..6e31b39e1 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -8,27 +8,31 @@ interface JamLoaderOptions { mediaRecorderAvailable?: boolean; } -export function loadJamRecorder(options: JamLoaderOptions = {}): void { - const documentRef = options.documentRef ?? document; - const mediaRecorderAvailable = options.mediaRecorderAvailable - ?? typeof globalThis.MediaRecorder !== 'undefined'; - if (!mediaRecorderAvailable) return; +function appendJamModule(documentRef: Document, source: string): Promise { + const existing = Array.from( + documentRef.head.querySelectorAll('script[data-jam-recorder]'), + ).find((script) => script.src === source); + if (existing) return Promise.resolve(); - const loadedSources = new Set( - Array.from(documentRef.head.querySelectorAll('script[data-jam-recorder]')) - .map((script) => script.src), - ); - - for (const source of JAM_SCRIPT_URLS) { - if (loadedSources.has(source)) continue; + return new Promise((resolve) => { const script = documentRef.createElement('script'); script.type = 'module'; + script.async = false; script.src = source; script.dataset.jamRecorder = 'true'; + script.addEventListener('load', () => resolve(), { once: true }); + script.addEventListener('error', () => resolve(), { once: true }); documentRef.head.append(script); - } + }); } -if (typeof document !== 'undefined') { - loadJamRecorder(); +export async function loadJamRecorder(options: JamLoaderOptions = {}): Promise { + const documentRef = options.documentRef ?? document; + const mediaRecorderAvailable = options.mediaRecorderAvailable + ?? typeof globalThis.MediaRecorder !== 'undefined'; + if (!mediaRecorderAvailable) return; + + for (const source of JAM_SCRIPT_URLS) { + await appendJamModule(documentRef, source); + } } diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts index 493bdc06a..3f89fee9c 100644 --- a/src/test/jam-loader.test.ts +++ b/src/test/jam-loader.test.ts @@ -1,26 +1,36 @@ import { describe, expect, it } from 'vitest'; import { loadJamRecorder } from '@/jam-loader'; +function jamScripts() { + return Array.from(document.head.querySelectorAll('script[data-jam-recorder]')); +} + describe('Jam recorder loader', () => { - it('does not load recorder scripts when MediaRecorder is unavailable', () => { - document.head.querySelectorAll('[data-jam-recorder]').forEach((element) => element.remove()); + it('does not load recorder scripts when MediaRecorder is unavailable', async () => { + jamScripts().forEach((element) => element.remove()); - loadJamRecorder({ documentRef: document, mediaRecorderAvailable: false }); + await loadJamRecorder({ documentRef: document, mediaRecorderAvailable: false }); - expect(document.head.querySelectorAll('[data-jam-recorder]')).toHaveLength(0); + expect(jamScripts()).toHaveLength(0); }); - it('loads each Jam module once when recording is supported', () => { - document.head.querySelectorAll('[data-jam-recorder]').forEach((element) => element.remove()); + it('loads recorder then capture before resolving startup', async () => { + jamScripts().forEach((element) => element.remove()); - loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); - loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + const loading = loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + expect(jamScripts().map((script) => script.src)).toEqual(['https://js.jam.dev/recorder.js']); - const scripts = Array.from(document.head.querySelectorAll('script[data-jam-recorder]')); - expect(scripts.map((script) => script.src)).toEqual([ + jamScripts()[0].dispatchEvent(new Event('load')); + await Promise.resolve(); + expect(jamScripts().map((script) => script.src)).toEqual([ 'https://js.jam.dev/recorder.js', 'https://js.jam.dev/capture.js', ]); - expect(scripts.every((script) => script.type === 'module')).toBe(true); + expect(jamScripts().every((script) => script.type === 'module' && !script.async)).toBe(true); + + jamScripts()[1].dispatchEvent(new Event('load')); + await loading; + await loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + expect(jamScripts()).toHaveLength(2); }); }); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 3e545ebe9..36b9c5baa 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -90,6 +90,7 @@ describe("Vercel frontend deployment configuration", () => { const config = loadConfig(); const csp = headersFor(config, "/(.*)")["Content-Security-Policy"]; const indexHtml = readProjectFile("index.html"); + const bootstrap = readProjectFile("src/bootstrap.ts"); const jamLoader = readProjectFile("src/jam-loader.ts"); const nginxConfig = readProjectFile("nginx.conf"); const nginxCsp = nginxConfig.match(/add_header Content-Security-Policy "([^"]+)"/)?.[1]; @@ -97,7 +98,10 @@ describe("Vercel frontend deployment configuration", () => { expect(csp).toContain("script-src 'self' https://*.jam.dev"); expect(csp).toContain("frame-src 'self' https://accounts.google.com https://*.jam.dev"); expect(nginxCsp).toBe(csp); - expect(indexHtml).toContain(''); + expect(indexHtml).toContain(''); + expect(indexHtml).not.toContain(''); + expect(bootstrap).toContain('loadJamRecorder()'); + expect(bootstrap).toContain("then(() => import('./main'))"); expect(jamLoader).toContain("typeof globalThis.MediaRecorder !== 'undefined'"); expect(jamLoader).toContain("https://js.jam.dev/recorder.js"); expect(jamLoader).toContain("https://js.jam.dev/capture.js"); From 30d24561afbfae42db98431e073ad2f8e717c7cd Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 17:07:13 +0000 Subject: [PATCH 3/3] fix: bound Jam recorder startup --- .audit/agent-verification-loop.tsv | 1 + src/jam-loader.ts | 20 +++++++++++++++++--- src/test/jam-loader.test.ts | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.audit/agent-verification-loop.tsv b/.audit/agent-verification-loop.tsv index 136c2578e..d311de7d9 100644 --- a/.audit/agent-verification-loop.tsv +++ b/.audit/agent-verification-loop.tsv @@ -16,3 +16,4 @@ ts phase decision why evidence result 2026-08-31T16:42:00Z webkit reran the full Safari-engine matrix after rebase main changed routing and AI code since the first WebKit proof Playwright 1.60 Noble container with Node 22 56 WebKit cases passed 2026-08-31T16:56:00Z CodeRabbit fixed all four current-head review comments restricted trusted staging triggers and loopback auth, updated plan state, and sequenced Jam before app startup PR 21 review on 5f0f67a all findings implemented or bounded with deterministic proof 2026-08-31T16:59:00Z local gates reran CI-equivalent checks after CodeRabbit fixes review changes affected workflow security and application bootstrap npm run verify, Playwright 1.60 Noble container 89 files, 930 tests, coverage, build, 112 Chromium and 56 WebKit cases passed +2026-08-31T17:06:00Z CodeRabbit added a bounded Jam CDN timeout prevented a stalled recorder request from blocking application startup src/jam-loader.ts, src/test/jam-loader.test.ts 20 focused tests, lint, typecheck, build, Chromium and WebKit startup passed diff --git a/src/jam-loader.ts b/src/jam-loader.ts index 6e31b39e1..4e978fc07 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -2,6 +2,7 @@ const JAM_SCRIPT_URLS = [ 'https://js.jam.dev/recorder.js', 'https://js.jam.dev/capture.js', ] as const; +const JAM_SCRIPT_TIMEOUT_MS = 3_000; interface JamLoaderOptions { documentRef?: Document; @@ -14,14 +15,27 @@ function appendJamModule(documentRef: Document, source: string): Promise { ).find((script) => script.src === source); if (existing) return Promise.resolve(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const script = documentRef.createElement('script'); script.type = 'module'; script.async = false; script.src = source; script.dataset.jamRecorder = 'true'; - script.addEventListener('load', () => resolve(), { once: true }); - script.addEventListener('error', () => resolve(), { once: true }); + + let settled = false; + const finish = (outcome: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + outcome(); + }; + const timeoutId = setTimeout(() => { + script.remove(); + finish(() => reject(new Error(`Jam module load timed out: ${source}`))); + }, JAM_SCRIPT_TIMEOUT_MS); + + script.addEventListener('load', () => finish(resolve), { once: true }); + script.addEventListener('error', () => finish(resolve), { once: true }); documentRef.head.append(script); }); } diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts index 3f89fee9c..48a0785c7 100644 --- a/src/test/jam-loader.test.ts +++ b/src/test/jam-loader.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { loadJamRecorder } from '@/jam-loader'; function jamScripts() { @@ -6,6 +6,10 @@ function jamScripts() { } describe('Jam recorder loader', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('does not load recorder scripts when MediaRecorder is unavailable', async () => { jamScripts().forEach((element) => element.remove()); @@ -33,4 +37,16 @@ describe('Jam recorder loader', () => { await loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); expect(jamScripts()).toHaveLength(2); }); + + it('rejects a stalled Jam load so application bootstrap can continue', async () => { + jamScripts().forEach((element) => element.remove()); + vi.useFakeTimers(); + + const loading = loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true }); + const rejection = expect(loading).rejects.toThrow('Jam module load timed out'); + await vi.advanceTimersByTimeAsync(3_000); + + await rejection; + expect(jamScripts()).toHaveLength(0); + }); });