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