Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions .superset/db-gate.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=()
Expand All @@ -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"
Expand Down Expand Up @@ -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)"
Expand All @@ -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+=("$!")

Expand All @@ -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" \
Expand All @@ -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
6 changes: 5 additions & 1 deletion .superset/workspace-test.env
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions e2e/full-stack/account-data.spec.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
39 changes: 39 additions & 0 deletions e2e/full-stack/admin.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
61 changes: 61 additions & 0 deletions e2e/full-stack/ai-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -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?");
});
Loading
Loading