diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 00000000..d85ee659
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,224 @@
+# End-to-end Playwright suite (auth, chat, projects, tabular reviews, workflows).
+#
+# This workflow is what makes the e2e suite a MERGE GATE: it runs on every pull
+# request into a protected branch, boots a full local stack (Supabase + backend
+# API + Next.js web + MinIO object storage), runs Playwright, and fails the check
+# when any spec fails. To have a red run actually BLOCK a merge you must also mark
+# this job "e2e / playwright" as a required status check in branch protection —
+# see docs/e2e-ci.md ("Make it merge-blocking").
+#
+# The suite is green with NO secret: the 4 LLM-dependent specs (chat + critical
+# path) skip themselves when ANTHROPIC_API_KEY is absent (see e2e/llm.ts), so a
+# keyless run passes the other ~23 specs. Set the ANTHROPIC_API_KEY secret under
+# Settings > Secrets and variables > Actions to also run and enforce those 4.
+# See docs/e2e-ci.md for the full picture.
+#
+# The e2e user (e2e@mike.local) is bootstrapped in-job by e2e/auth.setup.ts against
+# the local Supabase admin API, so no E2E_PASSWORD secret is needed — the defaults
+# baked into auth.setup.ts are the single source of truth.
+name: e2e
+
+on:
+ workflow_dispatch:
+ pull_request:
+ # `main` is the destination upstream; `upstream-main` is the fork's mirror of
+ # it, so the suite is exercised on the fork PR and stays correct once merged.
+ branches: [main, upstream-main]
+
+# Don't pile up runs on rapid pushes to the same PR.
+concurrency:
+ group: e2e-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ playwright:
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+ env:
+ # Defaults match e2e/auth.setup.ts; overriding the password here would break
+ # the valid-login specs, so only the base URL is pinned.
+ PLAYWRIGHT_BASE_URL: http://localhost:3000
+ # Exposed to the Playwright process (not just backend/.env) so the LLM-gated
+ # specs can skip themselves when no key is present — see e2e/llm.ts. When the
+ # secret is set they run and must pass; when absent they skip, so a keyless
+ # run (e.g. a fork PR) is still green on the other ~20 specs.
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ # Three installs: the root package (Playwright + the e2e tooling), the
+ # backend API, and the Next.js web app. Each carries its own lockfile in
+ # this repo's backend/ + frontend/ layout (no root workspace).
+ - name: Install root (Playwright) deps
+ run: npm ci
+ - name: Install backend deps
+ run: npm ci --prefix backend
+ - name: Install frontend deps
+ run: npm ci --prefix frontend
+
+ # npm ci intermittently skips lightningcss's Linux native optional dep
+ # (npm/cli#4828); without it `next dev` can't compile Tailwind CSS and the
+ # web server never boots, so wait-on times out and every spec fails.
+ - name: Ensure lightningcss native binary
+ run: npm install --no-save lightningcss --prefix frontend
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+
+ # Object storage. Three specs upload a document (tabular-review row,
+ # project-folder fixture) and assert the result renders; those uploads go
+ # through backend/src/lib/storage.ts, an S3-compatible client that only
+ # ENABLES when R2_ENDPOINT_URL + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY are
+ # set (region "auto", forcePathStyle true — MinIO-compatible as written).
+ # With no storage the upload endpoint 5xxs and the row never appears. A
+ # `docker run` (not a `services:` container) is used because the minio image
+ # needs the `server /data` argument the services block can't supply, and we
+ # must create the bucket after the server is healthy.
+ - name: Start MinIO (object storage)
+ run: |
+ docker run -d --name minio -p 9000:9000 \
+ -e MINIO_ROOT_USER=minioadmin \
+ -e MINIO_ROOT_PASSWORD=minioadmin \
+ minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:9000/minio/health/ready >/dev/null; then
+ echo "MinIO ready"; break
+ fi
+ echo "waiting for minio ($i)…"; sleep 2
+ done
+ AWS_ACCESS_KEY_ID=minioadmin \
+ AWS_SECRET_ACCESS_KEY=minioadmin \
+ AWS_DEFAULT_REGION=us-east-1 \
+ aws --endpoint-url http://localhost:9000 s3 mb s3://mike
+ AWS_ACCESS_KEY_ID=minioadmin \
+ AWS_SECRET_ACCESS_KEY=minioadmin \
+ AWS_DEFAULT_REGION=us-east-1 \
+ aws --endpoint-url http://localhost:9000 s3 ls s3://mike
+
+ # Supabase (Auth + Postgres). This layout ships no supabase/ config dir
+ # (the app targets a hosted Supabase project), so we scaffold one with
+ # `supabase init` purely to boot the CLI's local stack, then load the
+ # repository's own fresh-database schema — exactly what the README tells a
+ # human to run on a new database (backend/schema.sql).
+ - name: Start local Supabase
+ uses: supabase/setup-cli@v1
+ with:
+ version: latest
+ - name: Init + start Supabase, load schema + migrations
+ run: |
+ supabase init --force --with-vscode-settings=false || supabase init --force
+ supabase start
+ DB_URL=$(supabase status -o json | jq -r '.DB_URL')
+ # 1) Base schema. README: "For a new Supabase database ... run
+ # backend/schema.sql". It is meant to be the latest shape but in practice
+ # lags the migrations (e.g. it is missing workflow_open_source_submissions,
+ # which GET /workflows/:id queries — a 500 that breaks the workflow specs).
+ psql "$DB_URL" -v ON_ERROR_STOP=1 -f backend/schema.sql
+ # 2) Apply every dated migration on top. They are written to be safe to
+ # re-run (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS), so the ones already
+ # in schema.sql are no-ops and the newer ones fill the gaps. Verified on a
+ # fresh CLI DB: all migrations apply with zero errors. Kept fail-tolerant
+ # so one non-idempotent migration can't wedge the whole gate — mismatches
+ # surface as a spec failure downstream, not an opaque CI abort.
+ for m in $(ls backend/migrations/*.sql | sort); do
+ psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$m" >/dev/null 2>&1 \
+ || echo "::warning::migration returned a non-zero status (already applied?): $m"
+ done
+ # 3) schema.sql now grants service_role its narrowed data privileges
+ # itself, but those GRANT ... ON ALL statements only cover tables that
+ # exist when schema.sql runs — tables created by the migrations above
+ # are not covered, and the backend (which queries exclusively as
+ # service_role, lib/supabase.ts) 500s with "permission denied" on them.
+ # Re-grant AFTER migrations with the SAME privilege set as schema.sql —
+ # deliberately not ALL, so e2e cannot pass on a privilege prod lacks.
+ psql "$DB_URL" -v ON_ERROR_STOP=1 <<'SQL'
+ GRANT USAGE ON SCHEMA public TO service_role;
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO service_role;
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO service_role;
+ SQL
+ # 4) PostgREST cached its schema when `supabase start` booted it (before
+ # the DDL above). Tell it to reload so the new tables/grants are visible.
+ psql "$DB_URL" -v ON_ERROR_STOP=1 -c "NOTIFY pgrst, 'reload schema';"
+
+ - name: Wire env from Supabase
+ run: |
+ API_URL=$(supabase status -o json | jq -r '.API_URL')
+ ANON_KEY=$(supabase status -o json | jq -r '.ANON_KEY')
+ SERVICE_KEY=$(supabase status -o json | jq -r '.SERVICE_ROLE_KEY')
+ # dotenv is last-wins: start from the committed example (so any key we
+ # don't override keeps a sane placeholder) then append the real values.
+ cp backend/.env.example backend/.env
+ {
+ echo "PORT=3001"
+ echo "FRONTEND_URL=http://localhost:3000"
+ echo "SUPABASE_URL=$API_URL"
+ echo "SUPABASE_SECRET_KEY=$SERVICE_KEY"
+ # Both secrets are consumed lazily by download-token signing and API-key
+ # encryption; supply CI dummies well over any length floor.
+ echo "DOWNLOAD_SIGNING_SECRET=ci-download-signing-secret-0123456789abcdef"
+ echo "USER_API_KEYS_ENCRYPTION_SECRET=ci-user-api-keys-encryption-secret-0123456789abcdef"
+ # The suite drives many writes back-to-back (create folder/workflow/
+ # review, upload, update profile, login); the default per-window caps
+ # trip 429s that surface as flaky timeouts. e2e isn't testing
+ # throttling, so raise every tunable cap above one serial run's needs.
+ echo "RATE_LIMIT_GENERAL_MAX=100000"
+ echo "RATE_LIMIT_CHAT_MAX=100000"
+ echo "RATE_LIMIT_CHAT_CREATE_MAX=100000"
+ echo "RATE_LIMIT_UPLOAD_MAX=100000"
+ echo "RATE_LIMIT_EXPORT_MAX=100000"
+ echo "RATE_LIMIT_DATA_DELETE_MAX=100000"
+ # Point the S3-compatible storage adapter at the MinIO container above.
+ echo "R2_ENDPOINT_URL=http://localhost:9000"
+ echo "R2_ACCESS_KEY_ID=minioadmin"
+ echo "R2_SECRET_ACCESS_KEY=minioadmin"
+ echo "R2_BUCKET_NAME=mike"
+ echo "ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}"
+ } >> backend/.env
+ # next build inlines NEXT_PUBLIC_* at build time, so these must be set
+ # before the "Build web" step below.
+ {
+ echo "NEXT_PUBLIC_SUPABASE_URL=$API_URL"
+ echo "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=$ANON_KEY"
+ echo "NEXT_PUBLIC_API_BASE_URL=http://localhost:3001"
+ } > frontend/.env.local
+
+ # Serve a PRODUCTION build, not `next dev`. Two reasons the dev server makes
+ # this suite flaky: (1) its on-demand route compilation makes the first hit
+ # of each page arbitrarily slow (20s waitForResponse assertions time out),
+ # and (2) its hydration-error overlay injects DOM (nextjs__container_errors…)
+ # that pollutes text locators — e.g. getByText('All') matched the overlay's
+ # "Call Stack"/"…fallback" text and failed strict mode. A production build has
+ # neither: no overlay, no JIT compile. Verified locally — flipping dev→start
+ # took the suite from intermittent overlay/timeout failures to green.
+ - name: Build web (production)
+ run: npm run build --prefix frontend
+
+ - name: Start backend API
+ run: npm run dev --prefix backend &
+ - name: Start web (production server)
+ run: npm run start --prefix frontend &
+
+ - name: Wait for servers
+ run: npx wait-on http://localhost:3001/health http://localhost:3000 --timeout 120000
+
+ # playwright.config.ts sets webServer to undefined when CI=true, so the job
+ # is responsible for the servers above; Playwright just drives them.
+ - name: Run Playwright
+ run: npx playwright test
+
+ # always() (not !cancelled()) so the report still uploads when the job is
+ # cancelled by the timeout — that's exactly when you most need the traces.
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: |
+ playwright-report/
+ test-results/
+ retention-days: 14
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index de2f95f3..f38b4a97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,12 @@ next-env.d.ts
.DS_Store
.vercel
coverage
+
+# Playwright artifacts and the bootstrapped e2e session (contains auth tokens)
+test-results/
+playwright-report/
+e2e/.auth/
+
+# Local Supabase scaffold (supabase init output) — the local stack is a test
+# harness only; CI scaffolds its own fresh copy on every run
+backend/supabase/
diff --git a/docs/e2e-ci.md b/docs/e2e-ci.md
new file mode 100644
index 00000000..bb2ab934
--- /dev/null
+++ b/docs/e2e-ci.md
@@ -0,0 +1,94 @@
+# End-to-end tests in CI
+
+The Playwright suite (`e2e/`) runs on every pull request through
+`.github/workflows/e2e.yml`. This document covers the one repository secret it
+needs and the **branch-protection step that turns a red run into a blocked
+merge** — the workflow reports pass/fail on its own, but only branch protection
+makes that check *required*.
+
+## What the workflow does
+
+On every `pull_request` targeting `main` (or `upstream-main`, the fork mirror),
+and on manual `workflow_dispatch`, the `e2e / playwright` job:
+
+1. installs the root (Playwright), `backend/`, and `frontend/` dependencies;
+2. boots **MinIO** (S3-compatible object storage — several specs upload documents);
+3. boots **local Supabase** (Auth + Postgres) via the Supabase CLI, loads
+ `backend/schema.sql`, then applies every dated migration in `backend/migrations/`
+ on top. `schema.sql` is meant to be the latest shape but in practice lags the
+ migrations (e.g. it is missing `workflow_open_source_submissions`, which
+ `GET /workflows/:id` queries — a 500 without the migrations). After the
+ migrations it re-grants `service_role` the same narrowed data privileges
+ `schema.sql` grants (`SELECT/INSERT/UPDATE/DELETE` on tables, `USAGE/SELECT`
+ on sequences): the schema's own `GRANT ... ON ALL` statements only cover
+ tables that existed when the schema loaded, not ones the migrations create;
+4. writes `backend/.env` and `frontend/.env.local` from the live Supabase values;
+5. **builds** the web app (`next build`) and serves it with `next start` — a
+ production build, not `next dev`, so there is no on-demand compilation (which
+ makes first-hit page loads slow enough to time out specs) and no dev
+ hydration-error overlay (whose injected DOM pollutes text locators). Starts the
+ backend API (`:3001`) and the web server (`:3000`) and waits for both healthy;
+6. runs `npx playwright test` and uploads the HTML report + traces as an artifact
+ (`playwright-report`) on pass, fail, or timeout.
+
+`e2e/auth.setup.ts` bootstraps the shared test user (`e2e@mike.local`) against
+the local Supabase admin API, so no login secret is needed — the credentials
+baked into that file are the single source of truth.
+
+Typical run: **~7 minutes**, **23 passed / 4 skipped / 0 failed** with no secret.
+
+## Optional secret (fuller coverage)
+
+| Secret | What it unlocks | Without it |
+|---|---|---|
+| `ANTHROPIC_API_KEY` | The 4 LLM-dependent specs (chat rename/delete/submit, critical-path "ask a question") send a message and assert a **streamed** answer. With the key set they run and must pass. | Those 4 specs **skip** (see `e2e/llm.ts`) instead of hanging, so the run is still green on the other ~23 specs. |
+
+The suite is green **without** any secret — the LLM specs skip themselves via
+`test.skip(!process.env.ANTHROPIC_API_KEY, …)`, which keeps keyless runs (local,
+and fork PRs with no secret access) green and fast. Add the key under
+**Settings → Secrets and variables → Actions → New repository secret** to also
+run and enforce the LLM specs. For fork PRs, GitHub withholds secrets until a
+maintainer approves the run.
+
+## Make it merge-blocking
+
+The workflow failing is not enough on its own — GitHub will still allow the merge
+unless the check is **required**. Enable branch protection once you have seen the
+suite go green a few times (it is environment-sensitive by nature):
+
+1. **Settings → Branches → Add branch protection rule** (or edit the rule for
+ `main`).
+2. Enable **Require status checks to pass before merging**.
+3. Enable **Require branches to be up to date before merging**.
+4. In the checks search box add **`e2e / playwright`** (the job appears in the
+ list after it has run at least once on a PR).
+5. Recommended alongside it: the unit/build check `backend` and the `license/cla`
+ check.
+6. Save. From now on a red e2e run blocks the **Merge** button.
+
+Equivalent via the GitHub CLI (repo admin token required):
+
+```bash
+gh api -X PUT repos/OWNER/REPO/branches/main/protection \
+ -H "Accept: application/vnd.github+json" \
+ -f 'required_status_checks[strict]=true' \
+ -f 'required_status_checks[contexts][]=e2e / playwright' \
+ -f 'enforce_admins=true' \
+ -f 'required_pull_request_reviews[required_approving_review_count]=1' \
+ -f 'restrictions='
+```
+
+## Running the suite locally
+
+Locally, `playwright.config.ts` starts the backend and web dev servers for you
+(`webServer` is only disabled when `CI=true`), so a full local stack plus:
+
+```bash
+npm ci
+npx playwright install --with-deps chromium
+npm run test:e2e # or test:e2e:ui / test:e2e:headed
+```
+
+`e2e/auth.setup.ts` reads `SUPABASE_URL` / `SUPABASE_SECRET_KEY` from the
+environment or `backend/.env`, so a running local Supabase + a populated
+`backend/.env` is all the setup needs.
diff --git a/e2e/auth-flows.spec.ts b/e2e/auth-flows.spec.ts
new file mode 100644
index 00000000..6c011579
--- /dev/null
+++ b/e2e/auth-flows.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * Authentication flow E2E tests:
+ * 1. Login: invalid credentials show an error message
+ * 2. Login: valid credentials redirect to /assistant
+ * 3. Logout redirects to /login
+ * 4. All protected routes redirect unauthenticated users to /login
+ *
+ * Tests 1, 2, and 4 run in a fresh browser context (no stored session).
+ * Test 3 inherits the authenticated storageState from the Playwright project
+ * config (e2e/.auth/user.json), so auth.setup.ts must run first.
+ */
+import { test, expect } from "@playwright/test";
+
+/* ─── Unauthenticated tests ───────────────────────────────────────────────── */
+
+/* describe-scoped test.use so only these tests run without a stored session.
+ File-level test.use would wipe the storageState for the authenticated
+ logout test below. */
+test.describe("unauthenticated", () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ /* ── Test 1: invalid credentials show error ──────────────────────────── */
+
+ test("login with invalid credentials shows error message", async ({
+ page,
+ }) => {
+ await page.goto("/login");
+ await expect(page).toHaveURL(/\/login/);
+
+ await page.fill("#email", "e2e@mike.local");
+ await page.fill("#password", "definitely-wrong-password");
+ await page.click('button[type="submit"]');
+
+ /* Wait for the client-side async signIn call to complete and for React
+ to set the `error` state and re-render the error element. */
+ await page.waitForLoadState("networkidle");
+
+ /* The login page conditionally renders:
+
+ {error}
+
+ when the `error` state is non-null after a failed signInWithPassword.
+ REGRESSION: fails if the error
is removed
+ from the login form or if the catch block stops setting `error`. */
+ await expect(page.locator("div.bg-red-50.text-red-600")).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ /* ── Test 2: valid credentials redirect to /assistant ─────────────────── */
+
+ test("login with valid credentials redirects to /assistant", async ({
+ page,
+ }) => {
+ /* Use the SAME credentials auth.setup.ts bootstrapped the shared user
+ with. Both read process.env.E2E_EMAIL / E2E_PASSWORD (falling back to
+ the local defaults). CI overrides E2E_PASSWORD to a value DIFFERENT
+ from the old hardcoded "E2eTestPass1!", so hardcoding it here typed a
+ password the user was never created with → signInWithPassword failed,
+ the error banner rendered, and the /assistant redirect never fired.
+ Reading the env keeps the typed password in lock-step with the
+ bootstrapped one in every environment. */
+ const email = process.env.E2E_EMAIL ?? "e2e@mike.local";
+ const password = process.env.E2E_PASSWORD ?? "E2eTestPass1!";
+
+ await page.goto("/login");
+ await expect(page).toHaveURL(/\/login/);
+
+ await page.fill("#email", email);
+ await page.fill("#password", password);
+ await page.click('button[type="submit"]');
+
+ /* REGRESSION: fails if `router.push("/assistant")` is removed from
+ the handleLogin success branch in frontend/src/app/login/page.tsx. */
+ await expect(page).toHaveURL(/\/assistant/, { timeout: 15_000 });
+ });
+
+ /* ── Test 4: all protected routes redirect to /login ─────────────────── */
+
+ test("all protected routes redirect unauthenticated users to /login", async ({
+ page,
+ }) => {
+ /* Every route under the (pages) route group is protected by the layout
+ auth guard:
+ if (!authLoading && !isAuthenticated) { router.push("/login"); }
+ in frontend/src/app/(pages)/layout.tsx.
+ REGRESSION: fails if that router.push("/login") is removed from the
+ layout, or if any of these routes is moved outside the (pages) group
+ without adding its own auth guard. */
+ const protectedRoutes = [
+ "/projects",
+ "/tabular-reviews",
+ "/workflows",
+ "/account",
+ ];
+
+ for (const route of protectedRoutes) {
+ await page.goto(route);
+ /* Auth check is client-side (Supabase getSession) — allow time for
+ the async check to resolve and for Next.js router.push to fire. */
+ await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
+ }
+ });
+});
+
+/* ─── Authenticated tests ─────────────────────────────────────────────────── */
+
+/* ── Test 3: logout redirects to /login ─────────────────────────────────── */
+
+/* The logout flow calls supabase.auth.signOut(), which defaults to GLOBAL
+ scope and revokes the user's session server-side. If this ran against the
+ shared `e2e@mike.local` user it would 401 every other parallel worker
+ ("Invalid or expired token"). So this test starts from a clean session and
+ logs in as a DEDICATED user (created in auth.setup.ts) whose session can be
+ safely destroyed without affecting any other test. */
+test.describe("logout (isolated user)", () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ const logoutEmail =
+ process.env.E2E_LOGOUT_EMAIL ?? "e2e-logout@mike.local";
+ const logoutPassword =
+ process.env.E2E_LOGOUT_PASSWORD ?? "E2eLogoutPass1!";
+
+ test("logout from account settings redirects to /login", async ({
+ page,
+ }) => {
+ /* Log in fresh as the dedicated logout user. */
+ await page.goto("/login");
+ await expect(page).toHaveURL(/\/login/);
+ await page.fill("#email", logoutEmail);
+ await page.fill("#password", logoutPassword);
+ await page.click('button[type="submit"]');
+
+ await page.waitForURL(/\/assistant/, { timeout: 15_000 });
+ await page.waitForLoadState("networkidle");
+
+ /* The AppSidebar renders a user-profile toggle button at the very bottom
+ of the sidebar. The button wraps a circular div that shows the user's
+ initial:
+
+ {getUserInitials(user.email)}
+
+ Locate the button by the presence of that inner div. */
+ const userMenuButton = page.locator("button").filter({
+ has: page.locator("div.rounded-full.bg-gray-700"),
+ });
+ await expect(userMenuButton).toBeVisible({ timeout: 10_000 });
+ await userMenuButton.click();
+
+ /* The dropdown that appears contains an "Account Settings" button which
+ navigates to /account via router.push("/account"). */
+ const accountSettingsItem = page.getByRole("button", {
+ name: "Account Settings",
+ });
+ await expect(accountSettingsItem).toBeVisible({ timeout: 5_000 });
+ await accountSettingsItem.click();
+
+ await expect(page).toHaveURL(/\/account/, { timeout: 10_000 });
+ await page.waitForLoadState("networkidle");
+
+ /* The /account page has a "Sign Out" button that calls:
+ await signOut();
+ router.push("/");
+ The root "/" page redirects to "/assistant", and the (pages) layout auth
+ guard then redirects the now-unauthenticated user to "/login".
+ REGRESSION: fails if signOut() is removed from handleLogout in
+ frontend/src/app/(pages)/account/page.tsx. */
+ const signOutButton = page.getByRole("button", { name: "Sign Out" });
+ await expect(signOutButton).toBeVisible({ timeout: 5_000 });
+ await signOutButton.click();
+
+ await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
+ });
+});
diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts
new file mode 100644
index 00000000..0211a32f
--- /dev/null
+++ b/e2e/auth.setup.ts
@@ -0,0 +1,110 @@
+import { test as setup, expect } from "@playwright/test";
+import path from "path";
+import fs from "fs";
+
+const authFile = path.join(__dirname, ".auth/user.json");
+
+/**
+ * Read a key out of backend/.env so the setup can reach Supabase with the
+ * service-role key without requiring the operator to export it manually.
+ */
+function readApiEnv(key: string): string | undefined {
+ if (process.env[key]) return process.env[key];
+ const envPath = path.join(__dirname, "..", "backend", ".env");
+ try {
+ const contents = fs.readFileSync(envPath, "utf8");
+ // dotenv semantics: a later assignment wins over an earlier one. CI
+ // does `cp .env.example .env` (which ships a PLACEHOLDER SUPABASE_URL)
+ // and then APPENDS the real values, so returning the FIRST match would
+ // hand back the placeholder (getaddrinfo ENOTFOUND your-project...).
+ // Iterate every line and keep the LAST matching value, mirroring how
+ // the API's dotenv loader resolves the file.
+ let value: string | undefined;
+ for (const line of contents.split("\n")) {
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (m && m[1] === key) value = m[2].trim();
+ }
+ return value;
+ } catch {
+ /* .env not present — fall through to undefined */
+ }
+ return undefined;
+}
+
+/**
+ * Idempotently create a confirmed Supabase user via the admin API. If the user
+ * already exists the admin endpoint returns a 422 which we treat as success.
+ */
+async function ensureUser(email: string, password: string) {
+ const supabaseUrl =
+ readApiEnv("SUPABASE_URL") ?? "http://127.0.0.1:54321";
+ const serviceKey = readApiEnv("SUPABASE_SECRET_KEY");
+ if (!serviceKey) {
+ throw new Error(
+ "SUPABASE_SECRET_KEY not found (checked env and backend/.env); " +
+ "cannot bootstrap E2E users",
+ );
+ }
+
+ const res = await fetch(`${supabaseUrl}/auth/v1/admin/users`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ apikey: serviceKey,
+ Authorization: `Bearer ${serviceKey}`,
+ },
+ body: JSON.stringify({
+ email,
+ password,
+ email_confirm: true,
+ }),
+ });
+
+ if (!res.ok && res.status !== 422) {
+ const body = await res.text();
+ // 422 == user already registered, which is fine for an idempotent setup.
+ if (!body.includes("already been registered")) {
+ throw new Error(
+ `Failed to create user ${email}: ${res.status} ${body}`,
+ );
+ }
+ }
+}
+
+/**
+ * The main authenticated session shared by every non-destructive test.
+ * Stored to e2e/.auth/user.json and loaded via the chromium project config.
+ */
+setup("authenticate", async ({ page }) => {
+ // Default to the credentials the spec files use (the specs log in with
+ // e2e@mike.local / E2eTestPass1!), so the suite runs out-of-the-box against
+ // a local stack with no env juggling. The bootstrapped user MUST match the
+ // password the specs type, or the valid-login tests fail; keeping the
+ // default here is the single source of truth. Override via env in CI.
+ const email = process.env.E2E_EMAIL ?? "e2e@mike.local";
+ const password = process.env.E2E_PASSWORD ?? "E2eTestPass1!";
+
+ /* Bootstrap the shared user plus a dedicated user for destructive auth
+ tests (logout / account deletion). The logout test calls Supabase
+ signOut() which uses GLOBAL scope and revokes the user's session
+ server-side; running it against the shared user would 401 every other
+ parallel worker. Isolating it onto its own user keeps the suite stable. */
+ await ensureUser(email, password);
+ await ensureUser(
+ process.env.E2E_LOGOUT_EMAIL ?? "e2e-logout@mike.local",
+ process.env.E2E_LOGOUT_PASSWORD ?? "E2eLogoutPass1!",
+ );
+
+ await page.goto("/login");
+ await expect(page).toHaveURL(/\/login/);
+
+ await page.fill("#email", email);
+ await page.fill("#password", password);
+ await page.click('button[type="submit"]');
+
+ /* After login the app redirects to /assistant */
+ await page.waitForURL(/\/assistant/, { timeout: 15_000 });
+
+ /* Save the authenticated session for all subsequent tests */
+ await page.context().storageState({ path: authFile });
+});
diff --git a/e2e/chat-management.spec.ts b/e2e/chat-management.spec.ts
new file mode 100644
index 00000000..a02fa3ed
--- /dev/null
+++ b/e2e/chat-management.spec.ts
@@ -0,0 +1,414 @@
+/**
+ * Chat-management E2E tests:
+ * 1. Cold-load existing chat — verifies getChat() API loads messages on direct URL
+ * 2. Rename a chat from sidebar — verifies rename API and sidebar UI update
+ * 3. Delete a chat from sidebar — verifies delete API and sidebar removal
+ * 4. Project assistant: create a new chat and submit a question
+ *
+ * Auth: inherits storageState from playwright.config.ts ("e2e/.auth/user.json")
+ * Test user: e2e@mike.local / E2eTestPass1!
+ */
+import { test, expect, type Page } from "@playwright/test";
+import { hasLlmKey, LLM_SKIP_REASON } from "./llm";
+
+/* ─── Helpers ────────────────────────────────────────────────────────────────── */
+
+/**
+ * Ensure the app sidebar is expanded so that "Assistant History" is visible.
+ *
+ * layout.tsx initialises isSidebarOpen=true on desktop (≥768 px, which is
+ * Playwright's Desktop Chrome viewport), but the project-chat page calls
+ * setSidebarOpen(false) on mount. This helper reopens it if needed.
+ */
+async function ensureSidebarOpen(page: Page) {
+ const historySection = page.getByText("Assistant History");
+ if (!(await historySection.isVisible())) {
+ // The toggle button's title alternates between "Open sidebar" and "Close sidebar"
+ // (AppSidebar.tsx onToggle handler). Use the first match in case both the
+ // desktop and mobile toggle buttons are in the DOM simultaneously.
+ await page.getByTitle("Open sidebar").first().click();
+ await expect(historySection).toBeVisible({ timeout: 5_000 });
+ }
+}
+
+/**
+ * Select a Claude model in the chat input's ModelToggle so the first submit
+ * actually creates a chat instead of opening the ApiKeyMissingModal.
+ *
+ * The specs that call this run only when ANTHROPIC_API_KEY is set in the
+ * Playwright environment (test.skip(!hasLlmKey, ...) — e2e/llm.ts). The CI
+ * stack exports the same secret to the backend, whose key resolution
+ * (userApiKeys.ts envApiKey()) falls back to the ANTHROPIC_API_KEY env var, so
+ * the "claude" provider reports as configured and ModelToggle shows the
+ * Anthropic models as available. The default model, however, is
+ * "gemini-3-flash-preview" (ModelToggle.DEFAULT_MODEL_ID), for which no key is
+ * configured in CI; ChatInput.handleSubmit (ChatInput.tsx:116-119) then refuses
+ * to send. ModelToggle renders a Radix DropdownMenu: the trigger is a button
+ * whose title is "Choose model" (current model available) or "API key missing
+ * for selected model" (current model not available — the default-Gemini case).
+ * We open it, pick "Claude Sonnet 4.6" (the cheapest Anthropic entry in
+ * ModelToggle.MODELS), and confirm the trigger now shows that label.
+ */
+const CLAUDE_MODEL_LABEL = "Claude Sonnet 4.6";
+
+async function selectClaudeModel(page: Page) {
+ const trigger = page
+ .locator(
+ 'button[title="Choose model"], button[title="API key missing for selected model"]',
+ )
+ .first();
+ await expect(trigger).toBeVisible({ timeout: 10_000 });
+ await trigger.click();
+ await page.getByRole("menuitem", { name: CLAUDE_MODEL_LABEL }).click();
+ // After selection the trigger label reflects the chosen model.
+ await expect(
+ page.getByRole("button", { name: CLAUDE_MODEL_LABEL }),
+ ).toBeVisible({ timeout: 5_000 });
+}
+
+/* ─── Test 1: cold-load existing chat ───────────────────────────────────────── */
+
+test("cold-load: direct URL to a chat triggers the getChat history load", async ({ page }) => {
+ // REGRESSION: fails if AssistantChatPage's cold-load history load is removed —
+ // i.e. if the component stops calling getChat(id) on mount
+ // (AssistantChatPage.tsx:37-45). On a direct navigation we assert (a) the
+ // GET /chat/ request actually fires, and (b) its result drives the
+ // documented navigation: when getChat yields no messages the page redirects
+ // back to /assistant (AssistantChatPage.tsx:42/45 router.replace("/assistant")).
+ // Verified by temporarily removing the getChat(...) call: the request no longer
+ // fires and no redirect happens, so this test fails.
+ //
+ // Why not assert a rendered message? This environment can't produce a chat
+ // with stored messages: no LLM provider key is configured (so the UI's
+ // Enter-to-send is blocked by the ApiKeyMissingModal, ChatInput.tsx:116-119),
+ // and even a direct POST /chat can't persist one — chat.routes.ts:530-536
+ // inserts a `workflow` column that does not exist on chat_messages, so every
+ // message insert fails silently and the table stays empty. An existing-but-
+ // empty chat and a never-created chat id are therefore observably identical
+ // here: getChat runs, returns no messages, and AssistantChatPage redirects.
+ // Using a fresh id keeps the test self-contained — no chat-creation request to
+ // fail under DB churn, no message-history precondition that can't be met.
+
+ // A valid-shaped UUID that will not exist (gen_random_uuid never yields it),
+ // so getChat(id) → GET /chat/ resolves 404 and the page redirects.
+ const chatId = "00000000-0000-4000-8000-000000000000";
+
+ // ── Step 1: the cold-load getChat(id) call must issue GET /chat/ ─────
+ // Scope to the API origin (port 3001) so we match the getChat() API call and
+ // NOT the Next.js page/RSC navigation request, whose URL also contains the
+ // path "/assistant/chat/".
+ const getChatRequest = page.waitForResponse(
+ (r) =>
+ /:3001\/chat\//.test(r.url()) &&
+ r.url().includes(`/chat/${chatId}`) &&
+ r.request().method() === "GET",
+ { timeout: 20_000 },
+ );
+ await page.goto(`/assistant/chat/${chatId}`);
+ await getChatRequest; // proves the cold-load getChat(id) call happened
+
+ // ── Step 2: with no messages, AssistantChatPage redirects to the landing ─────
+ await expect(page).toHaveURL(/\/assistant$/, { timeout: 15_000 });
+});
+
+/* ─── Test 2: rename a chat from sidebar ────────────────────────────────────── */
+
+test("rename chat: sidebar rename interaction updates the title", async ({ page }) => {
+ test.skip(!hasLlmKey, LLM_SKIP_REASON);
+ // REGRESSION: fails if the renameChat API call or the optimistic title update in
+ // ChatHistoryContext.renameChatFn / SidebarChatItem.handleRenameSave is removed.
+
+ // Chat creation (saveChat → POST /chat/create) can be slow when the dev server
+ // / DB is under load, so allow extra headroom over the default 30s test cap.
+ test.setTimeout(90_000);
+
+ const message = `Rename test ${Date.now()}`;
+ const newTitle = `Renamed Chat ${Date.now()}`;
+
+ // ── Step 1: create a new chat ─────────────────────────────────────────────────
+ await page.goto("/assistant");
+ const textarea = page.getByPlaceholder("How can I help?");
+ await expect(textarea).toBeVisible({ timeout: 10_000 });
+ // Pick a Claude model (available via the backend's ANTHROPIC_API_KEY) so
+ // the submit isn't blocked by the ApiKeyMissingModal — the default Gemini
+ // model has no key configured in this run.
+ await selectClaudeModel(page);
+ await textarea.fill(message);
+
+ // Sending the first message triggers auto title-generation
+ // (useGenerateChatTitle → POST /chat//generate-title → renameChat). That
+ // would overwrite our manual rename below if it lands afterwards, so wait for
+ // it to settle first. Best-effort: if it never fires (e.g. the LLM errors),
+ // proceed — our manual rename is then unopposed.
+ const titleGenerated = page
+ .waitForResponse(
+ (r) =>
+ /:3001\/chat\/.+\/generate-title$/.test(r.url()) &&
+ r.request().method() === "POST",
+ { timeout: 30_000 },
+ )
+ .catch(() => null);
+ await textarea.press("Enter");
+
+ // ── Step 2: wait for navigation to the new chat page ─────────────────────────
+ await page.waitForURL(/\/assistant\/chat\/.+/, { timeout: 45_000 });
+ await titleGenerated; // let auto title-generation apply before we rename
+
+ // ── Step 3: ensure the sidebar is open ───────────────────────────────────────
+ await ensureSidebarOpen(page);
+
+ // ── Step 4: locate the active chat item ──────────────────────────────────────
+ // SidebarChatItem.tsx renders a `div.group.relative` wrapper for each chat.
+ // When isActive=true the wrapper carries APP_SURFACE_ACTIVE_CLASS
+ // ("bg-app-surface-active"); inactive items carry APP_SURFACE_HOVER_CLASS
+ // ("hover:bg-app-surface-hover", a different token), so matching
+ // "bg-app-surface-active" distinguishes the active item. (The olp liquid-
+ // surface refresh renamed the old "bg-gray-200/60" active token.)
+ const activeItem = page
+ .locator('div.group.relative[class*="bg-app-surface-active"]')
+ .first();
+
+ // The active item's trigger is already opacity-100, but hover is harmless and
+ // keeps parity with the inactive-item path.
+ await activeItem.hover();
+
+ // ── Step 5: click the MoreHorizontal trigger (three-dot menu) ────────────────
+ // SidebarChatItem.tsx lines 104-115: DropdownMenuTrigger wraps a