Merge pull request #220 from amal66/olp-pr/e2e-playwright

[Testing 12] test: Playwright e2e suite + CI merge gate (auth, chat, projects, tabular, workflows)
This commit is contained in:
Will Chen 2026-07-26 00:16:19 +08:00 committed by GitHub
commit dad4b9d21a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2532 additions and 26 deletions

224
.github/workflows/e2e.yml vendored Normal file
View file

@ -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

9
.gitignore vendored
View file

@ -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/

94
docs/e2e-ci.md Normal file
View file

@ -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.

174
e2e/auth-flows.spec.ts Normal file
View file

@ -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:
<div className="text-red-600 text-sm bg-red-50 p-3 rounded">
{error}
</div>
when the `error` state is non-null after a failed signInWithPassword.
REGRESSION: fails if the error <div className="bg-red-50"> 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:
<div className="h-7 w-7 ... rounded-full bg-gray-700 ...">
{getUserInitials(user.email)}
</div>
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 });
});
});

110
e2e/auth.setup.ts Normal file
View file

@ -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 });
});

414
e2e/chat-management.spec.ts Normal file
View file

@ -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/<id> 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/<id> resolves 404 and the page redirects.
const chatId = "00000000-0000-4000-8000-000000000000";
// ── Step 1: the cold-load getChat(id) call must issue GET <api>/chat/<id> ─────
// 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/<id>".
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/<id>/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 <button> with
// the MoreHorizontal icon. In the non-renaming state the two buttons inside the
// item are [0] chat-title button and [1] the trigger; .last() picks the trigger.
const triggerBtn = activeItem.locator("button").last();
await triggerBtn.click();
// ── Step 6: click "Rename" in the Radix DropdownMenuContent ─────────────────
// SidebarChatItem.tsx lines 117-129: DropdownMenuItem with Pencil icon + "Rename"
const renameItem = page.getByRole("menuitem", { name: "Rename" });
await expect(renameItem).toBeVisible({ timeout: 5_000 });
await renameItem.click();
// ── Step 7: type the new title in the inline input ───────────────────────────
// SidebarChatItem.tsx lines 56-68: isRenaming state shows an <input type="text">
// that is focused automatically (editInputRef.current?.focus() in useEffect).
// There is no data-testid; scope to the item container to avoid ambiguity.
const renameInput = activeItem.locator("input[type='text']");
await expect(renameInput).toBeVisible({ timeout: 5_000 });
await renameInput.fill(newTitle);
// SidebarChatItem.tsx line 63: Enter key calls handleRenameSave()
await renameInput.press("Enter");
// ── Step 8: assert the new title appears in the sidebar ──────────────────────
// ChatHistoryContext.renameChatFn optimistically updates the chat title in state.
// SidebarChatItem re-renders the title button with the new text.
await expect(
page.getByRole("button", { name: newTitle })
).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 3: delete a chat from sidebar ────────────────────────────────────── */
test("delete chat: sidebar delete action removes the chat from history", async ({ page }) => {
test.skip(!hasLlmKey, LLM_SKIP_REASON);
// REGRESSION: fails if the deleteChat API call or the optimistic list removal in
// ChatHistoryContext.deleteChatFn (filter by chatId) 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 = `Delete test ${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 });
// ── Step 2: create the chat ──────────────────────────────────────────────────
// ChatInput.handleSubmit creates the chat (saveChat → POST /chat/create) then
// navigates to /assistant/chat/<id>.
const newChatUrl = /\/assistant\/chat\/.+/;
// Sending the first message kicks off auto title-generation
// (useGenerateChatTitle → POST /chat/<id>/generate-title → renameChat). If it
// lands after the manual rename in step 4 it overwrites the unique title and
// the row can no longer be found. Wait for it to settle first, exactly as the
// rename test does. Best-effort: if it never fires, our rename is unopposed.
const titleGenerated = page
.waitForResponse(
(r) =>
/:3001\/chat\/.+\/generate-title$/.test(r.url()) &&
r.request().method() === "POST",
{ timeout: 30_000 },
)
.catch(() => null);
// Pick a Claude model (available via the backend's ANTHROPIC_API_KEY) so
// the submit isn't blocked by the ApiKeyMissingModal, then send the first
// message.
await selectClaudeModel(page);
await textarea.fill(message);
await textarea.press("Enter");
await page.waitForURL(newChatUrl, { timeout: 20_000 });
// Let auto title-generation land before renaming, so it cannot clobber the
// unique title this test targets the row by.
await titleGenerated;
// ── Step 3: ensure the sidebar is open ───────────────────────────────────────
await ensureSidebarOpen(page);
// ── Step 4: rename the new chat to a unique title so we can target it ─────────
// The shared test user accumulates many chats across runs and the sidebar
// paginates at INITIAL_CHAT_LIMIT (20), so total-row-count and positional
// assertions are unreliable. Instead, give this chat a unique title and then
// assert on that exact title — immune to pagination and to other chats. The
// just-created chat is active and prepended, so it is the first row; rename it
// via the same three-dot menu the rename test exercises.
const uniqueTitle = `Delete Target ${Date.now()}`;
const firstRow = page.locator("div.group.relative.h-8.rounded-md").first();
await firstRow.hover();
await firstRow.locator("button").last().click();
await page.getByRole("menuitem", { name: "Rename" }).click();
const renameInput = firstRow.locator("input[type='text']");
await expect(renameInput).toBeVisible({ timeout: 5_000 });
await renameInput.fill(uniqueTitle);
await renameInput.press("Enter");
// The renamed chat's title button now uniquely identifies its row.
const targetTitle = page.getByRole("button", { name: uniqueTitle });
await expect(targetTitle).toBeVisible({ timeout: 10_000 });
// The row wrapper that contains that title button (for reaching its menu).
const targetRow = page
.locator("div.group.relative.h-8.rounded-md")
.filter({ has: targetTitle });
// ── Step 5-7: delete that specific chat ──────────────────────────────────────
// deleteChatFn (ChatHistoryContext.tsx:157-168) optimistically removes the
// row. SidebarChatItem.tsx:132-144: the "Delete" DropdownMenuItem calls
// deleteChat(chat.id) directly — no confirmation dialog.
await targetRow.hover();
await targetRow.locator("button").last().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(targetTitle).toBeHidden({ timeout: 10_000 });
});
/* ─── Test 4: project assistant — create new chat ───────────────────────────── */
test("project assistant: create a new chat and submit a question", async ({ page }) => {
test.skip(!hasLlmKey, LLM_SKIP_REASON);
// REGRESSION: fails if the project chat creation route is broken — specifically if
// handleNewChat() in ProjectPage.tsx (lines 515-519) fails to call saveChat() or
// router.push to /projects/[id]/assistant/chat/[chatId]. (Verified by temporarily
// removing that router.push: "+ Create New" then no longer navigates and the
// Step 8 waitForURL below fails.)
// This test creates a project then a chat (two sequential write round-trips
// plus a navigation each) and ends with an LLM-backed submit, so give it
// headroom beyond the 30s default.
test.setTimeout(120_000);
// ── Step 1: navigate to projects ─────────────────────────────────────────────
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/, { timeout: 10_000 });
// ── Step 2: open the "New project" modal ─────────────────────────────────────
const createBtn = page.getByRole("button", { name: "New project" });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
// ── Step 3: fill in the project name ─────────────────────────────────────────
// NewProjectModal.tsx line 203: disabled={!name.trim() || loading}
// PDF upload is optional — we omit it to keep this test focused on routing.
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
const projectName = `E2E Chat Route ${Date.now()}`;
await nameInput.fill(projectName);
// NewProjectModal is a two-step wizard ("Details" → "Add Documents"); the
// "Create project" submit button only exists on the second step.
await page.getByRole("button", { name: "Next", exact: true }).click();
// ── Step 4: submit the form ──────────────────────────────────────────────────
// NewProjectModal.handleSubmit does NOT navigate itself — it calls onCreated()
// then onClose(). ProjectsOverview.onCreated (ProjectsOverview.tsx:475-478)
// inserts the row AND router.push(`/projects/${p.id}`), so the app navigates
// straight to the project detail page; the name never re-appears in a list to
// click. Wait for that navigation instead of asserting a list row.
const submitBtn = page.getByRole("button", {
name: /create project|creating/i,
});
const projectUrl = /\/projects\/[^/]+$/;
await expect(submitBtn).toBeEnabled({ timeout: 10_000 });
await submitBtn.click();
await page.waitForURL(projectUrl, { timeout: 20_000 });
// ── Step 6: open the assistant tab and reach the empty-state "Create" ────────
// The project assistant is now a nested route (/projects/[id]/assistant), not a
// ?tab= query on the detail page. Navigating straight there avoids ambiguity
// with the sidebar "Assistant" nav item.
const assistantUrl = page.url() + "/assistant";
// The olp UI replaced the old "+ Create New" text link with a PillButton
// reading "Create" in the ProjectAssistantTable empty state
// (ProjectAssistantTable.tsx:110-115, shown when chats.length === 0).
const createNewBtn = page.getByRole("button", { name: "Create", exact: true });
await page.goto(assistantUrl);
await expect(createNewBtn).toBeVisible({ timeout: 20_000 });
// ── Step 7-8: click "Create" and wait for the project chat URL ───────────────
// handleNewChat (ProjectPage.tsx:515-519) calls saveChat(projectId) then
// router.push(`/projects/${projectId}/assistant/chat/${id}`).
//
// REGRESSION (the target of this test): if handleNewChat's router.push is
// removed — or saveChat itself is broken — no navigation happens and the
// waitForURL below fails.
const chatUrl = /\/projects\/.+\/assistant\/chat\/.+/;
await createNewBtn.click();
await page.waitForURL(chatUrl, { timeout: 20_000 });
// ── Step 9: assert the ChatInput textarea is visible ─────────────────────────
// ProjectAssistantChatPage renders <ChatInput> in the right "Project Assistant"
// panel (line 1221-1229 of the chat page component).
const chatInput = page.getByPlaceholder("How can I help?");
await expect(chatInput).toBeVisible({ timeout: 10_000 });
// ── Step 10-11: pick an available model, submit a question, assert it clears ──
// ChatInput.handleSubmit (ChatInput.tsx:113-140) clears the textarea
// synchronously (setValue("")) ONLY when the selected model is available and no
// prior response is in flight; otherwise it returns early. Two transient gates
// exist under load:
// • useSelectedModel (useSelectedModel.ts:16-20) seeds DEFAULT (Gemini) for
// one render before its localStorage-read effect restores the Claude
// model, so a submit racing a ChatInput remount can momentarily see
// Gemini → the ApiKeyMissingModal ("API key required") pops and the
// submit no-ops.
// • A transient in-flight response (isResponseLoading) also no-ops the submit.
// Re-select the Claude model and re-submit until the textarea clears. A
// genuinely broken send never clears on ANY attempt, so a real regression is
// still caught.
const question = "What is in this project?";
const apiKeyModalHeading = page.getByRole("heading", {
name: "API key required",
});
const SUBMIT_ATTEMPTS = 4;
let cleared = false;
for (let attempt = 0; attempt < SUBMIT_ATTEMPTS && !cleared; attempt++) {
// Dismiss a stray "API key required" modal left by a prior racey attempt
// (Cancel closes it without navigating; "Go to account settings" would).
if (await apiKeyModalHeading.isVisible().catch(() => false)) {
await page
.getByRole("button", { name: "Cancel" })
.click()
.catch(() => {});
}
// Re-assert the Claude model as the active (available) model after any remount.
await selectClaudeModel(page);
await chatInput.fill(question);
// ChatInput.handleKeyDown: Enter (no Shift) → handleSubmit().
await chatInput.press("Enter");
// setValue("") runs synchronously on a successful send.
cleared = await expect(chatInput)
.toHaveValue("", { timeout: 5_000 })
.then(() => true)
.catch(() => false);
}
// Final assertion surfaces a genuinely broken send (never clears).
await expect(chatInput).toHaveValue("", { timeout: 5_000 });
});

