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]]; } 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('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('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(); // 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('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 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('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); });