mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
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:
commit
dad4b9d21a
22 changed files with 2532 additions and 26 deletions
224
.github/workflows/e2e.yml
vendored
Normal file
224
.github/workflows/e2e.yml
vendored
Normal 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
9
.gitignore
vendored
|
|
@ -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
94
docs/e2e-ci.md
Normal 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
174
e2e/auth-flows.spec.ts
Normal 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
110
e2e/auth.setup.ts
Normal 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
414
e2e/chat-management.spec.ts
Normal 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
181
e2e/critical-path.spec.ts
Normal 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
17
e2e/fixtures/test.pdf
Normal 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
18
e2e/llm.ts
Normal 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";
|
||||
342
e2e/project-management.spec.ts
Normal file
342
e2e/project-management.spec.ts
Normal 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
248
e2e/tabular-reviews.spec.ts
Normal 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 });
|
||||
});
|
||||
342
e2e/workflows-account.spec.ts
Normal file
342
e2e/workflows-account.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,9 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
14
frontend/src/app/components/shared/FullScreenLoader.tsx
Normal file
14
frontend/src/app/components/shared/FullScreenLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
113
package-lock.json
generated
Normal 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
19
package.json
Normal 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
70
playwright.config.ts
Normal 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
117
scripts/e2e-local-stack.sh
Executable 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
14
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue