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
20 changes: 10 additions & 10 deletions .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ $H down # stop everything `up` started; evidence stays

## Safety rules, read first

- **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to `http://127.0.0.1:54321`.
- **Plain `npm run dev` talks to the PRODUCTION Supabase project** (`src/lib/supabase/env.ts` falls back to production). Never sign in or submit forms on a dev server you started yourself. Drive only the `up` instance. It sets `VITE_LOCAL_PREVIEW=true`, and `doctor` proves the client resolved to its allocated local API URL (`54321` or `54421`).
- The Docker container `debatelab` on `127.0.0.1:8080` is Adam's deployed app. Never drive it, restart it, or point tests at it.
- The local Supabase stack uses fixed ports (54321+) and shared container names, so only one worktree can run it at a time. `up` takes `~/.superset/debatelab-db.lock` (the lock `.superset/db-gate.sh` uses) and refuses to start if the lock is held, if any `supabase_*` container is running, or if port 18081 or 18090 is busy. Don't work around a refusal. Another worktree owns the stack.
- Local verification shares two slots with `.superset/db-gate.sh`. `up` takes the first free slot or waits up to 600 seconds for either. Slot 1 keeps the default ports and `~/.superset/debatelab-db.lock`; slot 2 adds 100 to ports and uses `~/.superset/debatelab-db.slot2.lock`. Set `DEBATELAB_STACK_SLOTS=1` to restrict allocation to slot 1. The port and subnet table is `.superset/lib/slot.sh`. It reserves `10.254.231.0/24` and `10.254.232.0/24` to avoid Docker allocating a subnet that overlaps the Pi's Tailscale route. An existing project network with another subnet is refused; stop its owning stack first. Containers and networks are named per worktree; the provider name is per slot. `up` refuses a stack already running for this worktree or busy slot ports. Never bypass an ownership check. Plain lock files, dead owners, and directories without a PID older than 120 seconds are reclaimed. Python 3 standard-library file locks serialize recovery and PID publication. Persistent `.guard` files are not slot claims; leave them in place so every caller locks the same inode.
- Recipes abort any request to `*.supabase.co` and fail the test if one is attempted.
- Never print values from `.verify/state/` (`drive.env`, `function.env`). They are local test keys, but treat them as secrets.

Expand All @@ -50,11 +50,11 @@ It prints nothing about the values. Ask Adam before the first live run: it spend

1. `supabase start`, which replays `supabase/migrations/`.
2. Writes the function env from `.superset/workspace-test.env`.
3. Starts the fake provider container `debatelab-fake-openrouter` (`127.0.0.1:18090`) on the stack network.
3. Starts the slot's fake provider container (`debatelab-fake-openrouter` on `18090`, or `debatelab-fake-openrouter-s2` on `18190`) on the stack network.
4. `supabase functions serve --no-verify-jwt`.
5. Starts Vite on `http://127.0.0.1:18081` in local-preview mode.
5. Starts Vite on the allocated app origin (`http://127.0.0.1:18081` or `http://127.0.0.1:18181`) in local-preview mode.

It is ready when it prints `READY http://127.0.0.1:18081`. Port 18081 is fixed: the Edge Function CORS list (`supabase/functions/_shared/cors.ts`) allows it for the local full-stack gate, and it stays clear of the dev server (8080) and the credential-free browser run (18080), which the list also allows.
It is ready when it prints `READY <app-origin>`. Use that URL for interactive driving. The chosen slot is saved in `.verify/state/stack.env` and restored by later commands. The generated `function.env` lists that app origin in `LOCAL_GATE_APP_ORIGINS`; CORS accepts this override only for dev origins. Production does not set it.

Vite serves the working tree live, so uncommitted edits to `src/` apply on reload. Changes to `supabase/functions/` are picked up by `functions serve`. A new migration needs `down` then `up`.

