diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29045eb..2a94ae9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,11 +90,61 @@ jobs: - name: Build run: npm run build + e2e: + name: E2E smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: datashield + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci -d datashield" + --health-interval 3s + --health-timeout 3s + --health-retries 20 + env: + DATABASE_URL: postgresql://ci:ci@localhost:5432/datashield + AUTH_SECRET: ci-placeholder-auth-secret + AUTH_URL: http://localhost:3000 + DIRECTORY_ENCRYPTION_KEY: ci-placeholder-encryption-key-32-chars-minimum + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Apply migrations + run: npx prisma migrate deploy + + - name: Seed admin account + run: npx tsx prisma/seed.ts + + - name: Build + run: npm run build + + - name: Install chromium + run: npx playwright install --with-deps chromium + + - name: Smoke test + run: npx playwright test + # Aggregate gate required by the main branch protection rule. - # Reports the "ci" status check, green only when quality and build pass. + # Reports the "ci" status check, green only when all gates pass. ci: name: ci runs-on: ubuntu-latest - needs: [quality, test, build] + needs: [quality, test, build, e2e] steps: - - run: echo "Quality, test and build gates passed." + - run: echo "Quality, test, build and e2e gates passed." diff --git a/.gitignore b/.gitignore index d77485d..28e8011 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ next-env.d.ts # database dumps (contain PII, never commit) backups/ +# playwright +playwright-report/ +test-results/ + # private ROADMAP.md prisma/seed.dev.ts diff --git a/Makefile b/Makefile index 9770589..2bd01a3 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,10 @@ lint-fix: ## Lint and auto-fix test: ## Run the test suite $(N) npm test +.PHONY: e2e +e2e: ## Run the Playwright smoke test (app must be reachable on :3000) + $(N) npm run test:e2e + .PHONY: check check: ## Run the same gates CI enforces (lint, types, schema, build) $(N) npm run lint -- --max-warnings 0 diff --git a/docs/superpowers/plans/2026-07-11-e2e-smoke.md b/docs/superpowers/plans/2026-07-11-e2e-smoke.md new file mode 100644 index 0000000..97a095b --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-e2e-smoke.md @@ -0,0 +1,215 @@ +# E2E Smoke Test Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One Playwright smoke spec (login -> dashboard -> alerts, CSP console guard) running locally against the compose stack and as a blocking CI job. + +**Architecture:** `@playwright/test` with a single chromium project; `playwright.config.ts` reuses the local server, starts `next start` in CI; new `e2e` CI job with a postgres service; aggregate `ci` job gains the dependency. + +**Tech Stack:** Playwright, GitHub Actions, postgres:16 service container. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-11-e2e-smoke-design.md`. Branch: `feat/e2e-smoke`. +- ASCII only. Seeded admin: `admin@datashield.local` / `ChangeMe123!` (defaults in `prisma/seed.ts`). +- CI placeholders identical to the existing `build` job (`DATABASE_URL`, `AUTH_SECRET`, `DIRECTORY_ENCRYPTION_KEY`). +- Gates: `npm run lint -- --max-warnings 0`, `npx tsc --noEmit`, `npm test` (still 148 on this branch). + +--- + +### Task 1: Playwright setup and smoke spec + +**Files:** +- Create: `playwright.config.ts` +- Create: `e2e/smoke.spec.ts` +- Modify: `package.json` (devDependency + `test:e2e` script) +- Modify: `.gitignore` (playwright artifacts) +- Modify: `Makefile` (after the `test` target) + +**Interfaces:** +- Produces: `npm run test:e2e` / `make e2e`; Task 2's CI job runs `npx playwright test`. + +- [ ] **Step 1: Install Playwright** + +Run: `npm install -D @playwright/test && npx playwright install chromium` +Expected: devDependency added, chromium downloaded. + +- [ ] **Step 2: Write `playwright.config.ts`** + +```ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "e2e", + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npm run start", + url: "http://localhost:3000/api/health", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) +``` + +- [ ] **Step 3: Write `e2e/smoke.spec.ts`** + +```ts +import { test, expect, type Page } from "@playwright/test" + +const cspViolations: string[] = [] + +function watchConsole(page: Page) { + page.on("console", (msg) => { + const text = msg.text() + if (text.includes("Content Security Policy") || text.includes("Refused to")) { + cspViolations.push(text) + } + }) +} + +test("admin signs in, sees dashboard and alerts", async ({ page }) => { + watchConsole(page) + + await page.goto("/login") + await page.getByLabel("Email").fill("admin@datashield.local") + await page.getByLabel("Password").fill("ChangeMe123!") + await page.getByRole("button", { name: "Sign in" }).click() + + await page.waitForURL("**/dashboard") + await expect(page.getByRole("main")).toBeVisible() + + await page.goto("/alerts") + await expect(page.getByRole("main")).toBeVisible() + + expect(cspViolations).toEqual([]) +}) +``` + +If `getByRole("main")` does not exist on those pages, use a heading or a +`data-slot` the pages already render; check the DOM rather than adding +test-ids. + +- [ ] **Step 4: Wire package.json, Makefile, .gitignore** + +`package.json` scripts, after `"test:watch"`: + +```json + "test:e2e": "playwright test", +``` + +`Makefile`, after the `test` target: + +```make +.PHONY: e2e +e2e: ## Run the Playwright smoke test (app must be reachable on :3000) + $(N) npm run test:e2e +``` + +`.gitignore`, after the backups block: + +``` +# playwright +playwright-report/ +test-results/ +``` + +- [ ] **Step 5: Run locally against the compose stack** + +Run: `npx playwright test` +Expected: 1 passed. The local dev server on :3000 is reused. + +- [ ] **Step 6: Gates and commit** + +Run: `npm run lint -- --max-warnings 0 && npx tsc --noEmit && npm test` +Expected: clean, 148 tests. + +```bash +git add playwright.config.ts e2e/smoke.spec.ts package.json package-lock.json Makefile .gitignore +git commit -m "feat: add Playwright smoke test" +``` + +### Task 2: CI job + +**Files:** +- Modify: `.github/workflows/ci.yml` (new `e2e` job before the aggregate; aggregate `needs` gains `e2e`) + +**Interfaces:** +- Consumes: `npx playwright test` from Task 1. + +- [ ] **Step 1: Add the `e2e` job** + +Insert after the `build` job: + +```yaml + e2e: + name: E2E smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: datashield + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci -d datashield" + --health-interval 3s + --health-timeout 3s + --health-retries 20 + env: + DATABASE_URL: postgresql://ci:ci@localhost:5432/datashield + AUTH_SECRET: ci-placeholder-auth-secret + AUTH_URL: http://localhost:3000 + DIRECTORY_ENCRYPTION_KEY: ci-placeholder-encryption-key-32-chars-minimum + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Apply migrations + run: npx prisma migrate deploy + + - name: Seed admin account + run: npx tsx prisma/seed.ts + + - name: Build + run: npm run build + + - name: Install chromium + run: npx playwright install --with-deps chromium + + - name: Smoke test + run: npx playwright test +``` + +- [ ] **Step 2: Gate the aggregate** + +Replace `needs: [quality, test, build]` with +`needs: [quality, test, build, e2e]` and update the final echo to mention +e2e. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: run e2e smoke test as blocking gate" +``` + +CI verification happens at the final push (nothing is pushed before the +roadmap ends). diff --git a/docs/superpowers/specs/2026-07-11-e2e-smoke-design.md b/docs/superpowers/specs/2026-07-11-e2e-smoke-design.md new file mode 100644 index 0000000..bc6eab9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-e2e-smoke-design.md @@ -0,0 +1,58 @@ +# E2E smoke test - design + +Date: 2026-07-11. Roadmap V1 step 4: one minimal end-to-end test that proves +login, dashboard, and alerts work in a real browser, wired as a blocking CI +gate. + +## Goal + +Catch the regressions unit tests cannot see (auth flow, middleware, CSP, +hydration) with a single stable spec. Deliberately minimal: one spec file, +one browser (chromium). Broader coverage is v1.1 territory. + +## Decisions + +- Tool: `@playwright/test` (devDependency), chromium only. +- One spec, `e2e/smoke.spec.ts`: sign in with the seeded admin + (`admin@datashield.local` / `ChangeMe123!`), assert the dashboard renders, + navigate to the alerts page, assert it renders. +- The spec collects browser console messages and fails on any CSP violation + (`Refused to` / `Content Security Policy`), so the strict CSP from step 2 + stays continuously verified. +- `playwright.config.ts`: `testDir: "e2e"`, `baseURL` + `http://localhost:3000`, `webServer` runs `npm run start` with + `reuseExistingServer: !process.env.CI` (locally it reuses the running + compose stack; in CI it starts the built app). +- CI: new `e2e` job (postgres:16 service, migrate, seed, build, playwright + install chromium, run tests), added to the aggregate `ci` job's `needs`, + so it blocks merges like the other gates. +- Vitest is untouched: its include is `src/**/*.test.ts`, the e2e dir uses + `.spec.ts`. +- Stable selectors: `getByLabel("Email")`, `getByLabel("Password")`, + `getByRole("button", { name: "Sign in" })` (labels exist in + `src/app/(auth)/login/page.tsx`). + +## Files + +| File | Change | +| --- | --- | +| `playwright.config.ts` | New. testDir, baseURL, webServer, chromium project. | +| `e2e/smoke.spec.ts` | New. The smoke spec with CSP console guard. | +| `package.json` | devDependency `@playwright/test`; script `test:e2e`. | +| `Makefile` | `e2e` target. | +| `.gitignore` | `playwright-report/`, `test-results/`. | +| `.github/workflows/ci.yml` | New `e2e` job; aggregate `ci` needs it. | + +## Error handling + +None custom; Playwright reports failures with traces +(`trace: "on-first-retry"`). + +## Verification + +- Local: `npx playwright test` against the running compose stack, 1 spec + green. +- Gates: lint, tsc (playwright config and spec are type-checked), vitest + count unchanged. +- CI: the `e2e` job goes green on the final push (verified at the end of + the roadmap since nothing is pushed before then). diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..b49961f --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "@playwright/test" + +let cspViolations: string[] + +test.beforeEach(({ page }) => { + cspViolations = [] + page.on("console", (msg) => { + const text = msg.text() + if (text.includes("Content Security Policy") || text.includes("Refused to")) { + cspViolations.push(text) + } + }) +}) + +test.afterEach(() => { + expect(cspViolations).toEqual([]) +}) + +test("admin signs in, sees dashboard and alerts", async ({ page }) => { + await page.goto("/login") + await page.getByLabel("Email").fill("admin@datashield.local") + await page.getByLabel("Password").fill("ChangeMe123!") + await page.getByRole("button", { name: "Sign in" }).click() + + await page.waitForURL("**/dashboard") + await expect(page.getByRole("main")).toBeVisible() + + await page.goto("/alerts") + await expect(page.getByRole("main")).toBeVisible() +}) diff --git a/package-lock.json b/package-lock.json index f0fb0e9..caccf65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", + "@playwright/test": "^1.61.1", "@tailwindcss/container-queries": "^0.1.1", "@types/bcryptjs": "^3.0.0", "@types/node": "^26.1.0", @@ -2137,6 +2138,22 @@ "url": "https://github.com/sponsors/panva" } }, + "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==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -8628,6 +8645,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": 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==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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/png-js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", diff --git a/package.json b/package.json index 04ea7ab..bbdd963 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", "seed": "dotenv -e .env.local -- tsx prisma/seed.ts", "db:up": "docker compose up -d db", "db:down": "docker compose down", @@ -66,6 +67,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", + "@playwright/test": "^1.61.1", "@tailwindcss/container-queries": "^0.1.1", "@types/bcryptjs": "^3.0.0", "@types/node": "^26.1.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..fdd97ea --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "e2e", + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npm run start", + url: "http://localhost:3000/api/health", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +})