diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index f3e5b883f..8dd95a7af 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Runs the Docker-backed Supabase, database, collaboration, and real-Auth browser gate +# Runs the Docker-backed Supabase, database, collaboration, and full-stack browser gate # with exclusive access across # Superset workspaces of this repo. `supabase/config.toml` sets no # project_id or custom port block, so `supabase db start` always binds the @@ -13,6 +13,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$DIR/lib/lock.sh" DB_LOCK="$HOME/.superset/debatelab-db.lock" +FAKE_PROVIDER=debatelab-fake-openrouter stack_started=false runtime_dir="" owned_pids=() @@ -32,6 +33,7 @@ cleanup() { trap - EXIT stop_owned_processes + docker rm -f "$FAKE_PROVIDER" >/dev/null 2>&1 || true if [ -n "$runtime_dir" ] && [ -f "$runtime_dir/function.env" ]; then rm -- "$runtime_dir/function.env" @@ -59,8 +61,15 @@ if [ -n "${WORKSPACE_GATE_ARTIFACT_ROOT:-}" ]; then else runtime_dir="$(mktemp -d)" fi -supabase start >"$runtime_dir/supabase.log" 2>&1 +# Shared CI runners intermittently hit registry rate limits while pulling images. stack_started=true +for attempt in 1 2 3; do + if supabase start >>"$runtime_dir/supabase.log" 2>&1; then break; fi + if [ "$attempt" -eq 3 ]; then exit 1; fi + echo "supabase start failed (attempt $attempt); retrying after $((attempt * 30))s" + supabase stop --no-backup >/dev/null 2>&1 || true + sleep $((attempt * 30)) +done npm run test:db status_json="$(supabase status -o json 2>/dev/null)" @@ -82,7 +91,13 @@ LOCAL_SERVICE_ROLE_KEY="$service_role_key" \ LOCAL_MAILER_HOST="$mailer_host" \ node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env" -setsid supabase functions serve workspace-invites --no-verify-jwt \ +# Edge Functions reach the fake provider by container name on the stack network. +docker rm -f "$FAKE_PROVIDER" >/dev/null 2>&1 || true +docker run -d --rm --name "$FAKE_PROVIDER" --network "supabase_network_$project_id" \ + -p 127.0.0.1:18090:8787 -v "$(pwd)/e2e/full-stack/fake-openrouter.mjs:/fake.mjs:ro" \ + node:22-alpine node /fake.mjs >/dev/null + +setsid supabase functions serve --no-verify-jwt \ --env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 & owned_pids+=("$!") @@ -94,19 +109,21 @@ VITE_COLLABORATION_URL=ws://127.0.0.1:12345 \ owned_pids+=("$!") mailer_url="$(status_value MAILPIT_URL)" -for url in "$mailer_url/api/v1/messages" "$app_origin"; do +for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/__requests"; do for _ in $(seq 1 200); do if curl --fail --silent --output /dev/null "$url"; then break; fi sleep 0.1 done curl --fail --silent --output /dev/null "$url" done -function_url="$api_url/functions/v1/workspace-invites" -for _ in $(seq 1 200); do - if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi - sleep 0.1 +for function_name in workspace-invites ai ai-stream demo-code-login admin announcements; do + function_url="$api_url/functions/v1/$function_name" + for _ in $(seq 1 200); do + if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi + sleep 0.1 + done + curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url" done -curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url" LOCAL_SUPABASE_URL="$api_url" \ LOCAL_SUPABASE_ANON_KEY="$anon_key" \ @@ -117,5 +134,7 @@ COLLABORATION_PORT=12345 \ COLLABORATION_ALLOWED_ORIGINS="$app_origin" \ SUPABASE_URL="$api_url" \ SUPABASE_ANON_KEY="$anon_key" \ +FAKE_OPENROUTER_URL=http://127.0.0.1:18090 \ +DEMO_CODE_PEPPER="$(sed -n 's/^DEMO_CODE_PEPPER=//p' .superset/workspace-test.env)" \ PLAYWRIGHT_BASE_URL="$app_origin" \ npx playwright test --config=playwright.workspace.config.ts diff --git a/.superset/workspace-test.env b/.superset/workspace-test.env index ee09e2f1b..4ab3b38f0 100644 --- a/.superset/workspace-test.env +++ b/.superset/workspace-test.env @@ -1,7 +1,11 @@ -# Test-only values for the locked local workspace browser gate. +# Test-only values for the locked local full-stack browser gate. WORKSPACE_INVITE_SECRET=local-workspace-invite-secret-for-browser-gate WORKSPACE_INVITE_MAILER_URL=http://workspace-mailer.invalid:8025/api/v1/send WORKSPACE_INVITE_MAILER_TOKEN=local-workspace-mailer WORKSPACE_INVITE_MAILER_FORMAT=mailpit WORKSPACE_INVITE_BASE_URL=http://127.0.0.1:18081 WORKSPACE_INVITE_ALLOWED_ORIGINS=http://127.0.0.1:18081 +OPENROUTER_API_KEY=local-fake-openrouter-key +OPENROUTER_API_URL=http://debatelab-fake-openrouter:8787/api +DEMO_CODE_PEPPER=local-demo-code-pepper-for-browser-gate +SPARRING_INTEGRITY_SECRET=local-sparring-integrity-secret-for-browser-gate diff --git a/docs/testing.md b/docs/testing.md index cbb35d08b..4a2ab799c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -145,7 +145,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, and Vite client run the real-Auth desktop/mobile workspace journey. 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. 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: ```bash supabase db start @@ -155,6 +155,23 @@ supabase stop --no-backup The stack replays migrations and runs pgTAP tests. The browser portion creates isolated local users and deletes them, captures mail only in local Mailpit, restarts the collaboration process, and proves recovery and revocation. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch. +### Full-stack feature journeys + +The same gate serves every Edge Function and runs `e2e/full-stack/` on desktop and mobile Chromium. Only the paid model provider is replaced: [`e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) runs as a `node:22-alpine` container on the Supabase Docker network, and the functions reach it through `OPENROUTER_API_URL`. Real code handles everything else: Auth, demo-code sign-in, RLS, quota, source loading, prompt building, output validation, streaming, and persistence. + +Each test signs in through the Login page with a fresh demo code and deletes that user afterwards. The specs cover: + +- sign-in, reload, sign-out, a rejected code, and an expired token that refreshes before an AI request; +- case generation grounded in a 250 KB Library source, provider failure with retry, a zero quota, and a saved case that a fresh browser loads from History; +- Attack, Brainstorm, Factoids, streamed Ask (it fails if the client falls back to the non-streaming path), and Ask with citations; +- a judged Lincoln-Douglas sparring round and a Crossfire debrief; +- Library activation and deletion, output-language sync across devices, and flowchart saves; +- admin announcements seen by another user, denied admin access, and a public share link. + +Tests assert the rendered result, what the Edge Function sent upstream (read back from the fake provider), and the resulting database rows. They also fail on any uncaught page error or horizontal page overflow. The fake provider rejects prompts it does not recognize, so a prompt change must update it. + +These journeys do not prove live provider behavior, web research (the local stack has no search key), Jev decisions (the fake returns 503 and callers use their fallbacks), Stripe, or Google export. + The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. Never substitute a staging or production project for this gate. diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts new file mode 100644 index 000000000..1f5a2ea68 --- /dev/null +++ b/e2e/full-stack/account-data.spec.ts @@ -0,0 +1,76 @@ +import { admin, expect, MARK, providerCall, signIn, stackReady, test } from "./stack"; +import type { Page } from "@playwright/test"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +async function addSource(page: Page, title: string, content: string) { + await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("textbox", { name: "Source title..." }).fill(title); + await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Save to Library" }).click(); + await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); +} + +test("only active Library sources ground AI requests, and deletions persist", async ({ signedIn: page, user }) => { + await page.goto("/app/library"); + await addSource(page, "Active study", "ACTIVE-SOURCE-MARKER ridership rose after fares were removed."); + await addSource(page, "Paused study", "PAUSED-SOURCE-MARKER congestion did not change."); + await page.getByRole("switch", { name: "Toggle Paused study" }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked(); + + await page.goto("/app/brainstorm"); + await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + const call = await providerCall("brainstorm"); + expect(call.system).toContain("ACTIVE-SOURCE-MARKER"); + expect(call.system).not.toContain("PAUSED-SOURCE-MARKER"); + + await page.goto("/app/library"); + await page.getByRole("button", { name: "Delete Paused study", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("switch", { name: "Toggle Active study" })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); + + const { data } = await admin.from("sources").select("title").eq("user_id", await user.userId()); + expect(data?.map((row) => row.title)).toEqual(["Active study"]); +}); + +test("the output language setting follows the account and reaches the model", async ({ signedIn: page, user, browser }) => { + await page.goto("/app/settings"); + await page.locator("div.space-y-2", { has: page.getByText("AI Output Language", { exact: true }) }).getByRole("combobox").click(); + await page.getByRole("option", { name: "Magyar" }).click(); + await expect.poll(async () => { + const { data } = await admin.from("user_settings").select("output_language").eq("user_id", await user.userId()).single(); + return data?.output_language; + }).toBe("hu"); + + // A new device starts with English defaults and must hydrate from the account. + const context = await browser.newContext(); + try { + const other = await context.newPage(); + await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); + await signIn(other, user); + await other.goto("/app/attack"); + await other.getByLabel("Your Argument *").fill("Free transit cuts congestion."); + await other.getByRole("button", { name: "Find Weaknesses" }).click(); + await expect(other.getByText(`${MARK} weakness`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("attack")).user).toContain("Language: Hungarian (hu)"); + } finally { + await context.close(); + } +}); + +test("a saved flowchart is stored on the account", async ({ signedIn: page, user }) => { + const title = `Flow ${Date.now()}`; + await page.goto("/app/flowchart"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("textbox").fill(title); + await dialog.getByRole("button", { name: "Save Flowchart" }).click(); + await expect(page.getByText("Flowchart saved successfully")).toBeVisible(); + + const { data } = await admin.from("flowcharts").select("title").eq("user_id", await user.userId()); + expect(data?.map((row) => row.title)).toEqual([title]); +}); diff --git a/e2e/full-stack/admin.spec.ts b/e2e/full-stack/admin.spec.ts new file mode 100644 index 000000000..e7bc856ab --- /dev/null +++ b/e2e/full-stack/admin.spec.ts @@ -0,0 +1,39 @@ +import { admin, createDemoUser, deleteDemoUser, expect, signIn, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +test("an admin publishes an announcement that another user sees", async ({ signedIn: page, user, browser }) => { + const title = `Maintenance ${Date.now()}`; + const reader = await createDemoUser("reader"); + const readerContext = await browser.newContext(); + try { + // Admin is granted out of band, never through demo sign-in. + await admin.from("user_roles").update({ role: "admin" }).eq("user_id", await user.userId()); + await page.goto("/app/admin"); + await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible(); + await page.getByRole("tab", { name: "Announcements" }).click(); + await page.getByLabel("Title").fill(title); + await page.getByLabel("Message").fill("DebateLab will be unavailable for ten minutes."); + await page.getByRole("button", { name: "Create announcement" }).click(); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); + + const readerPage = await readerContext.newPage(); + await readerPage.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); + await signIn(readerPage, reader); + await expect(readerPage.getByRole("heading", { name: title })).toBeVisible(); + await readerPage.getByRole("button", { name: `Dismiss announcement: ${title}` }).click(); + await readerPage.reload(); + await expect(readerPage.getByRole("main")).toBeVisible(); + await expect(readerPage.getByRole("heading", { name: title })).toHaveCount(0); + } finally { + await readerContext.close(); + await admin.from("admin_announcements").delete().eq("title", title); + await deleteDemoUser(reader); + } +}); + +test("a regular user cannot open the admin dashboard", async ({ signedIn: page }) => { + await page.goto("/app/admin"); + await expect(page.getByText("Access Denied")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toHaveCount(0); +}); diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts new file mode 100644 index 000000000..d86712c62 --- /dev/null +++ b/e2e/full-stack/ai-tools.spec.ts @@ -0,0 +1,61 @@ +import { admin, expect, MARK, fake, providerCall, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +test("Attack analyses a pasted case", async ({ signedIn: page }) => { + await page.goto("/app/attack"); + await page.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home."); + await page.getByRole("button", { name: "Find Weaknesses" }).click(); + + await expect(page.getByText(`${MARK} weakness`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("attack")).user).toContain("riders leave their cars at home"); +}); + +test("Brainstorm returns ranked angles", async ({ signedIn: page }) => { + await page.goto("/app/brainstorm"); + await page.getByLabel("Motion / Topic *").fill("Universal basic income should replace targeted welfare"); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + + await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("brainstorm")).user).toContain("Motion: Universal basic income should replace targeted welfare"); +}); + +test("Factoids completes research without verified web sources", async ({ signedIn: page }) => { + await page.goto("/app/factoids"); + await page.getByLabel("Statement *").fill("Fare-free transit raises ridership"); + await page.getByRole("button", { name: "Find Factoids" }).click(); + + // The local stack has no search key, so the server must drop every unverified + // factoid and still finish instead of inventing citations. + await expect(page.getByText(`${MARK} search suggestion`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("factoids")).user).toContain('"Fare-free transit raises ridership"'); +}); + +test("Ask streams a coaching answer and records usage", async ({ signedIn: page, user }) => { + await page.goto("/app/ask"); + await page.getByPlaceholder("Ask your debate coach...").fill("How do I weigh access against cost?"); + await page.getByRole("button", { name: "Send message" }).click(); + + await expect(page.getByText(`${MARK} streamed answer: weigh access against cost.`)).toBeVisible({ timeout: 30_000 }); + // The client silently falls back to the non-streaming path when streaming + // fails, so prove the answer really came through ai-stream. + const features = (await fake.requests()).map((request) => request.feature); + expect(features).toContain("ask-stream"); + expect(features).not.toContain("ask"); + + const userId = await user.userId(); + await expect.poll(async () => { + const { data } = await admin.from("ai_requests").select("feature,status").eq("user_id", userId); + return data; + }).toContainEqual(expect.objectContaining({ feature: "ask", status: "success" })); +}); + +test("Ask with citations uses the structured endpoint", async ({ signedIn: page }) => { + await page.goto("/app/ask"); + await page.locator("#citation-mode").click(); + await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); + await page.getByRole("button", { name: "Send message" }).click(); + + await expect(page.getByText(`${MARK} answer`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("ask")).user).toContain("Which evidence supports fare-free transit?"); +}); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts new file mode 100644 index 000000000..398216e79 --- /dev/null +++ b/e2e/full-stack/auth.spec.ts @@ -0,0 +1,44 @@ +import { admin, expect, MARK, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +test("a demo code signs in, survives reload, and signs out", async ({ signedIn: page, user }) => { + const userId = await user.userId(); + // demo-code-login provisions a Pro demo account; entitlement checks depend on it. + const { data: settings } = await admin.from("user_settings").select("subscription_tier,daily_limit_override").eq("user_id", userId).single(); + expect(settings).toEqual({ subscription_tier: "pro", daily_limit_override: 999 }); + + await page.reload(); + await expect(page).toHaveURL(/\/app$/); + await page.goto("/app/settings"); + await expect(page.getByText(user.email).first()).toBeVisible(); + + await page.getByRole("button", { name: "Sign out" }).click(); + await page.goto("/app/generate"); + await expect(page).toHaveURL(/\/login/); +}); + +test("an unknown demo code is rejected by the real function", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("tab", { name: "Demo code" }).click(); + await page.getByRole("textbox", { name: "Demo code" }).fill("DEMO-ZZZZ-ZZZZ"); + await page.getByRole("button", { name: "Sign in with demo code" }).click(); + + await expect(page.getByRole("alert")).toHaveText("Demo code not found."); + await expect(page).toHaveURL(/\/login/); +}); + +test("an expired access token refreshes before an AI request", async ({ signedIn: page }) => { + // Returning to a tab after the token expired is the common real-world case + // for session races between the auth client and AI requests. + await page.evaluate(() => { + const key = Object.keys(localStorage).find((name) => name.startsWith("debate-auth-"))!; + const session = JSON.parse(localStorage.getItem(key)!); + session.expires_at = Math.floor(Date.now() / 1000) - 60; + localStorage.setItem(key, JSON.stringify(session)); + }); + await page.goto("/app/generate"); + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); +}); diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts new file mode 100644 index 000000000..406da9df1 --- /dev/null +++ b/e2e/full-stack/case-generation.spec.ts @@ -0,0 +1,86 @@ +import { admin, expect, fake, MARK, providerCall, signIn, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +const motion = "This House would make public transport free"; + +test("a signed-in user generates a case grounded in a saved Library source", async ({ signedIn: page, user }) => { + const marker = `SOURCE-${Date.now()}`; + await page.goto("/app/library"); + await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("textbox", { name: "Source title..." }).fill("Tallinn fare study"); + // Imported articles are often this long. Inlining that text in the request + // exceeded the Edge Function's 200 KB body limit before sources loaded server-side. + await page.getByRole("textbox", { name: "Paste source content..." }) + .fill(`${marker} Tallinn ridership rose 14 percent after fares were removed. ${"Detailed ridership evidence. ".repeat(9_000)}`); + await page.getByRole("button", { name: "Save to Library" }).click(); + await expect(page.getByText("Grounding Active")).toBeVisible(); + + const userId = await user.userId(); + const { data: sources } = await admin.from("sources").select("id,is_active").eq("user_id", userId); + expect(sources).toEqual([expect.objectContaining({ is_active: true })]); + + await page.goto("/app/generate"); + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + + // A hang (as in the auth relock regression) fails here instead of spinning forever. + await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(`${MARK} contention 3`)).toBeVisible(); + + const call = await providerCall("generate"); + expect(call.user).toContain(`Motion: ${motion}`); + // The Edge Function loaded the persisted source itself and grounded the prompt in it. + expect(call.system).toContain(marker); + + const { data: usage } = await admin.from("ai_requests").select("feature,status").eq("user_id", userId); + expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success" })); +}); + +test("a provider failure shows an error and lets the user retry", async ({ signedIn: page }) => { + await fake.fail("generate", 500); + await page.goto("/app/generate"); + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + + await expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeEnabled(); + + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); +}); + +test("an account without quota is told why generation is blocked", async ({ signedIn: page, user }) => { + await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); + await page.goto("/app/generate"); + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + + await expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: 30_000 }); + expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); +}); + +test("a saved case persists to the account and reloads from History", async ({ signedIn: page, user }) => { + await page.goto("/app/generate"); + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + + const title = `Saved case ${Date.now()}`; + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("dialog").getByLabel("Case title").fill(title); + await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(async () => { + const { data } = await admin.from("cases").select("title").eq("user_id", await user.userId()); + return data?.map((row) => row.title); + }).toEqual([title]); + + // A fresh browser context has no local copy, so History must hydrate from the database. + const fresh = await page.context().browser()!.newContext(); + const other = await fresh.newPage(); + await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); + await signIn(other, user); + await other.goto("/app/history"); + await expect(other.getByText(title)).toBeVisible(); + await fresh.close(); +}); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs new file mode 100644 index 000000000..aa9ff1f42 --- /dev/null +++ b/e2e/full-stack/fake-openrouter.mjs @@ -0,0 +1,231 @@ +// Deterministic stand-in for OpenRouter used by the full-stack browser gate. +// The real `ai` and `ai-stream` Edge Functions call it through +// OPENROUTER_API_URL, so their auth, quota, source loading, prompt building, +// and output validation all run for real. Unknown prompts fail loudly: a prompt +// change must update this file rather than silently receive a generic answer. +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 8787); +const requests = []; +let failures = []; + +const MARK = "FAKE-PROVIDER"; + +const usage = { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, cost: 0.0001 }; + +const contention = (index) => ({ + id: String(index + 1), + claim: `${MARK} contention ${index + 1}: fares block the riders who need transit most`, + reasoning: "Fares are a regressive barrier concentrated on low-income riders.", + evidence: "Comparable jurisdictions report ridership gains after fare removal.", + needsSource: false, + impact: "More people reach work, school, and healthcare.", +}); + +const generate = (user) => { + const length = /^Length: (\w+)/m.exec(user)?.[1] ?? "standard"; + const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3; + return { + caseStructure: { + thesis: `${MARK} case thesis: free transit is a net public good`, + framework: "Net societal benefit, weighed on access and cost.", + keyDefinitions: ["Public transport: publicly funded scheduled transit."], + burdenOfProof: "We must show the policy produces a net gain in practice.", + }, + contentions: Array.from({ length: count }, (_, index) => contention(index)), + predictedOpponentCase: { + likelyArguments: ["The lost farebox revenue has to come from somewhere."], + strategicWeaknesses: ["Assumes fare revenue is the only funding model."], + keyClashes: ["Who actually bears the cost."], + }, + counters: [{ + targetArgument: "Funding gap", + counterpoint: "Fare collection itself carries substantial overhead.", + rebuttal: "Net cost is far below headline farebox revenue.", + }], + metadata: { side: "pro", format: "custom", generatedAt: new Date().toISOString() }, + }; +}; + +const attack = () => ({ + overallAssessment: { + score: 62, + summary: `${MARK} attack summary: the central warrant is underdefended.`, + strengthAreas: ["Clear thesis"], + }, + weaknesses: [{ + id: "w1", + severity: "major", + type: "evidential", + description: `${MARK} weakness: the congestion claim has no comparative evidence.`, + whyWeak: "Correlation between fare policy and congestion is asserted, not shown.", + howOpponentAttacks: "Name a city where ridership rose and congestion did not fall.", + improvedVersion: "Cite a before/after study with a control corridor.", + }], + fallaciesDetected: [{ name: "Hasty generalization", location: "Second contention", explanation: "One city generalized to all." }], + crossExQuestions: [{ question: "Which funding line replaces the farebox?", purpose: "Force a concrete mechanism.", expectedTrap: "A vague appeal to general taxation." }], + weighingAnalysis: { + currentWeighing: "Access is asserted to outweigh cost without comparison.", + missingComparisons: ["Cost per additional rider versus targeted subsidy."], + improvementSuggestions: ["Weigh against the strongest alternative."], + }, +}); + +const idea = (index, side) => ({ + id: `i${index}`, + title: `${MARK} idea ${index}: access as a precondition for participation`, + description: "Mobility gates work, education, and civic life.", + side, + strength: "strong", + tradeoffs: ["Requires a durable funding source."], + relatedTo: [], +}); + +const brainstorm = () => ({ + needsMoreInfo: false, + topFive: [1, 2, 3, 4, 5].map((rank) => ({ rank, idea: idea(rank, rank % 2 ? "pro" : "con"), whyTop: "It reaches the widest impact." })), + nextSteps: ["Find a before/after ridership study."], + warningsAndTradeoffs: ["Do not overclaim the congestion effect."], +}); + +const factoids = () => ({ + factoids: [], + searchSuggestions: [`${MARK} search suggestion`], + warnings: [], + verificationStatus: "complete", +}); + +const ask = () => ({ + response: `${MARK} answer: weigh access against cost by naming who bears each.`, + followUpQuestions: ["What is the strongest alternative policy?"], + uncertainties: [], +}); + +const sparring = (user) => /^SPARRING ACTION: score/m.test(user) + ? { + action: "score", + scorecard: { + userScore: 78, + aiScore: 74, + categories: ["Logic", "Evidence", "Delivery", "Strategy"].map((name) => ({ name, userScore: 20, aiScore: 18, feedback: `${name} feedback.` })), + overallFeedback: "The user won a narrow edge by weighing the main mechanism.", + keyMoments: ["The rebuttal answered the strongest objection."], + improvementAreas: ["Compare the alternative policy explicitly."], + winner: "user", + verdict: `${MARK} verdict: the user had the edge.`, + }, + } + : { + action: "respond", + speech: { + content: `${MARK} opponent speech: the policy needs a stronger comparative case.`, + strategy: "Test the warrant, then weigh the alternative.", + targetedPoints: ["The comparative mechanism is underdeveloped."], + }, + }; + +const crossfire = (user) => { + if (user.startsWith("CROSSFIRE DEBRIEF")) { + return { + concessionsSecured: [], + controlledQuestions: [], + weakOrRepetitiveQuestions: [], + evasiveOrIncompleteAnswers: [], + missedFollowUps: [], + betterFollowUpQuestions: [], + strongestExchange: null, + }; + } + const type = /must be type "(question|answer)"/.exec(user)?.[1]; + if (!type) return null; + return { type, text: `${MARK} crossfire ${type}: what evidence links fewer cars to safer streets?` }; +}; + +// Order matters only where one marker could appear inside another prompt. +const routes = [ + ["research-decision", "You decide whether a debate coaching answer needs live web research", () => ({ shouldSearch: false, query: "" })], + ["research-queries", "generate 2-3 follow-up search queries", () => ({ queries: [] })], + ["generate", "Generate a tournament-ready debate case", (user) => generate(user)], + ["attack", "Analyze the opponent's case clearly", attack], + ["brainstorm", "Brainstorm arguments like experienced competitive debaters", brainstorm], + ["factoids", "You are a debate research specialist", factoids], + ["sparring", "live debate sparring opponent", (user) => sparring(user)], + ["crossfire", "You are DebateLab's crossfire coach", (user) => crossfire(user)], + ["ask", "You are a debate coach chat bot", ask], +]; + +function classify(body) { + const messages = Array.isArray(body.messages) ? body.messages : []; + const system = String(messages.find((message) => message.role === "system")?.content ?? ""); + const user = String(messages.at(-1)?.content ?? ""); + if (body.stream === true && system.includes("Answer in clear Markdown that can stream smoothly")) { + return { feature: "ask-stream", system, user }; + } + const route = routes.find(([, marker]) => system.includes(marker)); + if (!route) return { feature: null, system, user }; + return { feature: route[0], system, user, output: route[2](user) }; +} + +function json(res, status, value) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); +} + +function stream(res) { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + const chunks = [`${MARK} streamed answer: `, "weigh access ", "against cost."]; + for (const content of chunks) { + res.write(`data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`); + } + res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`); + res.write(`data: ${JSON.stringify({ choices: [], usage })}\n\n`); + res.end("data: [DONE]\n\n"); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://fake"); + if (url.pathname === "/__requests" && req.method === "GET") return json(res, 200, requests); + if (url.pathname === "/__reset" && req.method === "POST") { + requests.length = 0; + failures = []; + return json(res, 200, { ok: true }); + } + if (url.pathname === "/__fail" && req.method === "POST") { + const { feature, count = 1, status = 500 } = JSON.parse(await readBody(req) || "{}"); + failures.push({ feature, count, status }); + return json(res, 200, { ok: true }); + } + if (url.pathname === "/api/alpha/decisions") { + // Jev is optional: callers fall back to deterministic ranking and signals. + requests.push({ feature: "jev", path: url.pathname }); + return json(res, 503, { error: { message: "Jev is not faked" } }); + } + if (url.pathname !== "/api/v1/chat/completions" || req.method !== "POST") { + return json(res, 404, { error: { message: `Unexpected ${req.method} ${url.pathname}` } }); + } + + const body = JSON.parse(await readBody(req)); + const { feature, system, user, output } = classify(body); + requests.push({ feature, model: body.model, stream: body.stream === true, system, user }); + if (!feature) return json(res, 500, { error: { message: "Fake OpenRouter does not recognize this prompt" } }); + + const failure = failures.find((item) => item.count > 0 && (!item.feature || item.feature === feature)); + if (failure) { + failure.count -= 1; + return json(res, failure.status, { error: { message: `Fake ${feature} failure` } }); + } + if (feature === "ask-stream") return stream(res); + if (output === null) return json(res, 500, { error: { message: `Fake ${feature} could not parse the request` } }); + return json(res, 200, { + id: `fake-${requests.length}`, + model: body.model, + choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: JSON.stringify(output) } }], + usage, + }); +}).listen(port, "0.0.0.0", () => console.log(`fake OpenRouter listening on ${port}`)); diff --git a/e2e/full-stack/practice.spec.ts b/e2e/full-stack/practice.spec.ts new file mode 100644 index 000000000..35823cb72 --- /dev/null +++ b/e2e/full-stack/practice.spec.ts @@ -0,0 +1,50 @@ +import { admin, expect, MARK, providerCall, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +test("a Lincoln-Douglas sparring round is judged and saved", async ({ signedIn: page, user }) => { + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill("This House would make public transport free at the point of use."); + await page.getByRole("combobox", { name: "Round format" }).click(); + await page.getByRole("option", { name: /Lincoln-Douglas ยท 5 speeches/ }).click(); + await page.getByRole("button", { name: "Start round" }).click(); + + const speeches = [ + ["Affirmative constructive", "Universal access removes the direct price barrier to participation."], + ["First affirmative rebuttal", "Targeted discounts preserve eligibility gaps and administrative friction."], + ["Second affirmative rebuttal", "The universal policy covers every marginal trip, so it is more reliable."], + ] as const; + for (const [turn, content] of speeches) { + await page.getByRole("textbox", { name: `Draft for ${turn}` }).fill(content); + await page.getByRole("button", { name: "Submit speech" }).click(); + if (turn !== "Second affirmative rebuttal") { + // Opponent speeches carry a server-signed integrity token; the score + // request below is rejected if the client cannot round-trip them. + await expect(page.getByText(`${MARK} opponent speech`).last()).toBeVisible({ timeout: 30_000 }); + } + } + + await expect(page.getByText(`${MARK} verdict: the user had the edge.`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("sparring")).user).toContain("SPARRING ACTION:"); + + await expect.poll(async () => { + const { data } = await admin.from("sparring_sessions").select("status,final_evaluation").eq("user_id", await user.userId()); + return data?.map((row) => row.status); + }).toEqual(["complete"]); +}); + +test("Crossfire answers a question and produces a debrief", async ({ signedIn: page }) => { + await page.goto("/app/crossfire"); + await page.getByTestId("crossfire-resolution").fill("Resolved: cities should ban private cars downtown."); + await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets."); + await page.getByTestId("mode-question-ai").click(); + await page.getByTestId("start-crossfire").click(); + + await page.getByTestId("crossfire-input").fill("What evidence supports the safety mechanism?"); + await page.getByTestId("submit-turn").click(); + await expect(page.getByText(`${MARK} crossfire answer`)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("crossfire")).user).toContain("What evidence supports the safety mechanism?"); + + await page.getByTestId("end-crossfire").click(); + await expect(page.getByTestId("crossfire-debrief")).toBeVisible({ timeout: 30_000 }); +}); diff --git a/e2e/full-stack/public.spec.ts b/e2e/full-stack/public.spec.ts new file mode 100644 index 000000000..8194c65d1 --- /dev/null +++ b/e2e/full-stack/public.spec.ts @@ -0,0 +1,18 @@ +import { randomUUID } from "node:crypto"; +import { admin, expect, stackReady, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +test("a share link opens for a signed-out visitor", async ({ page }) => { + const code = randomUUID().slice(0, 8); + const title = `Shared case ${code}`; + await admin.from("shared_items").insert({ share_code: code, item_type: "case", title, item_data: { thesis: "Free transit is a net public good" } }); + try { + await page.goto(`/share/${code}`); + await expect(page.getByText(title)).toBeVisible(); + await page.goto("/share/does-not-exist"); + await expect(page.getByText("This shared content is no longer available")).toBeVisible(); + } finally { + await admin.from("shared_items").delete().eq("share_code", code); + } +}); diff --git a/e2e/full-stack/stack.ts b/e2e/full-stack/stack.ts new file mode 100644 index 000000000..33b94ce60 --- /dev/null +++ b/e2e/full-stack/stack.ts @@ -0,0 +1,123 @@ +import { createClient } from "@supabase/supabase-js"; +import { expect, test as base, type Page } from "@playwright/test"; +import { createHash, randomUUID } from "node:crypto"; + +// These specs drive the real local stack: Supabase Auth, Postgres/RLS, every +// Edge Function, and a fake OpenRouter (e2e/full-stack/fake-openrouter.mjs). +// Only the paid model provider is replaced. `.superset/db-gate.sh` provides the +// environment below; without it the specs skip. +const supabaseUrl = process.env.LOCAL_SUPABASE_URL; +const serviceKey = process.env.LOCAL_SUPABASE_SERVICE_ROLE_KEY; +const fakeUrl = process.env.FAKE_OPENROUTER_URL; +const demoPepper = process.env.DEMO_CODE_PEPPER; + +export const MARK = "FAKE-PROVIDER"; +export const stackReady = Boolean(supabaseUrl && serviceKey && fakeUrl && demoPepper); +// Only the locked gate loads these specs in CI; a skip there would read as a pass. +if (process.env.CI && !stackReady) throw new Error("Full-stack gate environment is incomplete"); + +export const admin = stackReady + ? createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }) + : (null as never); + +export type FakeRequest = { feature: string | null; model?: string; stream?: boolean; system: string; user: string }; + +export const fake = { + async reset() { + await fetch(`${fakeUrl}/__reset`, { method: "POST" }); + }, + async requests(): Promise { + return (await fetch(`${fakeUrl}/__requests`)).json(); + }, + async fail(feature: string, status = 500, count = 1) { + await fetch(`${fakeUrl}/__fail`, { method: "POST", body: JSON.stringify({ feature, status, count }) }); + }, +}; + +export type DemoUser = { code: string; email: string; userId: () => Promise }; + +async function userIdFor(email: string) { + // The local project holds only test users, so one page covers it. + const { data, error } = await admin.auth.admin.listUsers({ perPage: 1000 }); + if (error) throw error; + const user = data.users.find((candidate) => candidate.email === email); + if (!user) throw new Error(`No user for ${email}`); + return user.id; +} + +export async function createDemoUser(label: string): Promise { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const part = () => Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(""); + const code = `DEMO-${part()}-${part()}`; + const email = `${label}-${randomUUID()}@fullstack.test`; + const codeHash = createHash("sha256").update(`${demoPepper}:${code}`).digest("hex"); + const { data, error } = await admin.from("demo_access_codes") + .insert({ code_hash: codeHash, label, email, max_uses: 10 }) + .select("id") + .single(); + if (error) throw error; + return { code, email, codeId: data.id, userId: () => userIdFor(email) }; +} + +export async function deleteDemoUser(user: DemoUser & { codeId: string }) { + const { data } = await admin.auth.admin.listUsers({ perPage: 1000 }); + const id = data?.users.find((candidate) => candidate.email === user.email)?.id; + if (id) await admin.auth.admin.deleteUser(id); + await admin.from("demo_access_codes").delete().eq("id", user.codeId); +} + +/** Sign in through the real Login page and demo-code-login Edge Function. */ +export async function signIn(page: Page, user: DemoUser) { + await page.goto("/login"); + await page.getByRole("tab", { name: "Demo code" }).click(); + await page.getByRole("textbox", { name: "Demo code" }).fill(user.code); + await page.getByRole("button", { name: "Sign in with demo code" }).click(); + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); +} + +type Fixtures = { user: DemoUser; signedIn: Page }; + +export const test = base.extend({ + // Playwright requires a destructuring pattern even when a fixture has no dependencies. + // eslint-disable-next-line no-empty-pattern + user: async ({}, provide, testInfo) => { + const created = await createDemoUser(testInfo.project.name); + await provide(created); + await deleteDemoUser(created); + }, + page: async ({ page }, provide) => { + // Any uncaught exception fails the test, even when the visible flow passes. + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + // Init scripts rerun on every load, so seed only what the app has not written. + await page.addInitScript(() => { + const seed = (key: string, state: Record) => { + if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify({ state, version: 0 })); + }; + seed("debate-trainer-onboarding", { hasCompletedOnboarding: true, isTourRunning: false }); + seed("debate-trainer-settings", { language: "en", outputLanguage: "en", theme: "system" }); + }); + await fake.reset(); + await provide(page); + expect(errors, "uncaught page errors").toEqual([]); + // Overflow widens the mobile layout viewport and pushes controls off-screen. + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow, "horizontal page overflow in px").toBeLessThanOrEqual(1); + }, + signedIn: async ({ page, user }, provide) => { + await signIn(page, user); + await provide(page); + }, +}); + +export { expect }; + +/** Waits for one fake-provider call for a feature and returns it. */ +export async function providerCall(feature: string) { + let match: FakeRequest | undefined; + await expect.poll(async () => { + match = (await fake.requests()).find((request) => request.feature === feature); + return Boolean(match); + }, { message: `provider call for ${feature}` }).toBe(true); + return match!; +} diff --git a/package.json b/package.json index 4c8a8fd2c..29f651f63 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "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,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: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:webkit": "playwright test --project=webkit", diff --git a/playwright.config.ts b/playwright.config.ts index 49742af09..8f9e403d2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -43,17 +43,17 @@ export default defineConfig({ projects: [ { name: 'desktop-chromium', - testIgnore: /staging\//, + testIgnore: /(staging|full-stack)\//, use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, }, { name: 'mobile-chromium', - testIgnore: /staging\//, + testIgnore: /(staging|full-stack)\//, use: { ...devices['Pixel 5'] }, }, { name: 'webkit', - testIgnore: /staging\//, + testIgnore: /(staging|full-stack)\//, use: { ...devices['Desktop Safari'], viewport: { width: 1280, height: 800 } }, }, ...(stagingEnabled ? [{ diff --git a/playwright.workspace.config.ts b/playwright.workspace.config.ts index 3f1c69452..c7426d4a0 100644 --- a/playwright.workspace.config.ts +++ b/playwright.workspace.config.ts @@ -4,7 +4,7 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:18081"; export default defineConfig({ testDir: "./e2e", - testMatch: /workspace-collaboration\.spec\.ts/, + testMatch: /(workspace-collaboration|full-stack\/.+)\.spec\.ts$/, fullyParallel: false, workers: 1, reporter: "list", diff --git a/src/index.css b/src/index.css index b24ad6531..7e1e0ce6c 100644 --- a/src/index.css +++ b/src/index.css @@ -628,6 +628,18 @@ input:focus, textarea:focus, select:focus { transition: width 0.5s var(--motion-ease-fluid); } +/* Sonner's mobile toaster sets width: 100% plus a left offset, which pushes + it past the viewport and widens the whole page on phones. */ +@media (max-width: 600px) { + [data-sonner-toaster] { + width: auto !important; + } + + [data-sonner-toaster] [data-sonner-toast] { + width: 100% !important; + } +} + /* Toast slide-in */ [data-sonner-toaster] [data-sonner-toast] { animation: toast-slide-in 0.3s var(--motion-ease-fluid) both !important; diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 36182e0b2..f265c0136 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -169,7 +169,9 @@ async function getUserId() { return data.user?.id || null; } -async function upsertSettings(payload: { +// The auth trigger provisions every settings row, and clients may only UPDATE +// the user-editable columns. An upsert needs INSERT and is always denied. +async function updateSettings(payload: { language: string; output_language: string; theme: string; @@ -178,11 +180,10 @@ async function upsertSettings(payload: { }) { const userId = await getUserId(); if (!userId) return; - const { error } = await supabase.from('user_settings').upsert({ - user_id: userId, + const { error } = await supabase.from('user_settings').update({ ...payload, updated_at: new Date().toISOString(), - }, { onConflict: 'user_id' }); + }).eq('user_id', userId); if (error) { console.warn('[SettingsStore] Failed to persist settings:', error.message); } @@ -213,7 +214,7 @@ export const useSettingsStore = create()( setLanguage: (language) => { set({ language }); void applyAppLanguage(language); - void upsertSettings({ + void updateSettings({ language, output_language: get().outputLanguage, theme: get().theme, @@ -223,7 +224,7 @@ export const useSettingsStore = create()( }, setOutputLanguage: (outputLanguage) => { set({ outputLanguage }); - void upsertSettings({ + void updateSettings({ language: get().language, output_language: outputLanguage, theme: get().theme, @@ -233,7 +234,7 @@ export const useSettingsStore = create()( }, setTheme: (theme) => { set({ theme }); - void upsertSettings({ + void updateSettings({ language: get().language, output_language: get().outputLanguage, theme, @@ -243,7 +244,7 @@ export const useSettingsStore = create()( }, setSystemPrompt: (prompt) => { set({ customSystemPrompt: prompt }); - void upsertSettings({ + void updateSettings({ language: get().language, output_language: get().outputLanguage, theme: get().theme, @@ -265,7 +266,7 @@ export const useSettingsStore = create()( ), }, })); - void upsertSettings({ + void updateSettings({ language: get().language, output_language: get().outputLanguage, theme: get().theme, diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx index bfa1ce253..b498113a6 100644 --- a/src/pages/FlowchartPage.tsx +++ b/src/pages/FlowchartPage.tsx @@ -423,7 +423,7 @@ export default function FlowchartPage() { -
+
@@ -432,7 +432,7 @@ export default function FlowchartPage() { {t('flowchart.title')}
-
+
{/* Load Dialog */} diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 24cc34ec9..b06ef704d 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -709,7 +709,7 @@ export default function LibraryPage() { )} -
+
diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index cab401d5a..6346e867c 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -78,8 +78,9 @@ describe('security hardening regressions', () => { expect(stream).toContain('const OPENROUTER_PROVIDER = "openrouter"'); expect(entitlements).not.toContain('api.anthropic.com'); expect(entitlements).not.toContain('api.openai.com'); - expect(ai).toContain('https://openrouter.ai/api/v1/chat/completions'); - expect(stream).toContain('https://openrouter.ai/api/v1/chat/completions'); + expect(ai).toContain('`${OPENROUTER_API_URL}/v1/chat/completions`'); + expect(stream).toContain('`${OPENROUTER_API_URL}/v1/chat/completions`'); + expect(read('supabase/functions/_shared/research.ts')).toContain('Deno.env.get("OPENROUTER_API_URL") || "https://openrouter.ai/api"'); expect(ai).not.toContain('api.anthropic.com'); expect(ai).not.toContain('api.openai.com'); expect(stream).not.toContain('api.anthropic.com'); diff --git a/src/test/stores/settings-store.test.ts b/src/test/stores/settings-store.test.ts index 983cc0555..3931ee54c 100644 --- a/src/test/stores/settings-store.test.ts +++ b/src/test/stores/settings-store.test.ts @@ -36,7 +36,7 @@ describe("settings-store", () => { expect(supabaseMock.from).toHaveBeenCalledWith("user_settings"); }); - it("setSystemPrompt updates local state and upserts settings", async () => { + it("setSystemPrompt updates local state and persists settings", async () => { const store = useSettingsStore.getState(); store.setSystemPrompt("Custom system"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -100,18 +100,17 @@ describe("settings-store", () => { store.setTheme("dark"); await new Promise((resolve) => setTimeout(resolve, 0)); - const settingsQuery = supabaseMock.from.mock.results.find(({ value }) => value.upsert)?.value; - expect(settingsQuery.upsert).toHaveBeenCalledWith( - expect.any(Object), - { onConflict: "user_id" }, - ); - expect(settingsQuery.upsert.mock.calls[0][0]).not.toHaveProperty("wiki_research_enabled"); + const settingsQuery = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value; + expect(settingsQuery.update).toHaveBeenCalledWith(expect.objectContaining({ theme: "dark" })); + expect(settingsQuery.update.mock.results[0].value.eq).toHaveBeenCalledWith("user_id", "user_1"); + expect(settingsQuery.upsert).not.toHaveBeenCalled(); + expect(settingsQuery.update.mock.calls[0][0]).not.toHaveProperty("wiki_research_enabled"); }); it("reports settings persistence failures", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const upsert = vi.fn(async () => ({ data: null, error: { message: "database unavailable" } })); - supabaseMock.from.mockImplementationOnce((() => ({ upsert })) as never); + const eq = vi.fn(async () => ({ data: null, error: { message: "database unavailable" } })); + supabaseMock.from.mockImplementationOnce((() => ({ update: () => ({ eq }) })) as never); useSettingsStore.getState().setTheme("dark"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -388,7 +387,7 @@ describe("settings-store", () => { expect(settingsWrites).toHaveLength(0); }); - it("does not upsert when user is not signed in", async () => { + it("does not write settings when user is not signed in", async () => { setMockAuthUser(null); const store = useSettingsStore.getState(); store.setTheme("dark"); @@ -403,7 +402,7 @@ describe("settings-store", () => { await new Promise((resolve) => setTimeout(resolve, 0)); const configWrite = supabaseMock.from.mock.results - .map(({ value }) => value.upsert?.mock.calls?.at(-1)?.[0]) + .map(({ value }) => value.update?.mock.calls?.at(-1)?.[0]) .filter(Boolean) .at(-1); expect(configWrite.custom_system_prompt).toBe("You are a debate coach."); @@ -412,7 +411,7 @@ describe("settings-store", () => { await new Promise((resolve) => setTimeout(resolve, 0)); const clearWrite = supabaseMock.from.mock.results - .map(({ value }) => value.upsert?.mock.calls?.at(-1)?.[0]) + .map(({ value }) => value.update?.mock.calls?.at(-1)?.[0]) .filter(Boolean) .at(-1); expect(clearWrite.custom_system_prompt).toBeNull(); diff --git a/supabase/README.md b/supabase/README.md index 19607797f..00e4f597a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -133,7 +133,7 @@ Run focused tests while changing one function, then run the complete Edge Functi Document names, never values. Depending on deployed features, server-side configuration includes categories such as: - Supabase URL, anon key, and service-role key; -- `OPENROUTER_API_KEY` for all model requests; +- `OPENROUTER_API_KEY` for all model requests, plus `OPENROUTER_API_URL`, which only the local full-stack gate sets to reach its fake provider (leave it unset everywhere else); - research provider keys; - Stripe secret, webhook secret, and price configuration; - demo-code hashing material; diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index 67a1b27ea..5ac9b4caa 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -13,6 +13,7 @@ export const ALLOWED_ORIGINS = [ "http://127.0.0.1:8080", "http://localhost:18080", "http://127.0.0.1:18080", + "http://127.0.0.1:18081", // Local full-stack browser gate "http://localhost:5173", "http://127.0.0.1:54321", // Local Supabase "capacitor://localhost", // iOS Capacitor WebView diff --git a/supabase/functions/_shared/jev.ts b/supabase/functions/_shared/jev.ts index 431ad6023..915956adf 100644 --- a/supabase/functions/_shared/jev.ts +++ b/supabase/functions/_shared/jev.ts @@ -7,11 +7,12 @@ import { MAX_PROVIDER_ERROR_BODY_BYTES, MAX_PROVIDER_ERROR_BODY_CHARS, MAX_PROVIDER_RESPONSE_BYTES, + OPENROUTER_API_URL, readResponseText, } from "./research.ts"; export const JEV_MODEL = "typesafe/jev-1.13"; -export const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions"; +export const JEV_DECISIONS_URL = `${OPENROUTER_API_URL}/alpha/decisions`; const JEV_TIMEOUT_MS = 8_000; type JsonPrimitive = string | number | boolean | null; diff --git a/supabase/functions/_shared/research.ts b/supabase/functions/_shared/research.ts index 9f21f9f24..86a68b29d 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -21,6 +21,8 @@ import type { ReasoningEffort } from "./model-catalog.ts"; export type Provider = "openrouter"; +// Local browser gates point this at a fake provider; production leaves it unset. +export const OPENROUTER_API_URL = Deno.env.get("OPENROUTER_API_URL") || "https://openrouter.ai/api"; export const MAX_PROVIDER_RESPONSE_BYTES = 1_048_576; export const MAX_PROVIDER_ERROR_BODY_BYTES = 65_536; export const MAX_PROVIDER_ERROR_BODY_CHARS = 16_000; diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index e9ed522be..f885defc5 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -12,6 +12,7 @@ import { } from "../_shared/cost.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { checkRateLimit, logUsage } from "../_shared/entitlements.ts"; +import { OPENROUTER_API_URL } from "../_shared/research.ts"; import { AUTO_PRO, DEFAULT_MODEL, @@ -242,7 +243,7 @@ export async function handleRequest(req: Request): Promise { }; if (modelEntry.supportsTemperature) payload.temperature = temperature; - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + const response = await fetch(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${providerKey}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index ecd3ef819..bb888fb9c 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -108,6 +108,7 @@ import { decideAskSearch, exaGetContents, fetchJsonWithTimeout, + OPENROUTER_API_URL, getResearchSources, verifyUrl, withResearchPreferences, @@ -757,7 +758,7 @@ async function callOpenRouter( }; if (modelEntry.supportsTemperature) payload.temperature = temperature; - const { response, data, errorBody } = await fetchJsonWithTimeout("https://openrouter.ai/api/v1/chat/completions", { + const { response, data, errorBody } = await fetchJsonWithTimeout(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, body: JSON.stringify(payload), diff --git a/supabase/migrations/20260923190000_user_settings_output_language_grant.sql b/supabase/migrations/20260923190000_user_settings_output_language_grant.sql new file mode 100644 index 000000000..e849acaa6 --- /dev/null +++ b/supabase/migrations/20260923190000_user_settings_output_language_grant.sql @@ -0,0 +1,3 @@ +-- The 20260819 hardening re-granted client-editable settings columns but left +-- out output_language, so saving the AI output language was always denied. +GRANT UPDATE (output_language) ON public.user_settings TO authenticated; diff --git a/supabase/tests/user_settings_privileges_test.sql b/supabase/tests/user_settings_privileges_test.sql new file mode 100644 index 000000000..6265e2325 --- /dev/null +++ b/supabase/tests/user_settings_privileges_test.sql @@ -0,0 +1,32 @@ +BEGIN; +SELECT plan(5); + +SELECT ok( + has_column_privilege('authenticated', 'public.user_settings', 'output_language', 'UPDATE'), + 'users can save their AI output language' +); +SELECT ok( + has_column_privilege('authenticated', 'public.user_settings', 'language', 'UPDATE') + AND has_column_privilege('authenticated', 'public.user_settings', 'theme', 'UPDATE') + AND has_column_privilege('authenticated', 'public.user_settings', 'feature_configs', 'UPDATE') + AND has_column_privilege('authenticated', 'public.user_settings', 'custom_system_prompt', 'UPDATE'), + 'users can save their other preferences' +); +SELECT ok( + NOT has_column_privilege('authenticated', 'public.user_settings', 'subscription_tier', 'UPDATE') + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'daily_limit_override', 'UPDATE') + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'grace_period_ends_at', 'UPDATE') + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'trial_ends_at', 'UPDATE'), + 'users cannot change entitlement columns' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.user_settings', 'INSERT'), + 'users cannot insert settings rows' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.user_settings', 'DELETE'), + 'users cannot delete settings rows' +); + +SELECT * FROM finish(); +ROLLBACK;