181
e2e/critical-path.spec.ts Normal file
View file

@ -0,0 +1,181 @@
/**
* Critical path E2E tests:
* 1. Authenticated landing /assistant loads correctly
* 2. Projects create a project, upload a PDF, open the project assistant,
* send a message and verify a response begins streaming
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json
*/
import { test, expect, type Page } from "@playwright/test";
import { hasLlmKey, LLM_SKIP_REASON } from "./llm";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
/**
* Select a Claude model in the chat input's ModelToggle.
*
* This spec runs 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 (Gemini) has no key configured in CI, so a
* submit with it would be blocked by the ApiKeyMissingModal. We pick
* "Claude Sonnet 4.6" (the cheapest Anthropic entry in ModelToggle.MODELS) so
* the request streams a real response. The Radix DropdownMenu trigger's title
* is "Choose model" (current model available) or "API key missing for selected
* model" (default-Gemini case).
*/
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: authenticated landing ─────────────────────────────────────── */
test("authenticated user lands on the assistant page", async ({ page }) => {
await page.goto("/assistant");
await expect(page).toHaveURL(/\/assistant/);
/* The InitialView renders a greeting heading */
await expect(page.locator("h1, h2").first()).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 2: create project → upload PDF → chat ─────────────────────────── */
test("create project, upload PDF, ask a question and receive a response", async ({
page,
}) => {
test.skip(!hasLlmKey, LLM_SKIP_REASON);
/* This end-to-end flow (create + upload + navigate + chat) needs more than
the 30s default. The per-test `{ timeout }` option that test() accepts is
silently ignored by Playwright (that object only takes tag/annotation),
so set it here. */
test.setTimeout(120_000);
/* ── Step 1: navigate to projects ─────────────────────────────────────── */
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/);
/* ── Step 2: open the "New project" modal ────────────────────────────── */
/* The Plus icon button in the header has aria-label="New project" */
const createBtn = page.getByRole("button", { name: "New project" });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
/* ── Step 3: fill in the project name ─────────────────────────────────── */
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
const projectName = `E2E Test Project ${Date.now()}`;
await nameInput.fill(projectName);
/* ── Step 4: advance to "Add Documents" and upload a PDF ──────────────── */
/* NewProjectModal is a two-step wizard; the details step's primary action is
a plain "Next" and only the documents step carries the file input. */
await page.getByRole("button", { name: "Next", exact: true }).click();
const uploadBtn = page.getByRole("button", { name: /^Upload/ });
/* We need to trigger the hidden file input; intercept the chooser */
const fileChooserPromise = page.waitForEvent("filechooser");
await uploadBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(PDF_FIXTURE);
/* The button label should update to reflect the queued file */
await expect(
page.getByRole("button", { name: /^Upload \(1\)/ }),
).toBeVisible({ timeout: 5_000 });
/* ── Step 5: submit the form ──────────────────────────────────────────── */
/* The PDF upload runs inside NewProjectModal.handleSubmit
(await Promise.all([uploadProjectDocument(...)])) BEFORE onCreated fires,
so the "Creating…" button state can persist for many seconds while the
file uploads. ProjectsOverview.onCreated then router.push()es to the new
project page, so wait for that navigation (generously, to cover the
upload). */
await page.click('button[type="submit"]');
await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 30_000 });
/* ── Step 6: open the project assistant ───────────────────────────────── */
/* We're already on /projects/[id] (Documents tab by default). The project
assistant is now a nested route, /projects/[id]/assistant. Navigate there
directly rather than clicking through the tab bar to avoid ambiguity with
the "Assistant" item in the sidebar nav. (The olp UI replaced the old
"+ Create New" text link with a PillButton reading "Create"
ProjectAssistantTable empty state.) */
const projectUrl = page.url().split("?")[0];
const createNew = page.getByRole("button", { name: "Create", exact: true });
await page.goto(`${projectUrl}/assistant`);
/* The assistant tab shows a chat list. Click "Create" to open the chat
interface where the text input appears. */
await expect(createNew).toBeVisible({ timeout: 20_000 });
await createNew.click();
/* Navigates to /projects/{id}/assistant (new chat UI) */
await page.waitForURL(/\/projects\/.+\/assistant/, { timeout: 10_000 });
await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {});
/* ── Step 7: select a Claude model, type a question, submit ───────────── */
const chatInput = page.getByPlaceholder("How can I help?");
await expect(chatInput).toBeVisible({ timeout: 10_000 });
/* The default Gemini model has no key configured, so submitting it would be
blocked by the ApiKeyMissingModal. Select a Claude model (backed by the
ANTHROPIC_API_KEY the backend reads from its environment) so the request
actually streams a response. */
await selectClaudeModel(page);
await chatInput.fill("What is this document about?");
/* This ChatInput submits on Enter (Shift+Enter inserts a newline). */
await chatInput.press("Enter");
/* ── Step 8: verify the assistant streams a response ─────────────────── */
/* With a real Claude model the reply content is nondeterministic, so assert
presence + nonempty rather than matching text. The assistant's answer
renders through MarkdownContent (message/MarkdownContent.tsx), whose
wrapper div carries "text-gray-900 … prose … font-serif" a combination
unique to assistant answer content on this page (user messages render a
plain <p>, and the gray pre-response EventBlocks prose uses
text-gray-400). Its appearance with nonempty text proves the message was
sent, streamed, and rendered end-to-end.
The reply is preceded by a POST that persists the chat, a client-side
route change to /assistant/chat/<id>, and a real LLM round-trip; under
local-Supabase load that can outlast a 30s budget, so allow the same
headroom the rest of this flow gets. */
const assistantAnswer = page
.locator("div.prose.font-serif.text-gray-900")
.first();
await expect(assistantAnswer).toBeVisible({ timeout: 60_000 });
/* Nonempty streamed text (any non-whitespace character). */
await expect(assistantAnswer).toContainText(/\S/);
});
/* ─── Test 3: login-page redirect for unauthenticated users ──────────────── */
/* describe-scoped test.use so only this test runs without a stored session.
File-level test.use would wipe the storageState for all tests in this file. */
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("unauthenticated request to /assistant redirects to login", async ({
page,
}) => {
await page.goto("/assistant");
/* 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: 15_000 });
});
});

17
e2e/fixtures/test.pdf Normal file
View file

@ -0,0 +1,17 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj 4 0 obj<</Length 44>>stream
BT /F1 12 Tf 100 700 Td (Test PDF Document) Tj ET
endstream
endobj 5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000062 00000 n
0000000119 00000 n
0000000273 00000 n
0000000370 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
441
%%EOF

18
e2e/llm.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Helpers for specs that require a live LLM turn.
*
* A few specs create/populate a chat by sending a message and awaiting a
* streamed answer. That only works when a model key is configured in CI the
* `ANTHROPIC_API_KEY` secret, which `.github/workflows/e2e.yml` exposes to the
* Playwright process. When it is absent (a plain local run, or a fork PR with no
* secret access) the app blocks message send behind the ApiKeyMissingModal, so
* these specs would hang to their timeout.
*
* Guarding them with `test.skip(!hasLlmKey, LLM_SKIP_REASON)` keeps a keyless
* run green and fast on the ~20 specs that don't need a model, while still
* running and enforcing the LLM specs whenever the key is present.
*/
export const hasLlmKey = Boolean(process.env.ANTHROPIC_API_KEY);
export const LLM_SKIP_REASON =
"requires a model key — set the ANTHROPIC_API_KEY secret to run LLM-dependent specs";

View file

@ -0,0 +1,342 @@
/**
* Project management E2E tests:
* 1. Rename a project inline
* 2. Delete a project
* 3. Create a folder inside a project
* 4. File upload type validation (wrong type rejected)
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json.
* All tests run with the authenticated storageState configured in playwright.config.ts
* no test.use override is needed here.
*
* Each test creates its own uniquely-named project so tests are fully isolated.
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// ─── Shared helper ────────────────────────────────────────────────────────────
/**
* Creates a new project via the "New project" modal and waits until
* NewProjectModal's onCreated handler redirects to /projects/<id>.
*
* Pass `filePath` to also upload a document during creation. This matters for
* the folder test: ProjectPage only renders the document tree (and therefore
* the root "Add Subfolder" input) when the project is NOT empty an empty
* project shows the "Drop PDF or DOCX files here" placeholder instead, which
* has no folder input.
*/
async function createProject(
page: import("@playwright/test").Page,
projectName: string,
filePath?: string,
) {
/* Creation is a navigation + modal wizard + (optionally) a file upload; the
per-test `{ timeout }` option passed to test() is silently ignored by
Playwright (that object only accepts tag/annotation), so raise the budget
here, where the slow work happens, for every caller. */
test.setTimeout(60_000);
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/, { timeout: 10_000 });
/* The Plus icon button in the header has aria-label="New project" */
const createBtn = page.getByRole("button", { name: "New project" });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(projectName);
/* NewProjectModal is a two-step wizard: "Details" (name / CM number /
practice / colleagues) then "Add Documents". Only the second step has a
submit button the first step's primary action is a plain "Next". */
await page.getByRole("button", { name: "Next", exact: true }).click();
if (filePath) {
/* On the documents step the footer "Upload" button opens a hidden file
input, and its label gains a "(n)" count once files are attached. */
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: /^Upload/ }).click();
(await fileChooserPromise).setFiles(filePath);
await expect(
page.getByRole("button", { name: /^Upload \(1\)/ }),
).toBeVisible({ timeout: 5_000 });
}
/* Submit NewProjectModal's onCreated calls router.push(`/projects/${id}`).
The PDF upload runs (awaited) inside handleSubmit before onCreated fires,
so allow extra time for navigation when a file is attached.
(The modal's FileDirectory used to fan out a getProject() request per
existing project on open, which could overwhelm the local Supabase
gateway and required settle-waits plus a submit-retry loop here. The
directory now loads via one batched listProjects?include=documents
request, so a single submit is reliable.)
The documents step's primary action submits the form (its label flips
to "Creating…" while in flight, so match on the submit type instead). */
const navTimeout = filePath ? 30_000 : 15_000;
await page.locator('button[type="submit"]').click();
await page.waitForURL(/\/projects\/.+/, { timeout: navTimeout });
}
/**
* Navigate to the projects list and return the table row for `projectName`.
* Rows are <div class="group">; the sidebar "Recent Projects" renders the same
* name as a <button>, so scoping to div.group avoids a strict-mode double match.
*/
async function gotoProjectRow(
page: import("@playwright/test").Page,
projectName: string,
) {
const row = page.locator("div.group").filter({ hasText: projectName });
await page.goto("/projects");
await expect(row.first()).toBeVisible({ timeout: 12_000 });
return row;
}
/**
* After creating a project we land on /projects/<id>, whose ProjectPage fetches
* getProject() once on mount. Wait until `anchor` a control that only renders
* once the project has loaded becomes visible.
*/
async function waitForProjectLoaded(
page: import("@playwright/test").Page,
anchor: import("@playwright/test").Locator,
) {
await expect(anchor).toBeVisible({ timeout: 15_000 });
}
// ─── Test 1: Rename a project inline ─────────────────────────────────────────
test("rename a project via Edit details", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/* Navigate to the projects list (where the rename UI lives) and grab the
row. Each project row is a <div class="group">. The sidebar "Recent
Projects" list renders the same name as a <button> (not div.group), so
scoping to div.group keeps the lookup to the table row. */
const row = await gotoProjectRow(page, projectName);
/* The ··· button (middle-dot U+00B7 × 3) is inside the last cell of the row */
const ellipsisBtn = row.locator("button").filter({ hasText: "···" });
await ellipsisBtn.click();
/*
* The row menu (RowActions) offers "Edit details" and "Delete" the old
* inline "Rename" affordance is gone; renaming now happens in
* ProjectDetailsModal, which also carries the CM number and practice fields.
*/
await page.getByRole("button", { name: "Edit details", exact: true }).click();
/* ProjectDetailsModal's name field is pre-filled with the current name;
fill() clears it first, platform-independently. */
const newName = `E2E Proj Renamed ${Date.now()}`;
const renameInput = page.locator("#project-details-name");
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(newName);
// REGRESSION: fails if ProjectDetailsModal's onSave (updateProject) is removed
await page.getByRole("button", { name: "Update", exact: true }).click();
/* handleRenameSubmit optimistically updates the projects list state.
Scope to table rows (div.group) so the sidebar's stale copy of the old
name does not interfere with the negative assertion below. */
// REGRESSION: fails if rename input or submit handler is removed
await expect(
page.locator("div.group").filter({ hasText: newName }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0);
});
// ─── Test 2: Delete a project ─────────────────────────────────────────────────
test("delete a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* Back to the projects list.
* Rows are scoped to div.group the sidebar "Recent Projects" list also
* shows the name as a <button>, so an unscoped getByText would match two
* elements.
*
* The row checkbox is wrapped in a div with onClick={e.stopPropagation()}
* to prevent accidental row navigation. Clicking the checkbox alone is safe.
*/
const row = await gotoProjectRow(page, projectName);
const checkbox = row.locator('input[type="checkbox"]');
await checkbox.click();
/*
* The "Actions" button is conditionally rendered only when selectedIds.length > 0.
* It opens a small dropdown containing a "Delete" option.
*/
const actionsBtn = page.getByRole("button", { name: /^Actions/ });
await expect(actionsBtn).toBeVisible({ timeout: 3_000 });
await actionsBtn.click();
/* exact:true so the substring match can't pick up any other button whose
accessible name merely contains "Delete". */
const deleteBtn = page.getByRole("button", { name: "Delete", exact: true });
await expect(deleteBtn).toBeVisible({ timeout: 3_000 });
// REGRESSION: fails if `handleDeleteSelected` is removed
await deleteBtn.click();
/* handleDeleteSelected removes the project from local state immediately.
Scope to table rows so a stale sidebar entry can't keep this truthy. */
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0, { timeout: 10_000 });
});
// ─── Test 3: Create a folder inside a project ────────────────────────────────
test("create a folder inside a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
/* Create WITH a document so the project isn't empty. The "Add Subfolder"
button always sits in the documents toolbar, but the root folder INPUT
only renders inside the document tree (ProjectPage.renderLevel), which is
shown only for a non-empty project an empty project shows the "Drop PDF
or DOCX files here" placeholder instead, with no folder input. */
await createProject(page, projectName, PDF_FIXTURE);
/*
* After createProject we are on the new project page (Documents tab). The
* documents toolbar (which hosts the folder-create button) only appears
* once the project has loaded.
*/
/* The olp UI renamed the documents-toolbar folder-create button from
"Add Subfolder" to "Folder" (a TabPillButton wired to the root
createFolderAction ProjectDocumentsView). Clicking it still renders the
autofocused "Folder name" input at root level (creatingIn === null). */
const addSubfolderBtn = page.getByRole("button", { name: "Folder" });
await waitForProjectLoaded(page, addSubfolderBtn);
/* Confirm the uploaded document rendered, i.e. the project is non-empty and
the document tree (and therefore the root folder input) will render. */
await expect(page.getByText("test.pdf").first()).toBeVisible({
timeout: 10_000,
});
/* Clicking "Add Subfolder" sets creatingFolderIn = null (root level). */
await addSubfolderBtn.click();
/*
* renderFolderInput renders an <input> with placeholder "Folder name"
* that is autoFocused. Pressing Enter calls handleCreateFolder(null)
* which creates a folder at the root level.
*/
const folderInput = page.getByPlaceholder("Folder name");
await expect(folderInput).toBeVisible({ timeout: 3_000 });
const folderName = `Test Folder ${Date.now()}`;
await folderInput.fill(folderName);
await folderInput.press("Enter");
/*
* handleCreateFolder optimistically inserts the folder into local state,
* then replaces the temp entry with the real folder from the API.
*/
// REGRESSION: fails if folder creation button or API call is removed
await expect(page.getByText(folderName)).toBeVisible({ timeout: 10_000 });
});
// ─── Test 4: File upload type validation (wrong type rejected) ────────────────
test("file upload type validation — .txt file is rejected", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* After createProject we are on the project's Documents tab.
* The "Add Documents" button opens AddDocumentsModal which has a
* hidden file input with accept=".pdf,.docx,.doc".
*
* Validation is now two layers, and this test covers both:
* (a) UI: AddDocumentsModal filters unsupported files client-side
* (partitionSupportedDocumentFiles) and shows a visible warning
* no request is sent, so we assert the warning + absence of the file.
* (b) Server: the upload endpoint must still 400 unsupported extensions
* (defense in depth for API/SDK callers that bypass the web UI).
* The UI never emits that request anymore, so we exercise the
* endpoint directly with the browser session's bearer token.
*/
/* Open the Add Documents modal. The "Add Documents" button only renders
once ProjectPage has loaded the project. */
const addDocsBtn = page.getByRole("button", { name: "Add Documents" });
await waitForProjectLoaded(page, addDocsBtn);
/* (b) Server-side rejection REGRESSION: fails if type validation is
removed from the upload handler. */
const projectId = page.url().match(/\/projects\/([0-9a-f-]{36})/)?.[1];
expect(projectId, "expected to be on a /projects/<id> page").toBeTruthy();
const accessToken = await page.evaluate(() => {
const item = Object.entries(localStorage).find(([k]) =>
k.includes("auth-token"),
);
if (!item) return null;
try {
return JSON.parse(item[1]).access_token ?? null;
} catch {
return null;
}
});
expect(accessToken, "expected a Supabase session in localStorage").toBeTruthy();
const apiBase = process.env.MIKE_API_BASE_URL ?? "http://localhost:3001";
const uploadResponse = await page.request.post(
`${apiBase}/projects/${projectId}/documents`,
{
headers: { Authorization: `Bearer ${accessToken}` },
multipart: {
file: {
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from(
"This is a plain text file that should be rejected.",
),
},
},
},
);
expect(uploadResponse.status()).toBe(400);
/* (a) UI-side filtering with a visible warning. */
await addDocsBtn.click();
const fileChooserPromise = page.waitForEvent("filechooser");
/* The Upload button label is "Upload" (not "Uploading…") when idle */
await page.getByRole("button", { name: "Upload" }).first().click();
const fileChooser = await fileChooserPromise;
/*
* Playwright's setFiles accepts an in-memory file descriptor, bypassing
* the browser's accept-attribute filter so the client-side partition
* logic (not the accept attribute) is what's under test.
*/
await fileChooser.setFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("This is a plain text file that should be rejected."),
});
// REGRESSION: fails if the visible unsupported-type warning is removed
// (UNSUPPORTED_DOCUMENT_WARNING_MESSAGE in documentUploadValidation.ts).
await expect(
page.getByText(
"Unsupported file type. Only PDF, Word, Excel, and PowerPoint files can be uploaded.",
),
).toBeVisible({ timeout: 10_000 });
/* The .txt file must not appear in the modal's document list */
await expect(page.getByText("test.txt")).not.toBeVisible();
});

