import { test, expect, Page } from '@playwright/test'; /** * Journey tests for SchoolCompare, run against the staging environment as the * gate before promotion to production. They assert stable data invariants * (results exist, key UI renders) rather than exact numbers, so routine data * refreshes don't break the pipeline. */ async function searchByName(page: Page, query: string) { await page.goto('/'); const searchInput = page.getByPlaceholder('School name or postcode').first(); await searchInput.fill(query); await searchInput.press('Enter'); await page.waitForURL(/search=|postcode=/); } function schoolLinks(page: Page) { return page.locator('a[href^="/school/"]'); } /** * Two URNs guaranteed to be pure-primary (same phase). The compare page's * phase tabs split all-through schools (which carry KS4 data) onto the * secondary tab, so picking two arbitrary "primary" search hits can land * them on different tabs where only the active one renders. Selecting via * the API by exact phase keeps both on the same tab. Data-invariant: uses * whatever primaries the environment holds. */ async function twoPrimaryUrns(page: Page): Promise<[string, string]> { const res = await page.request.get('/api/schools?search=primary&per_page=50'); expect(res.ok()).toBeTruthy(); const body = await res.json(); const urns: string[] = (body.schools ?? []) .filter((s: { phase?: string; rwm_expected_pct?: number | null }) => s.phase === 'Primary' && s.rwm_expected_pct != null, ) .map((s: { urn: number }) => String(s.urn)); expect(urns.length).toBeGreaterThanOrEqual(2); return [urns[0], urns[1]]; } async function twoSecondaryUrns(page: Page): Promise<[string, string]> { const res = await page.request.get('/api/schools?search=school&per_page=100'); expect(res.ok()).toBeTruthy(); const body = await res.json(); const urns: string[] = (body.schools ?? []) .filter((s: { phase?: string; attainment_8_score?: number | null }) => s.phase === 'Secondary' && s.attainment_8_score != null, ) .map((s: { urn: number }) => String(s.urn)); expect(urns.length).toBeGreaterThanOrEqual(2); return [urns[0], urns[1]]; } test('home page loads with hero search', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1').first()).toBeVisible(); await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible(); }); test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => { await page.goto('/'); // The geolocation shortcut lives inside the hero search card, right under the // search input — not in a separate strip further down the page. const searchInput = page.getByPlaceholder('School name or postcode').first(); await expect(searchInput).toBeVisible(); const nearMe = page.getByRole('button', { name: /use my location/i }); await expect(nearMe).toBeVisible(); }); test('searching by name returns school results', async ({ page }) => { await searchByName(page, 'primary'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); expect(await schoolLinks(page).count()).toBeGreaterThan(1); }); test('searching by postcode returns nearby schools', async ({ page }) => { await searchByName(page, 'B1 1BB'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); }); test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => { // List/map badges keyed off ofsted_grade (the carried-forward legacy grade) // and never reached the report-card branch, so report-card schools were // labelled by their old grade (e.g. "Outstanding · 2021"). The list now // carries ofsted_rc_date and the badge treats a report card as winning. const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`); expect(res.ok()).toBeTruthy(); const barclay = ((await res.json()).schools ?? []).find( (s: { urn: number }) => s.urn === RC_URN, ); // Hard assertions, not test.skip: if the backend stops exposing // ofsted_rc_date for this report-card school, that IS the regression this // test exists to catch, so it must fail loudly rather than skip. expect(barclay, 'Barclay must appear in the search results').toBeTruthy(); expect( barclay.ofsted_rc_date, 'the list must expose ofsted_rc_date for a report-card school', ).toBeTruthy(); await searchByName(page, 'Barclay Primary'); // The Barclay row must be present… await expect(page.locator(`a[href*="${RC_URN}"]`).first()).toBeVisible({ timeout: 15_000 }); // …badged as a Report Card, not its carried-forward "Outstanding" grade. await expect(page.getByText(/Report Card ·/).first()).toBeVisible(); }); test('school detail page renders name and performance data', async ({ page }) => { await searchByName(page, 'primary'); const firstSchool = schoolLinks(page).first(); await expect(firstSchool).toBeVisible({ timeout: 15_000 }); await firstSchool.click(); await page.waitForURL(/\/school\//); await expect(page.locator('h1').first()).toBeVisible(); // The detail page renders at least one *visible* chart canvas. Plain // .first() is wrong here: the admissions card stacks its year/trend views // in one grid cell and keeps the inactive view's canvas visibility:hidden // by design, and that canvas comes first in the DOM. await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); }); test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => { // Detail views detected report cards via `framework`, which the API never // sets to "ReportCard" — so report-card schools rendered as legacy ratings // dated to a pre-Nov-2025 inspection. Detection now keys off the report_card // object and dates it with rc_inspection_date. const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card const res = await page.request.get(`/api/schools/${RC_URN}`); expect(res.ok()).toBeTruthy(); const ofsted = (await res.json()).ofsted; test.skip( !ofsted?.report_card || Object.keys(ofsted.report_card).length === 0, 'precondition: chosen URN must currently have a report card', ); const rcYear = new Date(ofsted.rc_inspection_date).getFullYear(); const legacyYear = new Date(ofsted.inspection_date).getFullYear(); await page.goto(`/school/${RC_URN}`); const ofstedSection = page.locator('#ofsted'); // Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating". await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 }); // Dating fixed: dated to the report-card inspection, never the legacy one. await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible(); if (legacyYear !== rcYear) { await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0); } }); test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => { // All-through schools carry both KS2 and KS4 data in the same yearly rows. // The detail view used to flip them to isSecondary and render GCSE-only, // hiding the primary phase. It now renders both phases and labels the school // "All-through". const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through const res = await page.request.get(`/api/schools/${AT_URN}`); expect(res.ok()).toBeTruthy(); const detail = await res.json(); const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> = detail.yearly_data ?? []; const hasKS2 = rows.some((r) => r.rwm_expected_pct != null); const hasKS4 = rows.some((r) => r.attainment_8_score != null); test.skip( (detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4, 'precondition: chosen URN must currently be all-through with both KS2 and KS4 results', ); await page.goto(`/school/${AT_URN}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); // Labelled as all-through in the hero meta. await expect(page.getByText(/All-through/i).first()).toBeVisible(); // The combined results section carries both phases. const results = page.locator('#results'); await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible(); await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block }); test('a special school is not shown as failing against the mainstream England average', async ({ page }) => { // Special schools sit the same tests but very few pupils reach the mainstream // "expected standard", so a "0.0% · −62 pts below England average" rendering // portrays them as failing against a benchmark that doesn't fit. The results // section drops the England comparison and explains the context instead. const SP_URN = 101099; // Greenmead School — a community special school const res = await page.request.get(`/api/schools/${SP_URN}`); expect(res.ok()).toBeTruthy(); const detail = await res.json(); test.skip( !/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''), 'precondition: chosen URN must currently be a special school', ); await page.goto(`/school/${SP_URN}`); const results = page.locator('#results'); // The special-school context note is shown… await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 }); // …and the mainstream England-average comparison is dropped entirely. await expect(results.getByText(/England avg/i)).toHaveCount(0); }); test('school with no performance data still gets a working detail page', async ({ page }) => { // Schools without KS2/KS4 results (special post-16 institutions, sixth-form // centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON // serialization — which the frontend rendered as a 404 on every such SEO // landing page. Find one via the search API (year === null marks "no // performance rows") and assert its page renders. const candidates: number[] = []; for (const q of ['post 16', 'specialist college', 'sixth form']) { const resp = await page.request.get( `/api/schools?search=${encodeURIComponent(q)}&per_page=20` ); if (!resp.ok()) continue; const body = await resp.json(); for (const s of body.schools ?? []) { if (s.year === null && s.urn) candidates.push(s.urn); } if (candidates.length) break; } test.skip(candidates.length === 0, 'no results-less school in this dataset'); const detail = await page.request.get(`/api/schools/${candidates[0]}`); expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200); await page.goto(`/school/${candidates[0]}`); await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug await expect(page.locator('h1').first()).toBeVisible(); }); test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => { // iOS Safari has no Element.requestFullscreen; the map must fall back to a // CSS overlay. Simulate that by removing the API before any page script runs. await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => { // @ts-expect-error deliberate API removal delete Element.prototype.requestFullscreen; }); await searchByName(page, 'primary'); const firstSchool = schoolLinks(page).first(); await expect(firstSchool).toBeVisible({ timeout: 15_000 }); await firstSchool.click(); await page.waitForURL(/\/school\//); const openMap = page.getByRole('button', { name: 'Open full map' }); await expect(openMap).toBeVisible({ timeout: 15_000 }); await openMap.click(); const closeMap = page.getByRole('button', { name: 'Close map' }); await expect(closeMap).toBeVisible(); await closeMap.click(); await expect(openMap).toBeVisible(); }); test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => { // Same iOS gap as the hero map: no Element.requestFullscreen, so the results // map's fullscreen button must fall back to a CSS overlay. await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => { // @ts-expect-error deliberate API removal delete Element.prototype.requestFullscreen; }); await searchByName(page, 'B1 1BB'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); // Switch to the map view, then open the map fullscreen. await page.getByRole('button', { name: 'Map', exact: true }).click(); const openFs = page.getByRole('button', { name: 'View map fullscreen' }); await expect(openFs).toBeVisible({ timeout: 15_000 }); await openFs.click(); // The button flips to its exit state once the overlay is up. const exitFs = page.getByRole('button', { name: 'Exit fullscreen' }); await expect(exitFs).toBeVisible(); await exitFs.click(); await expect(openFs).toBeVisible(); }); test('comparing two schools shows the parent-first sections side by side', async ({ page }) => { // Two same-phase (pure primary) schools so both stay on one tab. const [urn0, urn1] = await twoPrimaryUrns(page); await page.goto(`/compare?urns=${urn0},${urn1}`); // Both schools' detail links should render in the comparison view await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible(); // The parent-first sections render in order (data-invariant: headings only) for (const heading of [ 'At a glance', 'Ofsted inspection', /How (children|students) do academically/, 'Who goes there', 'Explore trends', ]) { await expect( page.getByRole('heading', { name: heading }).first(), ).toBeVisible({ timeout: 15_000 }); } // Every number gets an anchor: at least one England-average tick or label await expect(page.getByText(/England \d+/).first()).toBeVisible(); // Desktop: the sticky school bar shares the sections' grid template // (200px label rail + one column per school) so chips align with the // columns they label. const barTemplate = await page .locator('[aria-label="Schools in this comparison"]') .evaluate((el) => getComputedStyle(el).gridTemplateColumns); expect(barTemplate).toMatch(/^200px /); // ...and its label rail carries the comparison caption. await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible(); // Ofsted linkout goes to the school's provider page, never a report deep-link const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first(); await expect(ofstedLink).toBeVisible(); expect(await ofstedLink.getAttribute('href')).toMatch( /reports\.ofsted\.gov\.uk\/provider\/21\/\d+/ ); // A school never shows both an overall-grade badge AND report-card detail: // "Report card" implies "no overall grade is given" copy is present too. const reportCards = await page.getByText('Report card', { exact: true }).count(); if (reportCards > 0) { await expect(page.getByText(/no overall grade/i).first()).toBeVisible(); } }); test('comparing two secondary schools renders the secondary sections', async ({ page }) => { const [urn0, urn1] = await twoSecondaryUrns(page); await page.goto(`/compare?urns=${urn0},${urn1}`); await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 }); // The parent-first sections must render — this page was completely blank // for all-secondary baskets (expert review must-fix #1). await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({ timeout: 15_000, }); await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible(); // A KS4 measure proves the secondary academics variant rendered. await expect(page.getByText(/Attainment 8/i).first()).toBeVisible(); await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0); // The admissions template must be phase-aware: the primaries' distance // copy ("non-faith primaries") must never appear on a secondary comparison // (expert sign-off must-fix M3). await expect(page.getByText(/non-faith primaries/)).toHaveCount(0); }); test('opening a different compare link after a previous comparison still renders', async ({ page }) => { // Regression: the first visit stores a basket in localStorage; opening a // link for a DIFFERENT school set then raced a stale fetch for the stored // basket against the new SSR data, blanking every section (including the // trends chart) until a hard refresh. const [s0, s1] = await twoSecondaryUrns(page); const [p0, p1] = await twoPrimaryUrns(page); await page.goto(`/compare?urns=${s0},${s1}`); await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({ timeout: 15_000, }); await page.goto(`/compare?urns=${p0},${p1}`); await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({ timeout: 15_000, }); // Give any straggling stale response time to land, then confirm the new // comparison is still on screen. await page.waitForTimeout(1500); await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible(); await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible(); }); test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await searchByName(page, 'primary'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); const hrefs = await schoolLinks(page).evaluateAll((links) => links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '') ); const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))]; // Compare three schools, not two: a "primary" search can return all-through // schools that classify as secondary, and the chips only appear for the // active phase. With three schools across two phases, the auto-selected // majority phase always holds ≥2, so the chip legend is guaranteed to render. expect(urns.length).toBeGreaterThanOrEqual(3); await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`); // Mobile is measure-first: the At a glance section stacks all active-phase // schools inside one flow — no horizontal swiping between school columns. await expect( page.getByRole('heading', { name: 'At a glance' }), ).toBeVisible({ timeout: 15_000 }); const body = page.locator('body'); const bodyOverflowsX = await body.evaluate( (el) => el.scrollWidth > el.clientWidth + 1, ); expect(bodyOverflowsX).toBe(false); // The sticky school bar must pin *below* the sticky site header, not at // top:0 where the header covers it and the selected schools are hidden. // Assert the sticky offset directly (robust — no scroll timing needed). const barTop = await page .locator('[class*="schoolBar"]') .first() .evaluate((el) => parseFloat(getComputedStyle(el).top)); const headerHeight = await page .locator('[class*="header"]') .first() .evaluate((el) => el.getBoundingClientRect().height); expect(barTop).toBeGreaterThanOrEqual(headerHeight - 1); // The trends chart still renders (inside the Explore trends section)… const chartCanvas = page.locator('canvas:visible').first(); await expect(chartCanvas).toBeVisible({ timeout: 15_000 }); // …at a real height, not the squashed ~150px Chart.js fallback that // appears when the container lacks a definite height. const chartBox = await chartCanvas.boundingBox(); expect(chartBox && chartBox.height).toBeGreaterThan(220); // …with the mobile chart legend chips and tap-to-focus behaviour intact. const chipGroup = page.getByRole('group', { name: /highlight a school/i }); const chips = chipGroup.getByRole('button'); await expect(chips.first()).toBeVisible({ timeout: 15_000 }); expect(await chips.count()).toBeGreaterThanOrEqual(2); // Tapping a chip focuses that school's line; tapping again releases it. await chips.first().click(); await expect(chips.first()).toHaveAttribute('aria-pressed', 'true'); await chips.first().click(); await expect(chips.first()).toHaveAttribute('aria-pressed', 'false'); }); test('admissions guide renders its key milestones', async ({ page }) => { // Static content page — assert the guide loads and the load-bearing // milestones parents rely on are present (dates are statutory, so these // strings are stable invariants, not data-refresh-sensitive). await page.goto('/admissions'); await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible(); // National Offer Day is the milestone the whole guide builds toward. await expect(page.getByText(/National Offer Day/i).first()).toBeVisible(); }); test('rankings page loads a populated table', async ({ page }) => { await page.goto('/rankings'); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 15_000 }); expect(await rows.count()).toBeGreaterThan(5); }); test('rankings stay populated after picking a specific year', async ({ page }) => { // Years are academic-year codes (e.g. 201819); the API must accept them // as the `year` query param rather than rejecting with a 422. await page.goto('/rankings'); const yearSelect = page.locator('#year-select'); await expect(yearSelect).toBeVisible({ timeout: 15_000 }); // Pick the last option — the most recent explicit year. The default view // already proved this year has rows, so an empty table after selecting it // can only mean the year param was rejected. (The oldest year is no good // here: staging doesn't always carry the full data history.) const yearValue = await yearSelect.locator('option').last().getAttribute('value'); expect(yearValue).toBeTruthy(); await yearSelect.selectOption(yearValue!); await page.waitForURL(/year=/); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 15_000 }); expect(await rows.count()).toBeGreaterThan(5); });