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:
@@ -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')
|
||||
}
|
||||
@@ -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}"]`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
Reference in New Issue
Block a user