diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43195c3..47d3869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,51 @@ jobs: - name: Test with coverage run: npm run coverage + + # --------------------------------------------------------------------------- + # E2E (Playwright): browser-driven checks the unit suites can't catch — + # see frontend/e2e/ for what each spec guards against. NOT a required/ + # blocking gate yet (continue-on-error): it should run and report for a + # while before it can fail anyone's merge. Promoting it: drop + # `continue-on-error`, add this job to the branch protection's required + # checks, and confirm several consecutive green runs first. + # --------------------------------------------------------------------------- + e2e: + name: E2E (Playwright) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install backend dependencies + working-directory: backend + run: uv sync + + - name: Install frontend dependencies + working-directory: frontend + run: npm install + + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Run Playwright E2E suite + working-directory: frontend + run: npm run e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts new file mode 100644 index 0000000..0837e50 --- /dev/null +++ b/frontend/e2e/auth.setup.ts @@ -0,0 +1,29 @@ +/** + * Playwright "setup" project: logs in through the real UI as the seeded + * demo user and saves the resulting storage state (the httpOnly refresh + * cookie + the `workspaceId` the app writes to localStorage on login) for + * every other project to reuse. + * + * The access token itself lives only in React state (see AuthContext.tsx) + * so it can't be persisted this way — but that's fine: on the next page + * load, `AuthProvider` calls `POST /api/auth/refresh` using the persisted + * refresh cookie and re-obtains one automatically. + */ +import { test as setup, expect } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { DEMO_EMAIL, DEMO_PASSWORD } from './support/api' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +export const AUTH_FILE = path.join(__dirname, '.auth/user.json') + +setup('authenticate as the seeded demo user', async ({ page }) => { + await page.goto('/auth') + await page.getByTestId('auth-email').fill(DEMO_EMAIL) + await page.getByTestId('auth-password').fill(DEMO_PASSWORD) + await page.getByTestId('auth-submit').click() + + await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 }) + + await page.context().storageState({ path: AUTH_FILE }) +}) diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..eac7ff9 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,103 @@ +/** + * Runs once before the whole E2E suite. Two jobs: + * + * 1. Seed a known-good demo workspace deterministically, by shelling out to + * the same `backend/scripts/seed_demo.py --reset` a human would run — + * see that script's docstring for exactly what it creates (couple + * workspace, Alex/Sam/Nova, wishes, overrides, a few applied days). + * + * 2. Materialize one extra, deterministic multi-week band of planned leave + * days via the API (never by clicking through the UI) — the calendar + * only renders committed `planned_leave_day` rows, not raw wishes, and + * the seed script's own applied days are only 3 days long (too short to + * exercise cross-week-row banding). The band's dates are computed + * relative to "today", never hardcoded, and written to + * `e2e/.auth/fixture.json` for `calendar-band.spec.ts` to read. + * + * Defensive design: this polls the backend's health endpoint before doing + * anything else, regardless of what order Playwright happens to start + * `webServer` vs. `globalSetup` in — a setup failure here should always be + * "the backend never came up", never a flaky race. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { apiCreatePlannedDay, apiGetContext, apiLogin, DEMO_EMAIL, DEMO_PASSWORD, waitForBackendHealth } from './support/api' +import { FIXTURE_PATH } from './support/fixture' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const BACKEND_DIR = path.resolve(__dirname, '../../backend') + +// A deterministic multi-week band, independent of the leave-wish allocation +// engine's own date choices: 15 consecutive WEEKDAYS (~3 calendar-week +// rows), starting on the 1st of the month that is two months after +// "today" — always safely in the future, always entirely inside one +// calendar month (so the Calendar page shows both the band's true start and +// true end in a single month view), and never tied to a hardcoded year. +function computeBandDates(): { startDate: string; endDate: string; dates: string[] } { + const now = new Date() + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1)) + const dates: string[] = [] + const cursor = new Date(monthStart.getTime()) + while (dates.length < 15) { + const weekday = cursor.getUTCDay() + if (weekday !== 0 && weekday !== 6) { + dates.push(cursor.toISOString().slice(0, 10)) + } + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + const first = dates[0] + const last = dates[dates.length - 1] + if (!first || !last) throw new Error('failed to compute band dates') + return { startDate: first, endDate: last, dates } +} + +export default async function globalSetup(): Promise { + await waitForBackendHealth() + + execFileSync('uv', ['run', 'python', 'scripts/seed_demo.py', '--reset'], { + cwd: BACKEND_DIR, + stdio: 'inherit', + }) + + const token = await apiLogin(DEMO_EMAIL, DEMO_PASSWORD) + const context = await apiGetContext(token) + const child = context.children[0] + if (!child) { + throw new Error('seed_demo.py did not create a child for the demo workspace') + } + + const band = computeBandDates() + for (const date of band.dates) { + await apiCreatePlannedDay(token, { + workspace_id: context.workspace_id, + child_id: child.id, + parent_personal_data_uuid: context.personal_data_uuid, + date, + day_type: 'vacation', + is_dubbeldag: false, + }) + } + + mkdirSync(path.dirname(FIXTURE_PATH), { recursive: true }) + writeFileSync( + FIXTURE_PATH, + JSON.stringify( + { + workspaceId: context.workspace_id, + childId: child.id, + personalDataUuid: context.personal_data_uuid, + childBirthDate: child.birth_date, + band: { + startDate: band.startDate, + endDate: band.endDate, + parentPersonalDataUuid: context.personal_data_uuid, + dayType: 'vacation', + }, + }, + null, + 2, + ), + ) +} diff --git a/frontend/e2e/support/api.ts b/frontend/e2e/support/api.ts new file mode 100644 index 0000000..82486e7 --- /dev/null +++ b/frontend/e2e/support/api.ts @@ -0,0 +1,120 @@ +/** + * Minimal, dependency-free HTTP helpers for talking to the backend directly + * (bypassing the browser) during E2E setup and ground-truth assertions. + * + * Deliberately hits the backend at 127.0.0.1:8000, not through the Vite + * proxy: these calls run in plain Node (global-setup) or alongside a + * Playwright test (spec files), never inside a browser page, so there is no + * CORS concern and no need for the proxy or the httpOnly refresh cookie — + * just a Bearer access token obtained via `apiLogin`. + */ + +export const API_BASE = 'http://127.0.0.1:8000' + +// Seeded by `backend/scripts/seed_demo.py` — see that file's module +// docstring. Not a secret: dev-only, fixed, printed by the script itself. +export const DEMO_EMAIL = 'demo@example.com' +export const DEMO_PASSWORD = 'DemoPass123!' + +export async function waitForBackendHealth(timeoutMs = 60_000): Promise { + const healthUrl = `${API_BASE}/health` + const deadline = Date.now() + timeoutMs + let lastError: unknown = null + while (Date.now() < deadline) { + try { + const res = await fetch(healthUrl) + if (res.ok) return + } catch (err) { + lastError = err + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`Backend never reported healthy at ${healthUrl}: ${String(lastError)}`) +} + +async function parseJsonOrThrow(res: Response, what: string): Promise { + if (!res.ok) { + throw new Error(`${what} failed: ${res.status} ${await res.text()}`) + } + return (await res.json()) as T +} + +interface LoginResponse { + access_token: string +} + +export async function apiLogin(email: string, password: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const data = await parseJsonOrThrow(res, 'login') + return data.access_token +} + +export interface ApiChild { + id: string + name: string + birth_date: string +} + +export interface ApiUserContext { + personal_data_uuid: string + workspace_id: string + parent_name: string + children: ApiChild[] +} + +export async function apiGetContext(token: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/context`, { + headers: { Authorization: `Bearer ${token}` }, + }) + return parseJsonOrThrow(res, 'get user context') +} + +export type PlannedDayType = 'sjukpenning' | 'lagsta' | 'vacation' + +export interface PlannedDayPayload { + workspace_id: string + child_id: string + parent_personal_data_uuid: string + date: string + day_type: PlannedDayType + is_dubbeldag: boolean +} + +export async function apiCreatePlannedDay(token: string, payload: PlannedDayPayload): Promise { + const res = await fetch(`${API_BASE}/api/planned-days`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + throw new Error(`create planned day ${payload.date} failed: ${res.status} ${await res.text()}`) + } +} + +export interface ApiAge4Projection { + personal_data_uuid: string + savable_after_b4: number + expiring_at_b4: number + projected_left_at_b4: number + savable_cap_at_b4: number +} + +export interface ApiWorkspaceOverview { + parent_projections: ApiAge4Projection[] + combined_expiring_at_b4: number + combined_days_left: number +} + +export async function apiGetOverview( + token: string, + workspaceId: string, + childId: string, +): Promise { + const url = `${API_BASE}/api/analytics/${workspaceId}/overview?child_id=${childId}` + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + return parseJsonOrThrow(res, 'get workspace overview') +} diff --git a/frontend/e2e/support/calendarNav.ts b/frontend/e2e/support/calendarNav.ts new file mode 100644 index 0000000..798f030 --- /dev/null +++ b/frontend/e2e/support/calendarNav.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Swedish labels from src/i18n/sv/calendar.ts — the suite pins +// `locale: 'sv-SE'` in playwright.config.ts so these are stable. +const MONTH_SELECT_LABEL = 'Välj månad' +const YEAR_SELECT_LABEL = 'Välj år' + +/** Navigate the Calendar page to the month containing `date` (UTC). */ +export async function goToCalendarMonth(page: Page, date: Date): Promise { + await page.goto('/calendar') + await expect(page.getByTestId('calendar')).toBeVisible() + + await page.getByLabel(YEAR_SELECT_LABEL).selectOption(String(date.getUTCFullYear())) + await page.getByLabel(MONTH_SELECT_LABEL).selectOption(String(date.getUTCMonth())) +} + +/** The exact `data-testid="calendar-day"` cell for an ISO date, wherever it + * falls in the currently-displayed grid (including a leading/trailing day + * from an adjacent month). */ +export function calendarDayCell(page: Page, isoDate: string) { + return page.locator(`[data-testid="calendar-day"][aria-label^="${isoDate}"]`) +} diff --git a/frontend/e2e/support/dates.ts b/frontend/e2e/support/dates.ts new file mode 100644 index 0000000..84d8d6d --- /dev/null +++ b/frontend/e2e/support/dates.ts @@ -0,0 +1,80 @@ +/** + * Date-math helpers shared by the E2E specs. The whole point of this module + * is that no spec ever hardcodes a year: every date used in an assertion is + * derived, at run time, from "today" or from the seeded child's birth date. + * + * All dates here are UTC-normalized `Date`s at midnight and ISO date + * strings (`YYYY-MM-DD`) — the same representation the app's calendar + * grid (`CalendarDay.tsx`) uses (`new Date(date + 'T00:00:00Z')`). + */ + +export function toIsoDate(date: Date): string { + return date.toISOString().slice(0, 10) +} + +export function addDays(date: Date, days: number): Date { + const result = new Date(date.getTime()) + result.setUTCDate(result.getUTCDate() + days) + return result +} + +/** + * Easter Sunday for `year`, via the Anonymous Gregorian (Meeus/Jones/ + * Butcher) algorithm. Pure date math, no external calendar data — this is + * intentionally independent of the backend's `swedish_calendar.json` cache, + * so the test computes its own expectation rather than asking the system + * under test what the system under test should show. + */ +export function easterSunday(year: number): Date { + const a = year % 19 + const b = Math.floor(year / 100) + const c = year % 100 + const d = Math.floor(b / 4) + const e = b % 4 + const f = Math.floor((b + 8) / 25) + const g = Math.floor((b - f + 1) / 3) + const h = (19 * a + b - d - g + 15) % 30 + const i = Math.floor(c / 4) + const k = c % 4 + const l = (32 + 2 * e + 2 * i - h - k) % 7 + const m = Math.floor((a + 11 * h + 22 * l) / 451) + const monthPlusDayEncoded = h + l - 7 * m + 114 + const month = Math.floor(monthPlusDayEncoded / 31) // 3 = March, 4 = April + const day = (monthPlusDayEncoded % 31) + 1 + return new Date(Date.UTC(year, month - 1, day)) +} + +/** The first Easter Sunday on or after `from` (whole-day comparison). */ +export function nextEasterOnOrAfter(from: Date): Date { + let year = from.getUTCFullYear() + let candidate = easterSunday(year) + while (candidate.getTime() < from.getTime()) { + year += 1 + candidate = easterSunday(year) + } + return candidate +} + +/** Whit Sunday (Pingstdagen) is always exactly 49 days after Easter Sunday. */ +export function whitSunday(easter: Date): Date { + return addDays(easter, 49) +} + +/** Monday of the ISO week containing `date` (Mon=0 .. Sun=6 offset). */ +export function mondayOfWeek(date: Date): Date { + const isoWeekday = (date.getUTCDay() + 6) % 7 // 0 = Monday + return addDays(date, -isoWeekday) +} + +/** + * Number of Mon–Sun calendar-week rows that the inclusive range + * [start, end] touches — i.e. how many `CalendarWeek` rows a band spanning + * that range should render across in the app's Monday-start grid + * (see `Calendar.tsx`'s `startOffset = (firstDay.getDay() + 6) % 7`). + */ +export function countWeekRows(start: Date, end: Date): number { + const startWeek = mondayOfWeek(start) + const endWeek = mondayOfWeek(end) + const diffDays = Math.round((endWeek.getTime() - startWeek.getTime()) / 86_400_000) + return diffDays / 7 + 1 +} diff --git a/frontend/e2e/support/fixture.ts b/frontend/e2e/support/fixture.ts new file mode 100644 index 0000000..b8eb81e --- /dev/null +++ b/frontend/e2e/support/fixture.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +/** Written once by `global-setup.ts` after seeding, read by every spec that + * needs the seeded workspace/child/parent ids or the deterministic + * multi-week band it created — see that file for how each value is + * derived. */ +export const FIXTURE_PATH = path.join(__dirname, '../.auth/fixture.json') + +export interface SeedFixture { + workspaceId: string + childId: string + personalDataUuid: string + childBirthDate: string + band: { + /** ISO date of the band's true first day. */ + startDate: string + /** ISO date of the band's true last day. */ + endDate: string + parentPersonalDataUuid: string + dayType: 'sjukpenning' | 'lagsta' | 'vacation' + } +} + +export function loadSeedFixture(): SeedFixture { + const raw = readFileSync(FIXTURE_PATH, 'utf-8') + return JSON.parse(raw) as SeedFixture +} diff --git a/frontend/e2e/support/pages.ts b/frontend/e2e/support/pages.ts new file mode 100644 index 0000000..066f912 --- /dev/null +++ b/frontend/e2e/support/pages.ts @@ -0,0 +1,25 @@ +export interface MainPage { + path: string + testId: string + name: string +} + +/** + * The authenticated pages scanned by the "no raw UUIDs" and "no console + * errors" guards. + * + * TODO(tips-wire-format): Tips (`/tips`, root testid `tips-page`) is + * deliberately left out. Its wire-format mapping (backend snake_case -> + * frontend camelCase) was never written, so today it renders blank — a fix + * is in flight in a parallel task and touching it here would collide. Once + * that fix lands, add: + * { path: '/tips', testId: 'tips-page', name: 'Tips' } + * to this list. Both `no-uuids.spec.ts` and `console-errors.spec.ts` + * iterate this array, so nothing else needs to change. + */ +export const MAIN_PAGES: MainPage[] = [ + { path: '/', testId: 'dashboard', name: 'Dashboard' }, + { path: '/calendar', testId: 'calendar', name: 'Calendar' }, + { path: '/plan', testId: 'plan-page', name: 'Plan' }, + { path: '/settings', testId: 'settings-page', name: 'Settings' }, +] diff --git a/frontend/e2e/tests/age4.spec.ts b/frontend/e2e/tests/age4.spec.ts new file mode 100644 index 0000000..521647a --- /dev/null +++ b/frontend/e2e/tests/age4.spec.ts @@ -0,0 +1,57 @@ +/** + * Regression guard for the age-4 savable/expiring arithmetic actually + * reaching the Dashboard UI. + * + * Domain rule (see AGENTS.md "Domain rules" and + * backend/app/config.py's `LeaveRules`): after the child turns 4, only 96 + * days may be saved *per child* — for a `couple` workspace with two + * parents, that's a 48-day cap *per parent*, not 96 each. A prior bug + * modelled a two-parent family as able to save 192 days combined by + * applying the per-child cap to each parent independently — see + * `AnalyticsService`'s module docstring, which documents this exact + * failure mode. This test asserts the invariant holds in the API response + * AND that the Dashboard renders those same numbers (not some other, + * separately-computed figure) — the historically-buggy progress bar was a + * *wiring* defect, not a backend one. + */ +import { test, expect } from '@playwright/test' +import { apiGetOverview, apiLogin, DEMO_EMAIL, DEMO_PASSWORD } from '../support/api' +import { loadSeedFixture } from '../support/fixture' + +test('per-parent expiring/savable figures respect the 48/96 age-4 cap, and the Dashboard shows the same numbers', async ({ + page, +}) => { + const fixture = loadSeedFixture() + const token = await apiLogin(DEMO_EMAIL, DEMO_PASSWORD) + const overview = await apiGetOverview(token, fixture.workspaceId, fixture.childId) + + // Ground truth from the API, independent of anything rendered. + expect(overview.parent_projections.length).toBe(2) // seed_demo.py: Alex + Sam + for (const projection of overview.parent_projections) { + expect(projection.savable_after_b4).toBeLessThanOrEqual(projection.savable_cap_at_b4) + expect(projection.savable_after_b4).toBeGreaterThanOrEqual(0) + expect(projection.expiring_at_b4).toBeGreaterThanOrEqual(0) + } + // The literal regression this guards: a two-parent workspace's per-parent + // caps must sum to the per-CHILD ceiling (96), never double it (192). + const capSum = overview.parent_projections.reduce((sum, p) => sum + p.savable_cap_at_b4, 0) + expect(capSum).toBeLessThanOrEqual(96) + const savableSum = overview.parent_projections.reduce((sum, p) => sum + p.savable_after_b4, 0) + expect(savableSum).toBeLessThanOrEqual(96) + + // Now confirm the Dashboard actually surfaces these same figures. + await page.goto('/') + await expect(page.getByTestId('dashboard')).toBeVisible() + + for (const projection of overview.parent_projections) { + const metric = page.getByTestId(`metric-expiring-parent-${projection.personal_data_uuid}`) + await expect(metric).toBeVisible() + const value = metric.locator('[data-testid^="metric-value-"]') + await expect(value).toHaveText(String(projection.expiring_at_b4)) + } + + const combinedMetric = page.getByTestId('metric-expiring-combined') + await expect(combinedMetric).toBeVisible() + const combinedValue = combinedMetric.locator('[data-testid^="metric-value-"]') + await expect(combinedValue).toHaveText(String(overview.combined_expiring_at_b4)) +}) diff --git a/frontend/e2e/tests/calendar-band.spec.ts b/frontend/e2e/tests/calendar-band.spec.ts new file mode 100644 index 0000000..195f476 --- /dev/null +++ b/frontend/e2e/tests/calendar-band.spec.ts @@ -0,0 +1,64 @@ +/** + * A multi-week leave period must render as ONE continuous band across + * `CalendarWeek` rows, not fragment into a separate bar per week row, and + * only the row containing the band's true first/last day gets a rounded + * end cap (see `CalendarWeek.tsx`'s `computeBandSegments`/ + * `isBridgeableGap`). + * + * The band under test is created directly via the API in global-setup.ts + * (15 consecutive weekdays, entirely inside one calendar month, dates + * derived from "today" — see that file), not by clicking through the UI, + * so a setup failure here is distinguishable from a real assertion + * failure. Its exact key is reproduced from `CalendarWeek.tsx`'s + * `bandKeyFor` for an ad-hoc (no wish) period: `adhoc:{parent}:{type}:{isDubbeldag}`. + */ +import { test, expect } from '@playwright/test' +import { countWeekRows } from '../support/dates' +import { goToCalendarMonth } from '../support/calendarNav' +import { loadSeedFixture } from '../support/fixture' + +test('a multi-week band renders as one continuous bar with caps only at its true ends', async ({ page }) => { + const fixture = loadSeedFixture() + const { startDate, endDate, parentPersonalDataUuid, dayType } = fixture.band + + const start = new Date(`${startDate}T00:00:00Z`) + const end = new Date(`${endDate}T00:00:00Z`) + const expectedRows = countWeekRows(start, end) + expect(expectedRows).toBeGreaterThan(1) // otherwise this isn't testing cross-week banding at all + + await goToCalendarMonth(page, start) + + const bandTestId = `calendar-band-adhoc:${parentPersonalDataUuid}:${dayType}:false::${startDate}` + const bandRows = page.locator(`[data-testid="${bandTestId}"]`) + await expect(bandRows).toHaveCount(expectedRows) + + // Not fragmented: every row of this band shares exactly this one + // testid/key — if bridging across a weekend had failed, the run would + // have split into multiple distinct ids instead. + const allTestIds = await page + .locator('[data-testid^="calendar-band-adhoc:"]') + .evaluateAll((els) => els.map((el) => el.getAttribute('data-testid'))) + const distinctIds = new Set(allTestIds) + expect(distinctIds.size, `expected exactly one band id, found: ${[...distinctIds].join(', ')}`).toBe(1) + + const classNames = await bandRows.evaluateAll((els) => els.map((el) => el.className)) + const startFlags = classNames.map((cls) => cls.includes('calendar-band--start')) + const endFlags = classNames.map((cls) => cls.includes('calendar-band--end')) + + // Rounded start cap only on the first (true-start) row. + expect(startFlags[0]).toBe(true) + expect(startFlags.slice(1).every((flag) => flag === false)).toBe(true) + + // Rounded end cap only on the last (true-end) row. + expect(endFlags[endFlags.length - 1]).toBe(true) + expect(endFlags.slice(0, -1).every((flag) => flag === false)).toBe(true) + + // Rendered corner radius follows the class, not just its name. + await expect(bandRows.first()).toHaveCSS('border-top-left-radius', '4px') + await expect(bandRows.last()).toHaveCSS('border-top-right-radius', '4px') + if (expectedRows > 2) { + const middleRow = bandRows.nth(1) + await expect(middleRow).toHaveCSS('border-top-left-radius', '0px') + await expect(middleRow).toHaveCSS('border-top-right-radius', '0px') + } +}) diff --git a/frontend/e2e/tests/console-errors.spec.ts b/frontend/e2e/tests/console-errors.spec.ts new file mode 100644 index 0000000..feb3fb0 --- /dev/null +++ b/frontend/e2e/tests/console-errors.spec.ts @@ -0,0 +1,25 @@ +/** + * No console errors on any main page — a cheap tripwire for exceptions + * that unit tests (which render components in isolation) don't exercise, + * such as a page-level data-fetch/render crash only visible end-to-end. + */ +import { test, expect } from '@playwright/test' +import { MAIN_PAGES } from '../support/pages' + +test.describe('no console errors on main pages', () => { + for (const mainPage of MAIN_PAGES) { + test(`${mainPage.name} (${mainPage.path}) logs no console errors`, async ({ page }) => { + const errors: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()) + }) + page.on('pageerror', (err) => errors.push(err.message)) + + await page.goto(mainPage.path) + await expect(page.getByTestId(mainPage.testId)).toBeVisible() + await page.waitForLoadState('networkidle') + + expect(errors, `console errors on ${mainPage.path}:\n${errors.join('\n')}`).toEqual([]) + }) + } +}) diff --git a/frontend/e2e/tests/no-uuids.spec.ts b/frontend/e2e/tests/no-uuids.spec.ts new file mode 100644 index 0000000..669cefd --- /dev/null +++ b/frontend/e2e/tests/no-uuids.spec.ts @@ -0,0 +1,25 @@ +/** + * No raw UUID should ever leak into visible copy where a name belongs (the + * historical bug: "3d6c6b2e-… has a large surplus" instead of a parent's + * name — see the surplus-advice/name-resolver code in + * `src/components/planning/PlanResults.tsx` and `src/utils/parentNames.ts`). + * Cheap to guard forever; scans the rendered text of every main page. + */ +import { test, expect } from '@playwright/test' +import { MAIN_PAGES } from '../support/pages' + +const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i + +test.describe('no raw UUIDs in visible copy', () => { + for (const mainPage of MAIN_PAGES) { + test(`${mainPage.name} (${mainPage.path}) never shows a raw id where a name belongs`, async ({ page }) => { + await page.goto(mainPage.path) + const root = page.getByTestId(mainPage.testId) + await expect(root).toBeVisible() + + const text = await root.innerText() + const match = text.match(UUID_PATTERN) + expect(match?.[0] ?? null, `found a raw UUID in visible text on ${mainPage.path}`).toBeNull() + }) + } +}) diff --git a/frontend/e2e/tests/red-days.spec.ts b/frontend/e2e/tests/red-days.spec.ts new file mode 100644 index 0000000..4f266bb --- /dev/null +++ b/frontend/e2e/tests/red-days.spec.ts @@ -0,0 +1,73 @@ +/** + * §3.4 red-day rendering, and the "combined holiday name" trap. + * + * The backend (`app/utils/holidays.py::get_swedish_holidays`) returns a + * COMBINED name like "Påskdagen; Söndag" whenever a fixed public holiday + * falls on a Sunday — `get_red_days` keeps that combined string (only a + * *bare* "Söndag" is dropped). A careless frontend filter that splits on + * ';' and throws away anything containing a weekday name would silently + * delete Påskdagen/Pingstdagen's own name along with it. This test asserts + * both holidays render as NAMED red days, by their real name, in the + * calendar cell's visible text (not hover-only). + * + * All dates are computed from "today" (see support/dates.ts) — never a + * hardcoded year — because Easter/Pentecost's date, and therefore which + * year's calendar page to look at, changes every run. + */ +import { test, expect } from '@playwright/test' +import { addDays, nextEasterOnOrAfter, whitSunday, toIsoDate } from '../support/dates' +import { calendarDayCell, goToCalendarMonth } from '../support/calendarNav' + +const RED_DAY_BG = 'rgb(253, 236, 235)' // .calendar-day--red-day +const WEEKEND_BG = 'rgb(240, 240, 240)' // .calendar-day--weekend (plain Saturday) + +test.describe('red days, klämdagar and the combined-holiday-name trap', () => { + test('Easter Sunday (Påskdagen) renders as a named red day', async ({ page }) => { + const easter = nextEasterOnOrAfter(new Date()) + await goToCalendarMonth(page, easter) + + const cell = calendarDayCell(page, toIsoDate(easter)) + await expect(cell).toHaveClass(/calendar-day--red-day/) + await expect(cell).toHaveCSS('background-color', RED_DAY_BG) + await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Påskdagen') + }) + + test('Whit Sunday (Pingstdagen) renders as a named red day', async ({ page }) => { + const whitsun = whitSunday(nextEasterOnOrAfter(new Date())) + await goToCalendarMonth(page, whitsun) + + const cell = calendarDayCell(page, toIsoDate(whitsun)) + await expect(cell).toHaveClass(/calendar-day--red-day/) + await expect(cell).toHaveCSS('background-color', RED_DAY_BG) + await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Pingstdagen') + }) + + test('a plain Sunday is red without a holiday name; a plain Saturday is neutral grey', async ({ page }) => { + // Two and three weeks before Easter: far enough from Easter's own + // de-facto cluster (Långfredagen/Påskafton/Påskdagen/Annandag påsk, + // Easter-2..Easter+1) that neither date is itself a public or de-facto + // holiday, just an ordinary weekend. + const easter = nextEasterOnOrAfter(new Date()) + const plainSunday = addDays(easter, -14) + const plainSaturday = addDays(plainSunday, -1) + + // Navigate by the Sunday's own month, not Easter's: whatever month + // `plainSunday` falls in, `plainSaturday` (the day right before it) is + // guaranteed to appear in that same month's grid too — either as an + // ordinary day in the month, or as a leading fill-in day completing + // that first Mon–Sun week row (see Calendar.tsx's `startOffset`) — so + // this never depends on the two dates sharing a calendar month. + await goToCalendarMonth(page, plainSunday) + + const sundayCell = calendarDayCell(page, toIsoDate(plainSunday)) + await expect(sundayCell).toHaveClass(/calendar-day--red-day/) + await expect(sundayCell).not.toHaveClass(/calendar-day--weekend/) + await expect(sundayCell).toHaveCSS('background-color', RED_DAY_BG) + await expect(sundayCell.getByTestId('calendar-day-holiday-name')).toHaveCount(0) + + const saturdayCell = calendarDayCell(page, toIsoDate(plainSaturday)) + await expect(saturdayCell).toHaveClass(/calendar-day--weekend/) + await expect(saturdayCell).not.toHaveClass(/calendar-day--red-day/) + await expect(saturdayCell).toHaveCSS('background-color', WEEKEND_BG) + }) +}) diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..4bd6f7f --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "types": ["node"], + "noEmit": true + }, + "include": ["**/*.ts", "../playwright.config.ts"] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index d1dfbe1..36dade6 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,10 +5,10 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks' export default [ { - ignores: ['coverage/**', 'dist/**'], + ignores: ['coverage/**', 'dist/**', 'playwright-report/**', 'test-results/**', 'e2e/.auth/**'], }, { - files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'], + files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}', 'e2e/**/*.ts', 'playwright.config.ts'], languageOptions: { parser: tsParser, parserOptions: { diff --git a/frontend/package.json b/frontend/package.json index 813b2ce..b59554b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,12 +8,15 @@ "dev:backend": "cd ../backend && uv run uvicorn app.main:app --reload --port 8000", "dev:web": "vite", "typecheck": "./node_modules/.bin/tsc --noEmit", + "typecheck:e2e": "./node_modules/.bin/tsc -p e2e/tsconfig.json", "build": "./node_modules/.bin/tsc && vite build", "preview": "vite preview", - "lint": "eslint src tests --ext ts,tsx --max-warnings 0", + "lint": "eslint src tests e2e playwright.config.ts --ext ts,tsx --max-warnings 0", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", "login": "concurrently -k -n backend,web \"npm:login:backend\" \"npm:login:web\"", "login:backend": "cd ../backend && APP_SCRAPER_ENABLED=true APP_SCRAPER_HEADLESS=true uv run uvicorn app.main:app --reload --port 8000", "login:web": "vite --open /dev/fetch" @@ -24,9 +27,11 @@ "react-router-dom": "^6.27.0" }, "devDependencies": { + "@playwright/test": "^1.49.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.17.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.15.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..0afc488 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const authFile = path.join(__dirname, 'e2e/.auth/user.json') + +// NOTE: the frontend origin is deliberately `http://localhost:5173`, never +// `127.0.0.1` — Vite's dev server binds IPv6 `::1`, and on macOS +// `127.0.0.1` can silently miss it (see the matching comment in +// vite.config.ts's proxy target, which uses 127.0.0.1 for the *backend* for +// the opposite reason: to dodge a stray IPv6 listener there). +const FRONTEND_URL = 'http://localhost:5173' +const BACKEND_HEALTH_URL = 'http://127.0.0.1:8000/health' + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + // A fresh suite against a single shared demo workspace — keep it to one + // worker in CI until it's proven flake-free under parallel load. Left + // unset locally so Playwright picks its usual CPU-based default. + ...(process.env.CI ? { workers: 1 } : {}), + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: FRONTEND_URL, + // Pinned so translated labels/aria-labels used as selectors (e.g. + // "Välj månad") are deterministic regardless of the host machine's + // locale — the app defaults to Swedish or English based on + // `navigator.language` (see LanguageContext.tsx). + locale: 'sv-SE', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'uv run uvicorn app.main:app --port 8000', + cwd: path.resolve(__dirname, '../backend'), + url: BACKEND_HEALTH_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + { + command: 'npx vite --port 5173 --strictPort', + cwd: __dirname, + url: FRONTEND_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: authFile }, + dependencies: ['setup'], + }, + ], +}) diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index d3cd127..46b5c7b 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -50,18 +50,22 @@ export function Auth() { )} setEmail(e.target.value)} /> setPassword(e.target.value)} /> {error &&

{error}

} - +