Expand All @@ -65,7 +65,7 @@ If `up` fails, it tears itself down and prints the log directory (`.verify/logs/
Run `$H doctor` first whenever anything looks off. It exits 0 only when all of these hold:

- The stack lock is held by this run's holder process.
- Port 18081 is served by this run's Vite process group.
- The allocated app port is served by this run's Vite process group.
- Vite's served `src/lib/supabase/env.ts` has `VITE_LOCAL_PREVIEW: "true"` and the local API URL.
- The Edge Functions process is alive, and `demo-code-login` answers CORS for the app origin.
- The fake provider answers.
Expand Down Expand Up @@ -106,7 +106,7 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`,

To prove a feature without a recipe, copy `recipes/case-generation.spec.ts` to `recipes/<feature>.spec.ts` and use the selectors from the matching file in `features/`. Recipes are maintained files. Keep the ones worth keeping.

**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts/<run-id>/`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you.
**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at the printed app origin plus `/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts/<run-id>/`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you.

Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate debate case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates.

Expand Down Expand Up @@ -158,12 +158,12 @@ For those, see `docs/testing.md` (the live-provider gate and the production cana

1. Deletes users minted by `user`.
2. Kills only the process groups it recorded (functions, Vite, collaboration).
3. Removes `debatelab-fake-openrouter`.
3. Removes only the recorded slot's provider container.
4. Runs `supabase stop --no-backup` (the local DB is wiped).
5. Kills the lock holder and releases the lock if it is still ours.
6. Deletes `.verify/state/`.

It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run twice. Run it after every session, including failed ones. Never kill processes by name, and never run `supabase stop` for a stack you didn't start. After `down`, `docker ps` should show no `supabase_*` and no `debatelab-fake-openrouter` containers, and `~/.superset/debatelab-db.lock` should be gone.
It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run twice. Run it after every session, including failed ones. Never kill processes by name, and never run `supabase stop` for a stack you didn't start. After `down`, this worktree's Supabase containers, its provider, and its slot lock should be gone. Another worktree may still own the other slot; leave it running.

`.verify/` is gitignored. Delete old artifact directories by hand when they are no longer needed.

Expand All @@ -183,4 +183,4 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run
- The client files a result in its debate after it renders. A `page.goto` straight after the `ai` response can drop it; wait for the result on screen (and its row, if the next step reloads) first.
- In a voice round, wait for the opponent's reply to finish (`spokenByBrowser`) before clicking `voice-mic` again; speaking over it abandons the voice request. `recipes/voice-rounds.spec.ts` shows the wait.
- Account deletion is irreversible. `recipes/account-data.spec.ts` deletes only its own `@fullstack.test` demo user and checks that before clicking.
- `.superset/db-gate.sh` and this skill share the lock. While your stack is up, the gate in any worktree waits (for up to 600 s) and then fails. Don't leave the stack up.
- `.superset/db-gate.sh` and this skill share two slots. An idle verification stack still occupies one; always run `down` when finished. The full gate's browser phase and `npm run verify` builds also share a local heavy-job lock; CI skips it.
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/bin/demo-user.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ if (command === "create") {
.single();
if (error) throw error;
await appendFile(usersFile, `${data.id}\t${email}\n`);
console.log(`DEMO_CODE=${code}\nEMAIL=${email}\nSign in at http://127.0.0.1:18081/login -> "Demo code" tab.`);
console.log(`DEMO_CODE=${code}\nEMAIL=${email}\nSign in at ${process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18081"}/login -> "Demo code" tab.`);
} else if (command === "delete-all") {
const lines = (await readFile(args[0], "utf8")).split("\n").filter(Boolean);
const { data } = await admin.auth.admin.listUsers({ perPage: 1000 });
Expand Down
38 changes: 21 additions & 17 deletions .claude/skills/verify-debatelab/bin/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,10 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(git -C "$SKILL_DIR" rev-parse --show-toplevel)"
cd "$REPO"
source "$REPO/.superset/lib/lock.sh"
source "$REPO/.superset/lib/slot.sh"
source "$REPO/.superset/lib/supabase-cli.sh"
use_pinned_supabase_cli "$REPO"

DB_LOCK="$HOME/.superset/debatelab-db.lock"
FAKE_PROVIDER=debatelab-fake-openrouter
APP_PORT=18081 # the only local client origin the Edge Function CORS list and invite fixture allow
FAKE_PORT=18090
COLLAB_PORT=12345
APP_ORIGIN="http://127.0.0.1:$APP_PORT"
VERIFY_ROOT="$REPO/.verify"
STATE_DIR="$VERIFY_ROOT/state" # secrets and pids; removed by down
STATE="$STATE_DIR/stack.env"
Expand All @@ -50,7 +45,7 @@ LIVE_KEYS_FILE="${VERIFY_LIVE_KEYS_FILE:-$HOME/.config/debatelab/live-verify.env
say() { printf '%s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; exit 1; }
record() { printf '%s=%q\n' "$1" "$2" >>"$STATE"; }
load_state() { [ -f "$STATE" ] || fail "no running verification stack (state file missing); run: verify.sh up"; source "$STATE"; }
load_state() { [ -f "$STATE" ] || fail "no running verification stack (state file missing); run: verify.sh up"; source "$STATE"; stack_slot_configure "${DEBATELAB_STACK_SLOT:-1}"; }
alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
port_busy() { ss -ltnH "sport = :$1" 2>/dev/null | grep -q .; }
wait_url() {
Expand Down Expand Up @@ -96,13 +91,12 @@ cmd_up() {
fi
fi

# The stack uses the CLI's default ports and shared container names, so only
# one worktree may run it. Refuse instead of waiting or double-driving.
lock_acquire "$DB_LOCK" 0 || fail "the local Supabase stack lock is held by pid $(cat "$DB_LOCK/pid" 2>/dev/null || echo '?'); another worktree owns it"
if docker ps --format '{{.Names}}' | grep -q '^supabase_'; then
lock_release "$DB_LOCK"; fail "Supabase containers are already running (not started by this run)"
stack_slot_acquire 600 || fail "no local Supabase slot available"
# Other projects may run in the other slot; never adopt this project's stack.
if docker ps --format '{{.Names}}' | grep -qxF "supabase_db_$PROJECT_ID"; then
lock_release "$DB_LOCK"; fail "Supabase containers for this worktree are already running"
fi
for port in 54321 "$APP_PORT" "$FAKE_PORT"; do
for port in "$SUPABASE_API_PORT" "$SUPABASE_DB_PORT" "$APP_PORT" "$FAKE_PORT" "$COLLAB_PORT"; do
if port_busy "$port"; then lock_release "$DB_LOCK"; fail "port $port is already in use"; fi
done

Expand All @@ -112,6 +106,7 @@ cmd_up() {
local log_dir="$VERIFY_ROOT/logs/$run_id"
mkdir -p "$log_dir"
: >"$STATE"; : >"$USERS"
record DEBATELAB_STACK_SLOT "$DEBATELAB_STACK_SLOT"
record PROJECT_ID "$PROJECT_ID"
record LOG_DIR "$log_dir"
record HEAD "$(git rev-parse HEAD)"
Expand All @@ -129,15 +124,22 @@ cmd_up() {
trap 'say "up failed; tearing down (logs: $log_dir)"; cmd_down || true' ERR

say "Starting local Supabase (logs: $log_dir)..."
stack_network_prepare "$REPO" || {
local network_status=$?
trap - ERR
say "up failed preparing the local network; tearing down (logs: $log_dir)"
cmd_down
return "$network_status"
}
record STACK_STARTED true
supabase start >>"$log_dir/supabase.log" 2>&1
supabase start -x "$SUPABASE_START_EXCLUDE" >>"$log_dir/supabase.log" 2>&1

local status_json; status_json="$(supabase status -o json 2>/dev/null)"
sv() { node -e 'const v=JSON.parse(process.argv[1])[process.argv[2]]; if (!v) process.exit(1); process.stdout.write(String(v))' "$status_json" "$1"; }
local api_url anon_key service_key db_url mailer_url mailer_host
api_url="$(sv API_URL)"; anon_key="$(sv ANON_KEY)"; service_key="$(sv SERVICE_ROLE_KEY)"
db_url="$(sv DB_URL)"; mailer_url="$(sv MAILPIT_URL)"
mailer_host="$(docker ps --filter "name=supabase_inbucket_$PROJECT_ID" --format '{{.Names}}')"
mailer_host="$(docker ps --filter "name=^supabase_inbucket_${PROJECT_ID}$" --format '{{.Names}}')"
[ -n "$mailer_host" ] || { say "Local Mailpit container is unavailable"; false; }

LOCAL_SERVICE_ROLE_KEY="$service_key" LOCAL_MAILER_HOST="$mailer_host" \
Expand All @@ -164,6 +166,8 @@ cmd_up() {
printf 'FAKE_OPENROUTER_URL=%s\n' "http://127.0.0.1:$FAKE_PORT"
printf 'WORKSPACE_TEST_MAILER_URL=%s\n' "$mailer_url"
printf 'DEMO_CODE_PEPPER=%s\n' "$(sed -n 's/^DEMO_CODE_PEPPER=//p' .superset/workspace-test.env)"
printf 'COLLABORATION_PORT=%s\n' "$COLLAB_PORT"
printf 'SUPABASE_DB_URL=%s\n' "$db_url"
printf 'PLAYWRIGHT_BASE_URL=%s\n' "$APP_ORIGIN"
printf 'VERIFY_MODE=%s\n' "$([ "$live" = true ] && echo live || echo fake)"
printf 'LIVE_MODEL=%s\n' "$([ "$live" = true ] && echo "$LIVE_MODEL")"
Expand Down Expand Up @@ -330,7 +334,7 @@ cmd_e2e() {

cmd_down() {
if [ ! -f "$STATE" ]; then say "nothing recorded in $STATE; nothing to stop"; return 0; fi
source "$STATE"
load_state
if [ -s "$USERS" ] && [ -f "$STATE_DIR/drive.env" ]; then
( set -a; source "$STATE_DIR/drive.env"; set +a; node "$SKILL_DIR/bin/demo-user.mjs" delete-all "$USERS" ) || say "warn: could not delete minted demo users (stack is about to be wiped anyway)"
fi
Expand All @@ -348,7 +352,7 @@ cmd_down() {
supabase stop --no-backup >>"${LOG_DIR:-/dev/null}/supabase.log" 2>&1 || say "warn: supabase stop failed; see ${LOG_DIR:-}/supabase.log"
fi
if [ -n "${HOLDER_PID:-}" ]; then
if [ "$(cat "$DB_LOCK/pid" 2>/dev/null)" = "$HOLDER_PID" ]; then lock_release "$DB_LOCK"; fi
if [ "$(cat "$DB_LOCK/pid" 2>/dev/null)" = "$HOLDER_PID" ]; then lock_release "$DB_LOCK" "$HOLDER_PID"; fi
kill "$HOLDER_PID" 2>/dev/null || true
fi
rm -rf -- "$STATE_DIR"
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ This directory is the maintained source for verifying DebateLab's user-facing be

## Baseline preconditions

- `bin/verify.sh up` has printed `READY http://127.0.0.1:18081`, and `bin/verify.sh doctor` exits 0.
- The browser client is the `up` instance at `http://127.0.0.1:18081`. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production.
- `bin/verify.sh up` has printed `READY <app-origin>`, and `bin/verify.sh doctor` exits 0.
- The browser client is the `up` instance at the printed app origin. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production.
- Each scripted test signs in with its own fresh demo-code user (a Pro account with a daily limit of 999) and deletes it afterwards. The fixture marks the onboarding tour as seen, and the user starts with no Current Debate.
- Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. The Library's `Add source` stays disabled until a debate exists.
- In fake mode, model answers come from the fake provider and contain the marker `FAKE-PROVIDER`. In live mode (`up --live`), every chat call goes to real OpenRouter pinned to `anthropic/claude-haiku-5.5` on the Anthropic BYOK key, research uses real Exa, and answers are real model text.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Preconditions:
- **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`.
- **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`.
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_<sha224(email + code)>&type=magiclink&redirect_to=<redirect_to of the /auth/v1/otp request>`. The `redirect_to` is `http://127.0.0.1:18081/auth/callback?sb_flow_id=<32 hex>`. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend.
- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_<sha224(email + code)>&type=magiclink&redirect_to=<redirect_to of the /auth/v1/otp request>`. The `redirect_to` is `<app-origin>/auth/callback?sb_flow_id=<32 hex>`, using this slot's app origin. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend.
- **Resend countdown.** After each send, `Resend code` is disabled and named `Resend in 0:NN` for a minute (or as long as a 429 asked). The deadline is in sessionStorage under `debatelab:otp-resend-until:<email>`; set it to `0` to end the wait, and the button turns back within a second (`expireResendCooldown` in `recipes/auth.spec.ts`). Sending again to the same address before then opens the code step without a request.
- **Switch account.** After `Sign out`, sign the second account in on the same `/login` page. `page.goto("/login")` drops the history state that the bug depended on. Create the second user with `createDemoUser` and delete it in `finally`.
- **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it.
Expand Down
Loading
Loading