248
e2e/tabular-reviews.spec.ts Normal file
View file

@ -0,0 +1,248 @@
/**
* Tabular Review E2E tests:
* 1. Navigate to /tabular-reviews the list page loads correctly
* 2. Create a new tabular review modal flow, API call, redirect to detail
* 3. Review detail page table structure and toolbar controls render
* 4. Add a document upload via AddDocumentsModal, row appears in table
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json
* Test user: e2e@mike.local / E2eTestPass1! (storageState inherited from playwright.config.ts)
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// Run these tests sequentially in a single worker: they share the one test
// user's review list, so concurrent create/detail runs would race each other.
test.describe.configure({ mode: "serial" });
/* ─── Helpers ────────────────────────────────────────────────────────────── */
/**
* Click the "New review" Plus icon button in the Tabular Reviews list-page header.
*
* The button has no aria-label or visible text it is an icon-only button
* rendered immediately after the HeaderSearchBtn (magnifier icon) in the same
* flex container that is the sibling of the <h1>Tabular Reviews</h1>.
*
* DOM structure:
* <div class="… justify-between …">
* <h1>Tabular Reviews</h1>
* <div class="flex items-center gap-2"> xpath=../div[1] from h1
* <div><button>{SearchIcon}</button></div> HeaderSearchBtn
* <button>{PlusIcon}</button> new-review button (.last())
* </div>
* </div>
*
* TODO: once aria-label="New review" is added to that button, replace with:
* page.getByRole("button", { name: "New review" })
*/
async function clickNewReviewBtn(page: import("@playwright/test").Page) {
// Walk from the h1 to the parent div, then select its first div child
// (the actions container); the last button within it is the Plus icon.
const actionsDiv = page
.getByRole("heading", { name: "Tabular Reviews" })
.locator("xpath=../div[1]"); // TODO: verify selector
await actionsDiv.getByRole("button").last().click();
}
/** Predicate matching the create-review request: POST /tabular-review (exact). */
const isCreateReviewPost = (r: import("@playwright/test").Response) =>
/\/tabular-review\/?$/.test(new URL(r.url()).pathname) &&
r.request().method() === "POST";
/**
* Open the AddNewTRModal (assumes /tabular-reviews is already loaded) and return
* its "Review name" input once visible.
*/
async function openNewReviewModal(page: import("@playwright/test").Page) {
await clickNewReviewBtn(page);
const titleInput = page.getByPlaceholder("Review name");
await expect(titleInput).toBeVisible({ timeout: 10_000 });
return titleInput;
}
/**
* Create a tabular review through the real modal flow and land on its detail
* page. Returns the title that was entered so callers can assert on it.
*
* @param onFirstOpen optional assertion run against the modal on the first open
* (used by Test 2 to verify the workflow-template default renders).
*/
async function createReview(
page: import("@playwright/test").Page,
label = "E2E Review",
onFirstOpen?: () => Promise<void>,
): Promise<string> {
await page.goto("/tabular-reviews");
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
const reviewName = `${label} ${Date.now()}`;
const titleInput = await openNewReviewModal(page);
if (onFirstOpen) await onFirstOpen();
await titleInput.fill(reviewName);
// NewTRModal is a two-step wizard ("Details" → "Add Documents"); the
// "Create" submit button only exists on the second step, and "Next" only
// enables once the review has a name.
await page.getByRole("button", { name: "Next", exact: true }).click();
const respP = page.waitForResponse(isCreateReviewPost, {
timeout: 30_000,
});
// The modal footer's submit button and the page's own "Create" CTA both
// read "Create"; scope to the modal's submit (button[name="modalAction"]
// value="create-review") to avoid a strict-mode ambiguity.
await page
.locator('button[name="modalAction"][value="create-review"]')
.click();
const resp = await respP;
expect(
resp.ok(),
`POST /tabular-review returned ${resp.status()} — create flow broken?`,
).toBe(true);
const review = (await resp.json()) as { id: string };
// createTabularReview() → router.push("/tabular-reviews/<id>").
await expect(page).toHaveURL(
new RegExp(`/tabular-reviews/${review.id}`),
{ timeout: 30_000 },
);
return reviewName;
}
/* ─── Test 1: list page loads ─────────────────────────────────────────────── */
test("navigates to /tabular-reviews and the list page renders", async ({
page,
}) => {
// REGRESSION: fails if the /tabular-reviews route is removed or broken
await page.goto("/tabular-reviews");
await expect(page).toHaveURL(/\/tabular-reviews/);
// The page renders an h1 heading with the section title
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
// The ToolbarTabs bar renders the "All" tab
// TODO: verify selector if ToolbarTabs uses role="tab" instead of role="button"
await expect(page.getByText("All")).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 2: create a new tabular review ─────────────────────────────────── */
test("creates a new tabular review and is redirected to the detail page", async ({
page,
}) => {
// Headroom for the create round-trip plus the first navigation to the
// dynamic /tabular-reviews/[id] route.
test.setTimeout(60_000);
// REGRESSION: fails if createTabularReview() API call is removed or the
// /tabular-reviews POST route is broken (createReview's response
// assertion then trips).
//
// createReview opens the modal, verifies the workflow-template default
// renders, submits, and lands on the new review's detail page.
const reviewName = await createReview(page, "E2E Review", async () => {
// The workflow template control defaults to "No template - start from
// scratch" once the templates request resolves (it shows "Loading
// templates…" until then), so allow time for that listWorkflows() fetch.
// NewTRModal renders it as a button, with a hyphen — not an em dash.
await expect(
page.getByRole("button", { name: "No template - start from scratch" }),
).toBeVisible({ timeout: 15_000 });
});
// The new review's title appears in the page breadcrumb header
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 3: review detail page table structure ─────────────────────────── */
test("review detail page renders the table structure and toolbar controls", async ({
page,
}) => {
// Headroom for the create round-trip plus the detail-route navigation.
test.setTimeout(60_000);
// REGRESSION: fails if the /tabular-reviews/[id] route, TRView, or TRTable
// component is broken
const reviewName = await createReview(page, "E2E Table Review");
// The breadcrumb header shows the review title via RenameableTitle
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
// The breadcrumb also contains a "Tabular Reviews" back-nav button. Scope to
// the <main> landmark: the left sidebar nav also has a "Tabular Reviews"
// button, so an unscoped role query is a strict-mode violation. exact:true
// avoids also matching the mobile-only "Back to Tabular Reviews" control.
await expect(
page
.getByRole("main")
.getByRole("button", { name: "Tabular Reviews", exact: true }),
).toBeVisible({ timeout: 5_000 });
// TRTable always renders a "Document" column header, even when the review
// is empty. This is visible in both the empty-state and populated states.
await expect(
page.getByText("Document", { exact: true }),
).toBeVisible({ timeout: 10_000 });
// The toolbar renders "Add Columns" and "Add Documents" once loading is done.
// Both may also appear in TRTable's empty-state CTA, so .first() is used.
await expect(
page.getByRole("button", { name: /Add Columns/ }).first(),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /Add Documents/ }).first(),
).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 4: add a document to a review ─────────────────────────────────── */
test("adds a document to a tabular review and the row appears in the table", async ({
page,
}) => {
// Headroom for the create round-trip plus the upload + document-link
// round-trips.
test.setTimeout(90_000);
// REGRESSION: fails if the document-to-review linking
// (PATCH /tabular-reviews/:id with document_ids) or the upload endpoint breaks
const reviewName = await createReview(page, "E2E Doc Review");
// reviewName is already confirmed visible on the detail page by createReview
const row = page.getByText("test.pdf").first();
const confirmBtn = page.getByRole("button", { name: "Confirm" });
// Open AddDocumentsModal (standalone path → AddDocumentsModal, not
// AddProjectDocsModal). first() handles both toolbar & empty-state CTA.
const addDocsBtn = page
.getByRole("button", { name: /Add Documents/ })
.first();
await expect(addDocsBtn).toBeVisible({ timeout: 10_000 });
await addDocsBtn.click();
// The footer's "Upload" button programmatically clicks a hidden
// <input type="file"> — Playwright intercepts it as a file-chooser event.
const uploadBtn = page.getByRole("button", { name: "Upload" });
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
const fileChooserPromise = page.waitForEvent("filechooser");
await uploadBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(PDF_FIXTURE);
// After a successful upload the server document is auto-selected and the
// "Confirm" button transitions disabled → enabled.
await expect(confirmBtn).toBeEnabled({ timeout: 20_000 });
// Confirm → onSelect() → handleAddDocuments() → updateTabularReview()
// PATCH → setDocuments() → TRTable renders the new row with doc.filename.
await confirmBtn.click();
await expect(row).toBeVisible({ timeout: 15_000 });
});

View file

@ -0,0 +1,342 @@
/**
* E2E tests for Workflows and Account Settings features.
*
* Test user: e2e@mike.local / E2eTestPass1! (session loaded from e2e/.auth/user.json)
*
* Key source facts used by these selectors:
* - WorkflowList.tsx: h1 "Workflows"; Plus icon button (no aria-label) opens NewWorkflowModal
* - NewWorkflowModal.tsx: placeholder "Workflow name"; submit button text "Create workflow"
* - systemWorkflows.ts (generated): built-in id "builtin-draft-cp-checklist", title "Draft CP Checklist"
* - WorkflowDetailPage ([id]/page.tsx): readOnly badge renders <span>Read-only</span>;
* WorkflowPromptEditor passes editable:!readOnly to Tiptap contenteditable="false" when readOnly
* - WorkflowPromptEditor.tsx: editorProps class = "workflow-editor-content" on the ProseMirror div
* - WorkflowDetailPage save status: text "Saving…" "Saved" rendered in a plain <span>
* - account/page.tsx: h2 "Profile"; Input placeholder "Enter your name"; Button "Save" / "Saved"
* - account/layout.tsx: h1 "Settings" in layout header
* - account/models/page.tsx: h2 "API Keys"; label texts include "Anthropic (Claude) API Key" etc.
*/
import { test, expect, type Page } from "@playwright/test";
/**
* Create a workflow from an already-open NewWorkflowModal and wait for the
* post-create navigation to /workflows/<id>.
*/
async function createWorkflowAndOpenDetail(page: Page, title: string) {
const nameInput = page.getByPlaceholder("Workflow name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(title);
// Match the submit button in BOTH states: its label is "Create workflow" when
// idle and "Creating…" while the request is in flight.
const createBtn = page.getByRole("button", {
name: /create workflow|creating/i,
});
await expect(createBtn).toBeEnabled({ timeout: 10_000 });
await createBtn.click();
await expect(page).toHaveURL(/\/workflows\/.+/, { timeout: 15_000 });
}
/*
WORKFLOWS
*/
test.describe("Workflows", () => {
/* ── Test 1: list page loads and shows built-in workflows ──────────────── */
test("workflow list page loads and shows built-in workflows", async ({
page,
}) => {
await page.goto("/workflows");
// REGRESSION: fails if the /workflows route or page component is broken
await expect(page).toHaveURL(/\/workflows/, { timeout: 10_000 });
// The WorkflowList renders an h1 heading
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// System workflows are generated into backend/src/lib/systemWorkflows.ts —
// "Draft CP Checklist" (id: builtin-draft-cp-checklist) is always present
// is always present; its title appears as a row in the table.
// REGRESSION: fails if the workflow list page or built-in workflow rendering is broken
await expect(page.getByText("Draft CP Checklist")).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 2: create a custom workflow ──────────────────────────────────── */
test("create a custom assistant workflow and navigate to its detail page", async ({
page,
}) => {
await page.goto("/workflows");
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// The Plus icon button (no aria-label) is the last button inside the div
// that directly contains the h1 "Workflows" heading. The only other button
// in that container is the HeaderSearchBtn search toggle, which comes first.
// TODO: verify selector if the page header layout changes
const newWorkflowBtn = page
.locator("div:has(> h1:has-text('Workflows')) button")
.last();
await expect(newWorkflowBtn).toBeVisible({ timeout: 5_000 });
await newWorkflowBtn.click();
// The NewWorkflowModal opens — its breadcrumb reads "New workflow"
await expect(page.getByText("New workflow")).toBeVisible({
timeout: 5_000,
});
// Fill the title, submit, and wait for the post-create router.push to
// /workflows/<id>. Type defaults to "Assistant" — no change needed.
// REGRESSION: a broken workflow-create API never navigates, so the
// helper's toHaveURL assertion fails.
const workflowTitle = `E2E Workflow ${Date.now()}`;
await createWorkflowAndOpenDetail(page, workflowTitle);
// The detail page shows the newly created workflow's title
await expect(page.getByText(workflowTitle)).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 3: built-in workflow detail page is read-only ────────────────── */
test("built-in workflow detail page shows Read-only badge and non-editable prompt", async ({
page,
}) => {
// Navigate directly to the known built-in ID; this avoids having to click
// through the DisplayWorkflowModal "View Page" button. builtin-draft-cp-checklist
// is an assistant-type workflow, so use the typed detail route
// (/workflows/assistant/[id]) that the app itself links to via
// workflowDetailPath — the flat /workflows/[id] path does not exist here.
await page.goto("/workflows/assistant/builtin-draft-cp-checklist");
// The page loads and shows the built-in workflow title
await expect(page.getByText("Draft CP Checklist")).toBeVisible({
timeout: 15_000,
});
// WorkflowDetailPage renders a "Read-only" badge for built-in (is_system) workflows
// REGRESSION: fails if built-in read-only enforcement is removed from the detail page
await expect(page.getByText("Read-only")).toBeVisible({
timeout: 10_000,
});
// WorkflowPromptEditor is dynamically imported (SSR: false); wait for it to mount.
// When readOnly=true, Tiptap sets editable:false which renders contenteditable="false"
// on the ProseMirror content div (given class "workflow-editor-content" via editorProps).
// REGRESSION: fails if the readOnly prop is no longer passed to WorkflowPromptEditor
const editorDiv = page.locator(".ProseMirror");
await expect(editorDiv).toBeVisible({ timeout: 15_000 });
await expect(editorDiv).toHaveAttribute("contenteditable", "false", {
timeout: 5_000,
});
});
/* ── Test 4: custom workflow prompt auto-saves on change ───────────────── */
test("editing a custom workflow prompt triggers auto-save", async ({
page,
}) => {
/* Step 1: create a fresh custom workflow to edit */
await page.goto("/workflows");
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// TODO: verify selector if the page header layout changes
const newWorkflowBtn = page
.locator("div:has(> h1:has-text('Workflows')) button")
.last();
await newWorkflowBtn.click();
const workflowTitle = `E2E Edit Workflow ${Date.now()}`;
await createWorkflowAndOpenDetail(page, workflowTitle);
await page.waitForLoadState("networkidle");
/* Step 2: type into the WorkflowPromptEditor */
// The editor is dynamically imported; wait until it is ready.
// When readOnly=false (custom workflow), contenteditable="true".
const editorDiv = page.locator(".ProseMirror");
await expect(editorDiv).toBeVisible({ timeout: 15_000 });
await expect(editorDiv).toHaveAttribute("contenteditable", "true", {
timeout: 5_000,
});
await editorDiv.click();
await page.keyboard.type("This is an E2E test prompt.");
/* Step 3: the debounced auto-save (800 ms) fires and the save-status
span transitions: "" "Saving…" "Saved".
save() (workflows/[id]/page.tsx:122-138) sets "Saving…" synchronously
on every edit, then PATCHes prompt_md and sets "Saved" (which
auto-reverts to idle after ~2 s).
REGRESSION: a removed/broken update API or save wiring shows NEITHER
"Saving…" (guard #1, save() never fires) NOR "Saved" (guard #2, the
PATCH never resolves), so this fails for a genuine break. */
// Guard #1: the save() handler must run (sets "Saving…" synchronously).
// PageHeader renders its actions twice — a desktop inline copy and a
// portal-mounted mobile copy — so an unscoped text locator resolves to
// two nodes and trips strict mode. Filter to the visible instance.
await expect(
page
.getByText(/^(Saving…|Saved)$/)
.filter({ visible: true })
.first(),
).toBeVisible({ timeout: 10_000 });
// Guard #2: the PATCH must resolve to "Saved".
await expect(
page.getByText("Saved").filter({ visible: true }).first(),
).toBeVisible({ timeout: 10_000 });
});
});
/*
ACCOUNT SETTINGS
*/
test.describe("Account Settings", () => {
/* ── Test 5: account page loads with user info ────────────────────────── */
test("account settings page loads and shows user email", async ({
page,
}) => {
await page.goto("/account");
// The account layout renders a "Settings" h1
// REGRESSION: fails if the account page or its layout is broken
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The Profile section has its own h2
await expect(
page.getByRole("heading", { name: "Profile" }),
).toBeVisible({ timeout: 10_000 });
// The email is rendered in the (editable) Email input, so assert its
// value rather than page text.
// REGRESSION: fails if user auth context is not propagated to the account page
await expect(page.getByPlaceholder("Enter your email")).toHaveValue(
"e2e@mike.local",
{ timeout: 10_000 },
);
});
/* ── Test 6: update display name ─────────────────────────────────────── */
test("updating display name saves and persists across navigation", async ({
page,
}) => {
// This test bounds-retries its mutation + persistence steps to converge
// past a real client-side hydration race (see below), so give it more
// headroom than the 30 s default.
test.setTimeout(120_000);
await page.goto("/account");
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The Display Name Input has placeholder "Enter your name"
const nameInput = page.getByPlaceholder("Enter your name");
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const newName = `E2E Test User ${Date.now()}`;
// The Save button is the sibling of the input in the same "flex gap-2" row.
// Scope it to that row so it is the Display-Name button, not the Organisation one.
// TODO: verify selector if the Profile section layout changes
const saveBtn = nameInput
.locator("xpath=parent::div")
.getByRole("button", { name: /save/i });
// Robustly save the new name and verify it persists. This retry exists
// for a real client-side hazard, independent of infrastructure:
//
// Async hydration race. The account page hydrates this input from a profile
// fetch (UserProfileContext → `if (profile?.displayName) setDisplayName(...)`).
// Under cold-start the auth state can settle late and trigger a SECOND profile
// fetch that overwrites the field AFTER we type — so the stale stored name is
// what handleSaveDisplayName persists (observed: a *previous* run's name was
// saved). We therefore (re)fill immediately before saving and re-verify the
// persisted value; if a late overwrite slipped a stale value in, the persist
// check fails and the block re-runs (auth has settled by then, so it converges).
//
// On success the label flips Save → "Saved" for ~2 s (Display-Name button only; the
// Organisation button stays "Save").
//
// REGRESSION: a broken profile PATCH / save handler never reaches "Saved" and never
// persists newName, so every attempt fails and toPass exhausts → the test fails.
await expect(async () => {
// Reload at the START of each attempt so a failed or stale profile GET
// (which leaves the input empty via the null-displayName fallback, with no
// client-side refetch) is retried with a fresh fetch rather than looping on a
// permanently-empty page.
//
// Hydration signal: wait for the profile GET itself, not for a non-empty
// input. A fresh e2e user (fresh database) has displayName=null, so
// "input pre-filled with the stored name" can never happen on the
// first-ever run — the old not.toHaveValue("") wait deadlocked there.
const profileLoaded = page.waitForResponse(
(resp) =>
resp.url().endsWith("/user/profile") &&
resp.request().method() === "GET" &&
resp.ok(),
{ timeout: 10_000 },
);
await page.goto("/account");
await profileLoaded;
await nameInput.fill(newName);
await expect(nameInput).toHaveValue(newName, { timeout: 2_000 });
await expect(saveBtn).toBeEnabled({ timeout: 5_000 });
await saveBtn.click();
await expect(saveBtn).toHaveText(/saved/i, { timeout: 8_000 });
// Navigate away and back; the freshly fetched profile must show newName.
await page.goto("/assistant");
await page.goto("/account");
await expect(nameInput).toHaveValue(newName, { timeout: 8_000 });
}).toPass({ timeout: 90_000 });
});
/* ── Test 7: API keys page loads and shows all three provider sections ── */
test("API keys page loads and shows Anthropic, Google, and OpenAI sections", async ({
page,
}) => {
// API keys were split out of /account/models into their own settings
// page (the "API Keys" sidebar entry) — /account/models now holds only
// model preferences.
await page.goto("/account/api-keys");
// The shared account layout still renders "Settings"
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The h2 "API Keys" section is present
// REGRESSION: fails if the /account/api-keys page is broken or the API Keys section is removed
await expect(
page.getByRole("heading", { name: "API Keys" }),
).toBeVisible({ timeout: 10_000 });
// All three provider label texts (from MODEL_API_KEY_FIELDS in api-keys/page.tsx) must appear
// REGRESSION: fails if any provider section is removed from the API keys page
await expect(
page.getByText("Anthropic (Claude) API Key"),
).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Google (Gemini) API Key")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("OpenAI API Key")).toBeVisible({
timeout: 10_000,
});
});
});

View file

@ -3,6 +3,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
turbopack: {
root: __dirname,
},
async rewrites() {
return [
{

View file

@ -8,6 +8,7 @@ import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
import { AppSidebar } from "@/app/components/shared/AppSidebar";
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
export default function MikeLayout({
children,
@ -75,11 +76,7 @@ export default function MikeLayout({
}, [authLoading, isAuthenticated, router]);
if (authLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-700" />
</div>
);
return <FullScreenLoader />;
}
if (!isAuthenticated) return null;

View file

@ -4,23 +4,16 @@ import { Suspense } from "react";
import { AuthProvider } from "@/app/contexts/AuthContext";
import { UserProfileProvider } from "@/app/contexts/UserProfileContext";
import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate";
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<UserProfileProvider>
<Suspense fallback={<ProviderLoader />}>
<Suspense fallback={<FullScreenLoader />}>
<MfaLoginGate>{children}</MfaLoginGate>
</Suspense>
</UserProfileProvider>
</AuthProvider>
);
}
function ProviderLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}

View file

@ -0,0 +1,14 @@
/**
* The single full-screen loading state. Every gate in the provider/layout
* chain (Suspense fallback, MFA gate, auth-loading layout) must render this
* exact markup: the server and client can resolve those gates differently on
* first paint, and identical DOM is what keeps that from being a hydration
* mismatch.
*/
export function FullScreenLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
type GateState = "idle" | "checking" | "required" | "verified";
@ -91,7 +92,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
return gateState === "verified" ? (
<>{children}</>
) : (
<FullScreenGateLoader />
<FullScreenLoader />
);
}
@ -100,15 +101,15 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
return <>{children}</>;
}
if (gateState === "verified" && isVerifyPage) {
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
if (gateState === "verified") {
return <>{children}</>;
}
if (gateState === "required" && !isVerifyPage) {
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
return <>{children}</>;
@ -122,14 +123,6 @@ function safeNextPath(value: string | null) {
return value;
}
function FullScreenGateLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}
export function markMfaVerifiedForGate() {
window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now()));
}

113
package-lock.json generated Normal file
View file

@ -0,0 +1,113 @@
{
"name": "mike",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mike",
"license": "AGPL-3.0-only",
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "mike",
"private": true,
"scripts": {
"test:e2e": "playwright test",
"test:e2e:local": "bash scripts/e2e-local-stack.sh",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22"
},
"license": "AGPL-3.0-only"
}

70
playwright.config.ts Normal file
View file

@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Run `npx playwright install` to download the browsers.
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* These E2E tests run against a single shared backend and a single shared
test user (e2e@mike.local). Running them concurrently causes data races
on shared list views (projects/chats/workflows) and on the user's
session, producing flaky pass/fail that can't be trusted for regression
detection. So we run strictly one test at a time. */
fullyParallel: false,
workers: 1,
/* Fail the build on CI if you accidentally left test.only in the source */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter */
reporter: process.env.CI ? "github" : "list",
/* Shared settings for all the projects below */
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
/* Run the auth setup before all other tests */
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
dependencies: ["setup"],
},
],
/* Start the backend and the Next.js dev server when running locally.
The backend command first runs the local-stack setup (Docker check,
supabase start, schema + migrations + grants, env wiring) so a plain
`npm run test:e2e` works against a ready local Supabase see
scripts/e2e-local-stack.sh. Idempotent: a few seconds when already up. */
webServer: process.env.CI
? undefined
: [
{
command:
"bash ../scripts/e2e-local-stack.sh --setup-only && npm run dev",
cwd: "backend",
url: "http://localhost:3001/health",
reuseExistingServer: true,
timeout: 120_000,
},
{
command: "npm run dev",
cwd: "frontend",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 120_000,
},
],
});

117
scripts/e2e-local-stack.sh Executable file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Boot the full local e2e stack and run the Playwright suite.
#
# Local-machine mirror of .github/workflows/e2e.yml: starts the Supabase CLI
# stack (Docker), loads backend/schema.sql + backend/migrations/ (re-granting
# service_role's narrowed privileges afterwards for migration-created tables —
# see docs/e2e-ci.md), points backend/.env and frontend/.env.local at the
# local stack, then runs `npx playwright test`.
#
# Usage, from the repo root:
# npm run test:e2e:local # whole suite
# npm run test:e2e:local -- -g "display name" # extra args go to Playwright
#
# With --setup-only the script prepares the stack and exits without running
# Playwright — playwright.config.ts uses this in the backend webServer command
# so a plain `npm run test:e2e` also boots against a ready local stack.
#
# The first run rewrites the Supabase lines in your env files; the previous
# (e.g. hosted) versions are kept once as .env.hosted.bak / .env.local.hosted.bak.
# Restore those backups to point back at the hosted project.
set -euo pipefail
SETUP_ONLY=0
if [ "${1:-}" = "--setup-only" ]; then
SETUP_ONLY=1
shift
fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/backend"
FRONTEND="$ROOT/frontend"
if ! docker info >/dev/null 2>&1; then
echo "Docker is not running — start it first (open -a Docker) and retry." >&2
exit 1
fi
cd "$BACKEND"
if [ ! -f supabase/config.toml ]; then
npx supabase init --force --with-vscode-settings=false ||
npx supabase init --force
fi
# Idempotent: if the stack is already up this is a no-op.
npx supabase start
STATUS=$(npx supabase status -o json)
DB_URL=$(jq -r '.DB_URL' <<<"$STATUS")
API_URL=$(jq -r '.API_URL' <<<"$STATUS")
ANON_KEY=$(jq -r '.ANON_KEY' <<<"$STATUS")
SERVICE_KEY=$(jq -r '.SERVICE_ROLE_KEY' <<<"$STATUS")
# schema.sql is not idempotent, so only load it into a virgin database; the
# dated migrations ARE re-runnable and fill any gap schema.sql has (it lags —
# see docs/e2e-ci.md), so apply them every time.
if [ "$(psql "$DB_URL" -tAc "SELECT to_regclass('public.user_profiles') IS NULL")" = "t" ]; then
echo "Loading schema.sql into fresh database…"
psql "$DB_URL" -v ON_ERROR_STOP=1 -q -f schema.sql
fi
for m in migrations/*.sql; do
psql "$DB_URL" -q -f "$m" >/dev/null 2>&1 ||
echo "warning: migration returned non-zero (already applied?): $m"
done
# schema.sql grants these itself, but only for tables that existed when it
# ran — re-grant after migrations, with the same narrowed set (not ALL).
psql "$DB_URL" -v ON_ERROR_STOP=1 -q <<'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;
NOTIFY pgrst, 'reload schema';
SQL
# Rewrite only the Supabase lines of the env files, preserving everything else
# (API keys, R2 storage, …). Keep a one-time backup of the pre-local versions.
set_kv() {
local file=$1 key=$2 value=$3
if grep -q "^${key}=" "$file" 2>/dev/null; then
awk -v k="$key" -v v="$value" \
'index($0, k"=") == 1 { print k "=" v; next } { print }' \
"$file" >"$file.tmp" && mv "$file.tmp" "$file"
else
echo "${key}=${value}" >>"$file"
fi
}
[ -f .env ] || cp .env.example .env
[ -f .env.hosted.bak ] || cp .env .env.hosted.bak
set_kv .env SUPABASE_URL "$API_URL"
set_kv .env SUPABASE_SECRET_KEY "$SERVICE_KEY"
# The suite fires well over the backend's default 300-requests/15-min general
# cap in one run; once tripped every call 429s and profile/list waits time out.
# Same overrides CI uses — e2e is not testing throttling.
set_kv .env RATE_LIMIT_GENERAL_MAX 100000
set_kv .env RATE_LIMIT_CHAT_MAX 100000
set_kv .env RATE_LIMIT_CHAT_CREATE_MAX 100000
set_kv .env RATE_LIMIT_UPLOAD_MAX 100000
set_kv .env RATE_LIMIT_EXPORT_MAX 100000
set_kv .env RATE_LIMIT_DATA_DELETE_MAX 100000
touch "$FRONTEND/.env.local"
[ -f "$FRONTEND/.env.local.hosted.bak" ] || cp "$FRONTEND/.env.local" "$FRONTEND/.env.local.hosted.bak"
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_SUPABASE_URL "$API_URL"
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY "$ANON_KEY"
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_API_BASE_URL "http://localhost:3001"
echo "Local stack ready: $API_URL (db: ${DB_URL%%\?*})"
if [ "$SETUP_ONLY" = "1" ]; then
exit 0
fi
echo "NOTE: kill any backend/frontend dev servers started before this script —"
echo "they hold the old env. playwright.config.ts starts fresh ones if none run."
cd "$ROOT"
npx playwright test "$@"

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
},
"include": ["e2e/**/*.ts", "playwright.config.ts"]
}