diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35f4f0b..d3be22e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,30 +33,79 @@ jobs: frontend-checks: name: Frontend Checks runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json - + - name: Install dependencies working-directory: frontend run: npm ci - + - name: Run type check working-directory: frontend run: npm run type-check - + - name: Run Prettier check working-directory: frontend run: npm run format:check - + - name: Build working-directory: frontend run: npm run build + + e2e-tests: + name: E2E Tests (Playwright) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Create venv and install Python dependencies + run: | + python -m venv venv + venv/bin/pip install -r requirements.txt + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install npm dependencies + working-directory: frontend + run: npm ci + + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + working-directory: frontend + run: npm run test:e2e + env: + CI: true + PLAYWRIGHT_DB_FILE: /tmp/playwright-test.db + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5092ac7..c03c218 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,8 @@ us_map/ test_integration_output/ # Personal scratch directory for one-off scripts and experiments -scratch/ \ No newline at end of file +scratch/ + +# Playwright +playwright-report/ +test-results/ \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-26-playwright-ui-tests.md b/docs/superpowers/plans/2026-05-26-playwright-ui-tests.md new file mode 100644 index 0000000..c0f0b4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-playwright-ui-tests.md @@ -0,0 +1,1236 @@ +# Playwright UI Test Suite 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:** Scaffold a Playwright E2E test suite with real-backend integration covering feedback modal, student management, and worksheet print flows. + +**Architecture:** Single shared FastAPI process on :8182 with a SQLite test DB (`CURRICULUM_DB_PATH=/tmp/playwright-test.db`); Next.js on :3000 via `webServer` config. Packet data seeded via a Python seed script (`scripts/e2e_seed.py`) that calls `save_weekly_packet` directly. Test isolation via fixed student IDs per describe block (no per-test teardown). + +**Tech Stack:** Playwright, TypeScript, Next.js 16, FastAPI/uvicorn, SQLite, Python venv + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `frontend/package.json` | Modify | Add `@playwright/test` devDep + `test:e2e` scripts | +| `frontend/playwright.config.ts` | Create | Chromium only, globalSetup/Teardown, webServer | +| `frontend/global-setup.ts` | Create | Spawn uvicorn, poll until ready, write PID | +| `frontend/global-teardown.ts` | Create | Read PID, kill uvicorn | +| `scripts/e2e_seed.py` | Create | CLI seed helper — create student, create packet, backdate feedback | +| `frontend/e2e/fixtures/api.ts` | Create | Typed wrappers calling backend via Playwright request context + execSync | +| `frontend/e2e/feedback.spec.ts` | Create | Plans list, plan detail modal, feedback modal flows | +| `frontend/e2e/students.spec.ts` | Create | Student CRUD flows | +| `frontend/e2e/print.spec.ts` | Create | Worksheet print endpoint smoke test | + +--- + +### Task 1: Install Playwright and add npm scripts + +**Files:** +- Modify: `frontend/package.json` + +- [ ] **Step 1: Install Playwright** + +From `frontend/`: +```bash +cd frontend && npm install --save-dev @playwright/test +``` + +- [ ] **Step 2: Verify install** + +```bash +cd frontend && npx playwright --version +``` + +Expected: prints `Version 1.x.x` + +- [ ] **Step 3: Add scripts to `frontend/package.json`** + +In the `"scripts"` block, add: +```json +"test:e2e": "playwright test", +"test:e2e:ui": "playwright test --ui", +"test:e2e:report": "playwright show-report" +``` + +Final `scripts` block should look like: +```json +"scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "type-check": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" +} +``` + +- [ ] **Step 4: Install Chromium browser** + +```bash +cd frontend && npx playwright install chromium +``` + +Expected: downloads Chromium browser binaries. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "feat: install Playwright and add test:e2e scripts" +``` + +--- + +### Task 2: Create `frontend/global-setup.ts` + +**Files:** +- Create: `frontend/global-setup.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { spawn } from 'child_process'; +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; + +const PID_FILE = '/tmp/playwright-backend.pid'; +const DB_FILE = process.env.PLAYWRIGHT_DB_FILE ?? '/tmp/playwright-test.db'; +const BACKEND_PORT = 8182; +const POLL_INTERVAL_MS = 500; +const MAX_WAIT_MS = 30_000; + +async function waitForBackend(): Promise { + const deadline = Date.now() + MAX_WAIT_MS; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://localhost:${BACKEND_PORT}/`); + if (res.ok) return; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + throw new Error(`Backend did not start within ${MAX_WAIT_MS}ms`); +} + +export default async function globalSetup(): Promise { + const projectRoot = resolve(__dirname, '..'); + const uvicorn = spawn( + `${projectRoot}/venv/bin/uvicorn`, + ['src.main:app', '--port', String(BACKEND_PORT)], + { + cwd: projectRoot, + env: { ...process.env, CURRICULUM_DB_PATH: DB_FILE }, + detached: true, + stdio: 'ignore', + } + ); + + uvicorn.unref(); + writeFileSync(PID_FILE, String(uvicorn.pid)); + await waitForBackend(); +} +``` + +- [ ] **Step 2: Verify TypeScript parses it** + +```bash +cd frontend && npx tsc --noEmit global-setup.ts 2>&1 | head -20 +``` + +Expected: no errors (or only "Cannot find module" for imports that will be resolved at runtime — those are fine if `tsconfig.json` targets Node types). + +If you see `Cannot find name 'fetch'`, add `"lib": ["ES2022"]` or `"lib": ["ES2022", "DOM"]` to `frontend/tsconfig.json`'s `compilerOptions`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/global-setup.ts +git commit -m "feat: Playwright global-setup spawns uvicorn backend" +``` + +--- + +### Task 3: Create `frontend/global-teardown.ts` + +**Files:** +- Create: `frontend/global-teardown.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { readFileSync, existsSync } from 'fs'; + +const PID_FILE = '/tmp/playwright-backend.pid'; + +export default async function globalTeardown(): Promise { + if (!existsSync(PID_FILE)) return; + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // process may have already exited + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/global-teardown.ts +git commit -m "feat: Playwright global-teardown kills backend process" +``` + +--- + +### Task 4: Create `frontend/playwright.config.ts` + +**Files:** +- Create: `frontend/playwright.config.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: 'html', + + globalSetup: require.resolve('./global-setup'), + globalTeardown: require.resolve('./global-teardown'), + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'BACKEND_URL=http://localhost:8182 npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); +``` + +- [ ] **Step 2: Run a dry-check to confirm the config loads** + +```bash +cd frontend && npx playwright test --list 2>&1 | head -20 +``` + +Expected: no config errors. It may say "no tests found" — that's fine since we haven't written any yet. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/playwright.config.ts +git commit -m "feat: Playwright config — chromium, webServer, global setup/teardown" +``` + +--- + +### Task 5: Create `scripts/e2e_seed.py` + +This script is called from TypeScript fixtures via `execSync`. It talks directly to the Python internals (no HTTP) to seed the test DB reliably and quickly. + +**Files:** +- Create: `scripts/e2e_seed.py` + +- [ ] **Step 1: Create the file** + +```python +#!/usr/bin/env python3 +""" +CLI seed helper for Playwright E2E tests. + +Usage: + python scripts/e2e_seed.py create_packet + python scripts/e2e_seed.py backdate_feedback + +The DB path is controlled by CURRICULUM_DB_PATH (same env var the backend uses). +""" + +import json +import os +import sqlite3 +import sys +from pathlib import Path + +# Allow importing from src/ +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from packet_store import save_weekly_packet # noqa: E402 + + +def _db_path() -> str: + return os.environ.get("CURRICULUM_DB_PATH", str(PROJECT_ROOT / "curriculum.db")) + + +def create_packet(student_id: str, packet_id: str) -> None: + """Seed a minimal ready packet owned by student_id.""" + plan = { + "plan_id": packet_id, + "student_id": student_id, + "grade_level": 3, + "subject": "Mathematics", + "week_of": "2026-01-06", + "weekly_overview": "E2E test packet — multiplying fractions.", + "daily_plan": [ + { + "day": "Monday", + "focus": "Introduction", + "lesson_plan": { + "objective": "Understand fractions", + "procedure": ["Read introduction", "Do examples"], + }, + "standards": [{"standard_id": "MATH.3.1"}], + "resources": { + "mathWorksheet": { + "title": "Warmup", + "artifacts": [ + { + "type": "pdf", + "path": f"artifacts/{packet_id}/monday.pdf", + "size_bytes": 1024, + "sha256": "abc123", + } + ], + } + }, + "worksheet_plans": [{"kind": "mathWorksheet", "filename_hint": "warmup"}], + "resource_errors": [], + }, + { + "day": "Tuesday", + "focus": "Practice", + "lesson_plan": { + "objective": "Apply fraction rules", + "procedure": ["Complete worksheet"], + }, + "standards": [{"standard_id": "MATH.3.2"}], + "resources": { + "readingWorksheet": { + "title": "Story Problems", + "artifacts": [ + { + "type": "pdf", + "path": f"artifacts/{packet_id}/tuesday.pdf", + "size_bytes": 512, + "sha256": "def456", + } + ], + } + }, + "worksheet_plans": [{"kind": "readingWorksheet", "filename_hint": "story"}], + "resource_errors": [], + }, + ], + } + save_weekly_packet(plan, status="ready") + print(f"created packet {packet_id} for student {student_id}") + + +def backdate_feedback(packet_id: str, iso_timestamp: str) -> None: + """Set completed_at on a packet's feedback row to iso_timestamp.""" + db = _db_path() + conn = sqlite3.connect(db) + try: + conn.execute( + "UPDATE packet_feedback SET completed_at = ? WHERE packet_id = ?", + (iso_timestamp, packet_id), + ) + conn.commit() + finally: + conn.close() + print(f"backdated feedback for {packet_id} to {iso_timestamp}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "create_packet": + if len(sys.argv) != 4: + print("Usage: e2e_seed.py create_packet ") + sys.exit(1) + create_packet(sys.argv[2], sys.argv[3]) + + elif cmd == "backdate_feedback": + if len(sys.argv) != 4: + print("Usage: e2e_seed.py backdate_feedback ") + sys.exit(1) + backdate_feedback(sys.argv[2], sys.argv[3]) + + else: + print(f"Unknown command: {cmd}") + sys.exit(1) +``` + +- [ ] **Step 2: Smoke-test the script against the test DB** + +```bash +CURRICULUM_DB_PATH=/tmp/playwright-test.db python scripts/e2e_seed.py create_packet test-smoke-student test-smoke-packet-001 +``` + +Expected output: +``` +created packet test-smoke-packet-001 for student test-smoke-student +``` + +- [ ] **Step 3: Verify the row appeared** + +```bash +sqlite3 /tmp/playwright-test.db "SELECT packet_id, student_id, status FROM weekly_packets WHERE packet_id='test-smoke-packet-001';" +``` + +Expected: +``` +test-smoke-packet-001|test-smoke-student|ready +``` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/e2e_seed.py +git commit -m "feat: e2e_seed.py CLI helper for seeding Playwright test data" +``` + +--- + +### Task 6: Create `frontend/e2e/fixtures/api.ts` + +**Files:** +- Create: `frontend/e2e/fixtures/api.ts` + +The fixtures use Playwright's `request` context to talk directly to FastAPI (bypassing the Next.js proxy) for creating students and submitting feedback. Packet seeding uses `execSync` to call the Python seed script — this is intentional because `save_weekly_packet` is faster and more reliable than the LLM-backed plan generation endpoint. + +- [ ] **Step 1: Create the directory** + +```bash +mkdir -p frontend/e2e/fixtures +``` + +- [ ] **Step 2: Create the file** + +```typescript +import { APIRequestContext } from '@playwright/test'; +import { execSync } from 'child_process'; +import { resolve } from 'path'; + +const BACKEND = 'http://localhost:8182'; +const DB_FILE = process.env.PLAYWRIGHT_DB_FILE ?? '/tmp/playwright-test.db'; +const PROJECT_ROOT = resolve(__dirname, '../../..'); + +function seedScript(args: string): void { + execSync( + `CURRICULUM_DB_PATH="${DB_FILE}" "${PROJECT_ROOT}/venv/bin/python" "${PROJECT_ROOT}/scripts/e2e_seed.py" ${args}`, + { stdio: 'inherit' } + ); +} + +export async function createStudent( + request: APIRequestContext, + id: string, + opts: { name: string; birthday: string } +): Promise { + const res = await request.post(`${BACKEND}/students`, { + data: { + student_id: id, + metadata: { name: opts.name, birthday: opts.birthday }, + }, + }); + if (!res.ok()) { + throw new Error(`createStudent failed: ${res.status()} ${await res.text()}`); + } +} + +export async function createPacket( + _request: APIRequestContext, + studentId: string, + packetId: string +): Promise<{ packet_id: string }> { + seedScript(`create_packet "${studentId}" "${packetId}"`); + return { packet_id: packetId }; +} + +export async function submitFeedback( + request: APIRequestContext, + studentId: string, + packetId: string, + ratings: { mastery: string; quantity: number } +): Promise { + const res = await request.post( + `${BACKEND}/students/${studentId}/weekly-packets/${packetId}/feedback`, + { + data: { + mastery_feedback: { overall: ratings.mastery }, + quantity_feedback: ratings.quantity, + }, + } + ); + if (!res.ok()) { + throw new Error(`submitFeedback failed: ${res.status()} ${await res.text()}`); + } +} + +export function backdateFeedback(packetId: string, isoTimestamp: string): void { + seedScript(`backdate_feedback "${packetId}" "${isoTimestamp}"`); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/e2e/fixtures/api.ts +git commit -m "feat: Playwright seed fixture helpers (createStudent, createPacket, submitFeedback, backdateFeedback)" +``` + +--- + +### Task 7: Create `frontend/e2e/feedback.spec.ts` + +**Files:** +- Create: `frontend/e2e/feedback.spec.ts` + +Each `describe` block uses a unique student ID derived from the block name so tests are fully independent and parallelisable. + +- [ ] **Step 1: Create the file** + +```typescript +import { test, expect } from '@playwright/test'; +import { + createStudent, + createPacket, + submitFeedback, + backdateFeedback, +} from './fixtures/api'; + +// --------------------------------------------------------------------------- +// Plans list — empty state +// --------------------------------------------------------------------------- +test.describe('Plans page — empty state', () => { + const STUDENT_ID = 'e2e-feedback-empty-a1b2'; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Empty State Student', + birthday: '2018-01-01', + }); + }); + + test('shows empty state when no packets exist', async ({ page }) => { + await page.goto('/plans'); + await expect(page.getByText('No Plans Yet')).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Plans list — pending packet card +// --------------------------------------------------------------------------- +test.describe('Plans page — pending packet card', () => { + const STUDENT_ID = 'e2e-feedback-card-c3d4'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Card Test Student', + birthday: '2018-02-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + }); + + test('renders a pending packet card with name, subject, grade, days, worksheet counts', async ({ + page, + }) => { + await page.goto('/plans'); + await expect(page.getByText('Card Test Student')).toBeVisible(); + await expect(page.getByText('Mathematics')).toBeVisible(); + await expect(page.getByText('3')).toBeVisible(); // grade_level + // Days = 2 (Monday + Tuesday in seed) + const daysBadge = page.locator('text=Days').locator('..').getByText('2'); + await expect(daysBadge).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Plan detail modal +// --------------------------------------------------------------------------- +test.describe('Plan detail modal', () => { + const STUDENT_ID = 'e2e-feedback-modal-e5f6'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Detail Modal Student', + birthday: '2018-03-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + }); + + test('clicking a pending packet card opens the plan detail modal', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByText('Plan Details')).toBeVisible(); + }); + + test('shows student name, week, subject, grade', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + const modal = page.getByRole('dialog'); + await expect(modal.getByText('Detail Modal Student')).toBeVisible(); + await expect(modal.getByText('Mathematics')).toBeVisible(); + await expect(modal.getByText('3')).toBeVisible(); + }); + + test('shows daily plan content — day label, focus, objective, procedure steps', async ({ + page, + }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + const modal = page.getByRole('dialog'); + await expect(modal.getByText('Monday')).toBeVisible(); + await expect(modal.getByText('Introduction')).toBeVisible(); + await expect(modal.getByText('Understand fractions')).toBeVisible(); + await expect(modal.getByText('Read introduction')).toBeVisible(); + }); + + test('Close button dismisses the modal', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('clicking the backdrop dismisses the modal', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + // Click outside the modal panel (top-left corner of viewport) + await page.mouse.click(10, 10); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('Escape key dismisses the modal', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('Print All button opens the print URL in a new tab', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.getByRole('button', { name: 'Print All' }).click(), + ]); + await expect(popup).toHaveURL(/\/print/); + }); + + test('shows "Provide Feedback" for a ready packet with no feedback', async ({ page }) => { + await page.goto('/plans'); + await page.getByText('Detail Modal Student').click(); + await expect(page.getByRole('button', { name: 'Provide Feedback' })).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Plan detail modal — "Edit Feedback" state +// --------------------------------------------------------------------------- +test.describe('Plan detail modal — existing feedback', () => { + const STUDENT_ID = 'e2e-feedback-edit-g7h8'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Edit Feedback Student', + birthday: '2018-04-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + await submitFeedback(request, STUDENT_ID, PACKET_ID, { + mastery: 'MASTERED', + quantity: 0, + }); + }); + + test('shows "Edit Feedback" for a ready packet with existing feedback (not locked)', async ({ + page, + }) => { + await page.goto('/plans'); + await page.getByText('Edit Feedback Student').click(); + await expect(page.getByRole('button', { name: 'Edit Feedback' })).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Plan detail modal — locked feedback state +// --------------------------------------------------------------------------- +test.describe('Plan detail modal — locked feedback', () => { + const STUDENT_ID = 'e2e-feedback-locked-i9j0'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Locked Feedback Student', + birthday: '2018-05-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + await submitFeedback(request, STUDENT_ID, PACKET_ID, { + mastery: 'DEVELOPING', + quantity: 1, + }); + backdateFeedback(PACKET_ID, '2026-01-01T00:00:00Z'); + }); + + test('shows disabled "Feedback Submitted" for a packet with feedback older than 3 weeks', async ({ + page, + }) => { + await page.goto('/plans'); + await page.getByText('Locked Feedback Student').click(); + const btn = page.getByRole('button', { name: 'Feedback Submitted' }); + await expect(btn).toBeVisible(); + await expect(btn).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Feedback modal — first submission +// --------------------------------------------------------------------------- +test.describe('Feedback modal — first submission', () => { + const STUDENT_ID = 'e2e-feedback-submit-k1l2'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Submit Feedback Student', + birthday: '2018-06-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + }); + + async function openFeedbackModal(page: import('@playwright/test').Page) { + await page.goto('/plans'); + await page.getByText('Submit Feedback Student').click(); + await page.getByRole('button', { name: 'Provide Feedback' }).click(); + } + + test('title reads "Provide Feedback"', async ({ page }) => { + await openFeedbackModal(page); + await expect(page.getByRole('dialog').getByText('Provide Feedback')).toBeVisible(); + }); + + test('Submit button is disabled until both ratings are selected', async ({ page }) => { + await openFeedbackModal(page); + const submit = page.getByRole('button', { name: 'Submit Feedback' }); + await expect(submit).toBeDisabled(); + + await page.getByRole('button', { name: 'Mastered' }).click(); + await expect(submit).toBeDisabled(); // still missing workload + + await page.getByRole('button', { name: 'Just Right' }).click(); + await expect(submit).toBeEnabled(); + }); + + test('selecting a mastery rating highlights it with a ring', async ({ page }) => { + await openFeedbackModal(page); + const btn = page.getByRole('button', { name: 'Mastered' }); + await btn.click(); + await expect(btn).toHaveClass(/ring-2/); + }); + + test('selecting a workload rating highlights it', async ({ page }) => { + await openFeedbackModal(page); + const btn = page.getByRole('button', { name: 'Too Much' }); + await btn.click(); + await expect(btn).toHaveClass(/ring-2/); + }); + + test('Cancel button closes the modal without submitting', async ({ page }) => { + await openFeedbackModal(page); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('Escape key closes the modal', async ({ page }) => { + await openFeedbackModal(page); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('backdrop click closes the modal', async ({ page }) => { + await openFeedbackModal(page); + await page.mouse.click(10, 10); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('submitting closes the modal and the button changes to "Edit Feedback"', async ({ + page, + }) => { + await openFeedbackModal(page); + await page.getByRole('button', { name: 'Mastered' }).click(); + await page.getByRole('button', { name: 'Just Right' }).click(); + await page.getByRole('button', { name: 'Submit Feedback' }).click(); + + // Modal closes and plan list refreshes — now the packet shows Edit Feedback + await page.getByText('Submit Feedback Student').click(); + await expect(page.getByRole('button', { name: 'Edit Feedback' })).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Feedback modal — editing existing feedback +// --------------------------------------------------------------------------- +test.describe('Feedback modal — editing existing feedback', () => { + const STUDENT_ID = 'e2e-feedback-resubmit-m3n4'; + const PACKET_ID = `${STUDENT_ID}-pkt-001`; + + test.beforeAll(async ({ request }) => { + await createStudent(request, STUDENT_ID, { + name: 'Resubmit Feedback Student', + birthday: '2018-07-01', + }); + await createPacket(request, STUDENT_ID, PACKET_ID); + await submitFeedback(request, STUDENT_ID, PACKET_ID, { + mastery: 'STRUGGLING', + quantity: 2, + }); + }); + + async function openEditModal(page: import('@playwright/test').Page) { + await page.goto('/plans'); + await page.getByText('Resubmit Feedback Student').click(); + await page.getByRole('button', { name: 'Edit Feedback' }).click(); + } + + test('title reads "Edit Feedback"', async ({ page }) => { + await openEditModal(page); + await expect(page.getByRole('dialog').getByText('Edit Feedback')).toBeVisible(); + }); + + test('mastery and workload ratings are pre-populated from the stored values', async ({ + page, + }) => { + await openEditModal(page); + // mastery was STRUGGLING + await expect(page.getByRole('button', { name: 'Struggling' })).toHaveClass(/ring-2/); + // quantity was 2 → TOO_LITTLE + await expect(page.getByRole('button', { name: 'Too Little' })).toHaveClass(/ring-2/); + }); + + test('Submit button reads "Update Feedback"', async ({ page }) => { + await openEditModal(page); + await expect(page.getByRole('button', { name: 'Update Feedback' })).toBeVisible(); + }); + + test('changing a rating and submitting succeeds', async ({ page }) => { + await openEditModal(page); + await page.getByRole('button', { name: 'Mastered' }).click(); + await page.getByRole('button', { name: 'Just Right' }).click(); + await page.getByRole('button', { name: 'Update Feedback' }).click(); + // After update, modal closes + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run just this spec to verify it parses and tests can be discovered** + +```bash +cd frontend && npx playwright test e2e/feedback.spec.ts --list 2>&1 | head -50 +``` + +Expected: list of test names with no parse errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/e2e/feedback.spec.ts +git commit -m "feat: Playwright feedback.spec.ts — plans list, detail modal, feedback modal flows" +``` + +--- + +### Task 8: Create `frontend/e2e/students.spec.ts` + +**Files:** +- Create: `frontend/e2e/students.spec.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { test, expect } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Student management — empty state +// --------------------------------------------------------------------------- +test.describe('Student management — empty state', () => { + test('shows "No Students Yet" and an "Add Student" button', async ({ page }) => { + // This test navigates directly — it only passes reliably if the test DB + // is fresh (CI) or the student ID namespace hasn't been polluted. + // We rely on the test DB being isolated from production. + await page.goto('/students'); + // The page may have students from other describe blocks — just verify + // the "Add Student" header button is always present. + await expect(page.getByRole('button', { name: 'Add Student' }).first()).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Student management — creating a student +// --------------------------------------------------------------------------- +test.describe('Student management — creating a student', () => { + test('"Add Student" header button opens the modal titled "Add New Student"', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await expect(page.getByRole('dialog').getByText('Add New Student')).toBeVisible(); + }); + + test('shows validation error for Student ID with invalid characters', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await page.getByLabel('Student ID').fill('Invalid ID!'); + await page.getByLabel('Full Name').fill('Test'); + await page.getByLabel('Birthday').fill('2018-01-01'); + await page.getByRole('button', { name: 'Create Student' }).click(); + await expect( + page.getByText('Use lowercase letters, numbers, and underscores only') + ).toBeVisible(); + }); + + test('shows validation error for missing required fields', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await page.getByRole('button', { name: 'Create Student' }).click(); + await expect(page.getByText('Student ID is required')).toBeVisible(); + await expect(page.getByText('Name is required')).toBeVisible(); + }); + + test('shows validation error for a badly formatted birthday', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await page.getByLabel('Student ID').fill('test_bad_bday'); + await page.getByLabel('Full Name').fill('Test Name'); + await page.getByLabel('Birthday').fill('not-a-date'); + await page.getByRole('button', { name: 'Create Student' }).click(); + await expect(page.getByText('Format: YYYY-MM-DD')).toBeVisible(); + }); + + test('successfully creates a student and it appears in the list', async ({ page }) => { + const uniqueId = `e2e_create_${Date.now()}`; + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await page.getByLabel('Student ID').fill(uniqueId); + await page.getByLabel('Full Name').fill('Created Student'); + await page.getByLabel('Birthday').fill('2018-01-15'); + await page.getByRole('button', { name: 'Create Student' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByText('Created Student')).toBeVisible(); + }); + + test('Cancel button closes the modal without creating', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('Escape key closes the modal', async ({ page }) => { + await page.goto('/students'); + await page.getByRole('button', { name: 'Add Student' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Student management — editing a student +// --------------------------------------------------------------------------- +test.describe('Student management — editing a student', () => { + const STUDENT_ID = 'e2e_edit_student_p5q6'; + const STUDENT_NAME = 'Edit Target Student'; + + test.beforeAll(async ({ request }) => { + // Create via API directly so we control the student ID + const res = await request.post('http://localhost:8182/students', { + data: { + student_id: STUDENT_ID, + metadata: { name: STUDENT_NAME, birthday: '2018-08-01' }, + }, + }); + // 400 means already exists from a previous run — that's fine + if (!res.ok() && res.status() !== 400) { + throw new Error(`Failed to create student: ${res.status()} ${await res.text()}`); + } + }); + + test('"Edit" button opens the modal titled "Edit Student"', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog').getByText('Edit Student')).toBeVisible(); + }); + + test('Student ID field is disabled (cannot be changed)', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByLabel('Student ID')).toBeDisabled(); + }); + + test('pre-populates all editable fields from existing data', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByLabel('Full Name')).toHaveValue(STUDENT_NAME); + await expect(page.getByLabel('Birthday')).toHaveValue('2018-08-01'); + }); + + test("saving updates the student's name in the list", async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Edit' }).click(); + await page.getByLabel('Full Name').fill('Renamed Student'); + await page.getByRole('button', { name: 'Update Student' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByText('Renamed Student')).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Student management — deleting a student +// --------------------------------------------------------------------------- +test.describe('Student management — deleting a student', () => { + const STUDENT_ID = 'e2e_delete_student_r7s8'; + const STUDENT_NAME = 'Delete Target Student'; + + test.beforeAll(async ({ request }) => { + const res = await request.post('http://localhost:8182/students', { + data: { + student_id: STUDENT_ID, + metadata: { name: STUDENT_NAME, birthday: '2018-09-01' }, + }, + }); + if (!res.ok() && res.status() !== 400) { + throw new Error(`Failed to create student: ${res.status()} ${await res.text()}`); + } + }); + + test('"Delete" button opens the confirmation modal', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByRole('dialog').getByText('Delete Student')).toBeVisible(); + }); + + test('Cancel button closes confirmation without deleting', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByText(STUDENT_NAME)).toBeVisible(); + }); + + test('confirming delete removes the student from the list', async ({ page }) => { + await page.goto('/students'); + const row = page.locator('div').filter({ hasText: STUDENT_NAME }).first(); + await row.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete Student' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByText(STUDENT_NAME)).not.toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Verify tests are discoverable** + +```bash +cd frontend && npx playwright test e2e/students.spec.ts --list 2>&1 | head -50 +``` + +Expected: list of all test names with no parse errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/e2e/students.spec.ts +git commit -m "feat: Playwright students.spec.ts — CRUD flows" +``` + +--- + +### Task 9: Create `frontend/e2e/print.spec.ts` + +**Files:** +- Create: `frontend/e2e/print.spec.ts` + +**Important constraint:** The print endpoint (`GET /students/{id}/weekly-packets/{id}/print`) returns 404 if there are no HTML worksheet artifact files on disk (only PDF/PNG paths are seeded by `e2e_seed.py`). Therefore: +- Tests that assert on `.day-header` elements or rendered HTML content **cannot** work without real on-disk HTML files. +- The spec below tests only what works without on-disk files: the CSS `