Check in a browser what the unit tests cannot see

Every serious defect in this project was found by opening the app, most
recently a feature that rendered blank cards to every user while its tests
passed, and a progress bar labelled in the wrong unit. Five checks now run
without a person: that the age-four figures on screen match what the API
computed, that a multi-week period draws as one bar rather than fragments,
that Sundays and holidays are red where Saturdays are not, that no identifier
leaks into visible text, and that no page logs an error.

Three of them were confirmed by reintroducing the original bug and watching
the right test fail. Dates are derived rather than written down: Easter is
computed independently instead of asked of the system under test, and the
fixture period is placed relative to today, so the suite does not expire.

It runs as its own job and does not block a merge until it has proved itself
stable.
This commit is contained in:
2026-09-17 19:46:17 +02:00
parent 0fc71101ae
commit ed5680253e
19 changed files with 791 additions and 5 deletions
+48
View File
@@ -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
+29
View File
@@ -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 })
})
+103
View File
@@ -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<void> {
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,
),
)
}
+120
View File
@@ -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<void> {
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<T>(res: Response, what: string): Promise<T> {
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<string> {
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<LoginResponse>(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<ApiUserContext> {
const res = await fetch(`${API_BASE}/api/auth/context`, {
headers: { Authorization: `Bearer ${token}` },
})
return parseJsonOrThrow<ApiUserContext>(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<void> {
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<ApiWorkspaceOverview> {
const url = `${API_BASE}/api/analytics/${workspaceId}/overview?child_id=${childId}`
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
return parseJsonOrThrow<ApiWorkspaceOverview>(res, 'get workspace overview')
}
+23
View File
@@ -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<void> {
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}"]`)
}
+80
View File
@@ -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
}
+31
View File
@@ -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
}
+25
View File
@@ -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' },
]
+57
View File
@@ -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))
})
+64
View File
@@ -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')
}
})
+25
View File
@@ -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([])
})
}
})
+25
View File
@@ -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()
})
}
})
+73
View File
@@ -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)
})
})
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"types": ["node"],
"noEmit": true
},
"include": ["**/*.ts", "../playwright.config.ts"]
}
+2 -2
View File
@@ -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: {
+6 -1
View File
@@ -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",
+61
View File
@@ -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'],
},
],
})
+5 -1
View File
@@ -50,18 +50,22 @@ export function Auth() {
)}
<input
type="email"
data-testid="auth-email"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
data-testid="auth-password"
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">{isRegister ? t('auth.registerButton') : t('auth.loginButton')}</button>
<button type="submit" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')}
</button>
</form>
<button type="button" onClick={() => setIsRegister((v) => !v)}>
{isRegister ? t('auth.haveAccountButton') : t('auth.createAccountButton')}
+5 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
@@ -13,6 +13,10 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
// The Playwright E2E suite lives under e2e/ and uses @playwright/test's
// own test runner (`npm run e2e`), not Vitest's — exclude it here so
// `vitest run` never tries to collect those specs.
exclude: [...configDefaults.exclude, 'e2e/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],