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(); }); /* * The landing page's claims must match the product and the data. * * Every failure this group guards against is silent: a hardcoded fallback that * renders as if it were live, a number that contradicts a constant elsewhere in * the codebase, illustrative copy that reads as fact. None of them throw, none * fail a build, and none show up in a unit test. */ test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => { const info = await (await page.request.get('/api/data-info')).json(); test.skip(info.status !== 'loaded', 'environment has no data loaded'); const expected: number = info.unique_schools; expect(expected).toBeGreaterThan(0); await page.goto('/'); const body = await page.locator('body').innerText(); // The real count, formatted as the page formats it. expect(body).toContain(expected.toLocaleString('en-GB')); // The fallback that used to render in its place. `total_schools` is not a // field this API returns, so reading it yielded undefined and the "24,000+" // floor was shown on every request while the database held far more. expect(body).not.toContain('24,000+'); }); test('the landing page states the real comparison limit', async ({ page }) => { await page.goto('/'); const body = await page.locator('body').innerText(); // MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to // promise three while a card lower on the same page promised five. expect(body).toMatch(/five schools/i); expect(body).not.toMatch(/three schools/i); }); test('the landing page invents no data about real schools', async ({ page }) => { await page.goto('/'); const preview = page.locator('table, [class*="comparePreview"]').first(); const body = await page.locator('body').innerText(); // The compare preview's columns carried two real school names beside // invented results and invented Ofsted grades. expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i); await expect(preview).toContainText(/School A/); }); test('the home page has a real title', async ({ page }) => { await page.goto('/'); // It was the bare string "Home" — the browser tab, the bookmark and the // search result all read that. expect(await page.title()).not.toBe('Home'); expect(await page.title()).toMatch(/schoolcompare/i); }); test('the site states its independence and never claims to be official', async ({ page }) => { await page.goto('/'); // Carried in the footer, so it appears on every page rather than only here. await expect( page.getByText(/an independent site\.\s*not affiliated with the department for education/i) ).toBeVisible(); /* * Describing the DATA as official is accurate and appears throughout * ("official DfE figures", "the official figure isn't in"). Describing the * SITE as official is not: schoolcompare republishes government data, it is * not a government service. The difference is the grammatical subject, so * this looks for text that opens with "Official" as a standalone label — * the shape a value-prop title or heading takes, and the shape the old * "Official & trusted" took. */ const claims = await page.evaluate(() => [...document.querySelectorAll('body *')] .flatMap(el => [...el.childNodes] .filter(n => n.nodeType === 3) .map(n => (n.textContent ?? '').trim())) .filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t)) ); expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]); }); test('the landing bands share one vertical rhythm', async ({ page }) => { await page.goto('/'); const gaps = await page.evaluate(() => { const landing = document.querySelector('main div[class*="landing"]'); if (!landing) return null; const kids = [...landing.children].map(el => el.getBoundingClientRect()); const out: number[] = []; for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom)); return out; }); expect(gaps).not.toBeNull(); expect(gaps!.length).toBeGreaterThan(1); // Every band gap comes from the page container's single `gap`, so they are // identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set // its own margin, and the page read as a stack of unrelated strips. expect(new Set(gaps!).size).toBe(1); }); /* * The hero artwork. Every assertion here covers a failure that renders as a * perfectly valid page: a path that 404s leaves an empty box, a crop that * stops switching silently decapitates the subject, and a format negotiation * that regresses just serves a bigger file. */ function heroImageState(page: Page) { return page.evaluate(() => { const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null; if (!img) return null; return { // naturalWidth is 0 for an image that failed to load, whatever the src says. loaded: img.complete && img.naturalWidth > 0, file: img.currentSrc.split('/').pop() ?? '', }; }); } test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); const wide = await heroImageState(page); expect(wide).not.toBeNull(); expect(wide!.loaded).toBe(true); expect(wide!.file).toContain('hero-wide'); /* * Below the one-column breakpoint the slot becomes a band up to 4.9:1. The * desktop frame centre-cropped to that shape loses the schoolhouse, which is * how the previous hero failed on phones — so must switch to the * pre-cropped band file, not merely to a smaller version of the same frame. */ await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); const band = await heroImageState(page); expect(band!.loaded).toBe(true); expect(band!.file).toContain('hero-band'); }); test('the hero artwork is served in a modern format', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); const state = await heroImageState(page); // The JPEG exists only as the fallback. Chromium takes AVIF, so seeing // the fallback here means the negotiation broke — which costs about // 24kB extra on the LCP element and nothing visible. expect(state!.file).toMatch(/\.(avif|webp)$/); }); test('the hero artwork is dimmed for the dark theme', async ({ browser }) => { const filterFor = async (colorScheme: 'light' | 'dark') => { const ctx = await browser.newContext({ colorScheme }); const p = await ctx.newPage(); await p.goto('/'); const value = await p.evaluate(() => { const img = document.querySelector('[class*="heroArt"] img'); return img ? getComputedStyle(img).filter : 'missing'; }); await ctx.close(); return value; }; // The artwork is a fixed raster, so it cannot be re-graded per theme the way // the drawn hero was. Undimmed it is the brightest object on a near-black // page and out-shouts the H1 and the search box — the exact regression the // drawn version shipped with. expect(await filterFor('light')).toBe('none'); expect(await filterFor('dark')).toMatch(/brightness/); }); 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('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => { const [urn] = await twoPrimaryUrns(page); const res = await page.request.get(`/api/schools/${urn}`); expect(res.ok()).toBeTruthy(); const info = (await res.json()).school_info; await page.goto(`/school/${urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); // Phonics, SEN-type breakdown and average class size were never populated by // the backend — the sections have been removed, so the Phonics section (its // own #phonics anchor) must no longer exist. await expect(page.locator('#phonics')).toHaveCount(0); // Newly surfaced GIAS/location fields render when the record carries them. const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/); if (ageMatch) { await expect(page.getByText(`Ages ${ageMatch[1]}–${ageMatch[2]}`).first()).toBeVisible(); } if (info.telephone) { await expect(page.locator('a[href^="tel:"]').first()).toBeVisible(); } if (info.parliamentary_constituency) { await expect(page.getByText('Constituency:').first()).toBeVisible(); } }); test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const [urn] = await twoPrimaryUrns(page); await page.goto(`/school/${urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); // Collapsed by default on mobile/tablet … const details = page.locator('#school-header-details'); await expect(details).toBeHidden(); const toggle = page.getByRole('button', { name: /show all details/i }); await expect(toggle).toBeVisible(); // … and the link reveals them (label flips to "Hide details"). await toggle.click(); await expect(details).toBeVisible(); await expect(page.getByRole('button', { name: /hide details/i })).toBeVisible(); }); 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); }); test('compare metric-help popover stays within the mobile viewport', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const [urn0, urn1] = await twoPrimaryUrns(page); await page.goto(`/compare?urns=${urn0},${urn1}`); await expect( page.getByRole('heading', { name: 'At a glance' }), ).toBeVisible({ timeout: 15_000 }); // The metric-help triggers are the circled-"?" buttons in the row labels. // "More information" is InfoPopover's default accessible name. const help = page.getByRole('button', { name: 'More information' }).first(); await expect(help).toBeVisible({ timeout: 15_000 }); await help.click(); const tip = page.getByRole('tooltip'); await expect(tip).toBeVisible(); // The whole bubble must sit inside the viewport — the original bug pushed it // off the right edge with no way to scroll to it. const box = await tip.boundingBox(); const width = page.viewportSize()!.width; expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(width); // And the page must not have gained a horizontal scrollbar from the bubble. const bodyOverflowsX = await page .locator('body') .evaluate((el) => el.scrollWidth > el.clientWidth + 1); expect(bodyOverflowsX).toBe(false); }); /** * The following two journeys cover the server/client split of the detail page. * The sections are now React Server Components composed in the route and passed * through a client shell; these assert that the two halves still meet correctly * in a real browser, which no unit test can prove. */ test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => { // Find a school with at least two years carrying an offer rate — the toggle // only appears then. Data-invariant: uses whatever the environment holds. const res = await page.request.get('/api/schools?search=primary&per_page=50'); expect(res.ok()).toBeTruthy(); const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn); let target: number | null = null; for (const urn of candidates.slice(0, 12)) { const detail = await page.request.get(`/api/schools/${urn}`); if (!detail.ok()) continue; const history = (await detail.json()).admissions_history ?? []; const withRate = history.filter( (h: { first_preference_offer_pct?: number | null }) => h.first_preference_offer_pct != null, ); if (withRate.length >= 2) { target = urn; break; } } test.skip(target === null, 'no school in this environment has 2+ years of admissions offer data'); await page.goto(`/school/${target}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); const yearBtn = page.getByRole('button', { name: 'This year' }); const trendBtn = page.getByRole('button', { name: /-year trend$/ }); await expect(yearBtn).toHaveAttribute('aria-pressed', 'true'); // The toggle is the one client island inside an otherwise server-rendered // section: clicking it must swap the two server-rendered views. await trendBtn.click(); await expect(trendBtn).toHaveAttribute('aria-pressed', 'true'); await expect(yearBtn).toHaveAttribute('aria-pressed', 'false'); await yearBtn.click(); await expect(yearBtn).toHaveAttribute('aria-pressed', 'true'); }); test('sticky section nav jumps to server-rendered sections', async ({ page }) => { const [urn] = await twoPrimaryUrns(page); await page.goto(`/school/${urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); // The nav is client-rendered from a server-computed list, while the sections // themselves are server-rendered. Every link must resolve to a real section: // the scroll-spy finds them with document.getElementById, so a mismatch // between the two halves would dead-end here. const navLinks = page.locator('nav a[href^="#"]'); const count = await navLinks.count(); expect(count).toBeGreaterThan(0); for (let i = 0; i < count; i++) { const href = await navLinks.nth(i).getAttribute('href'); expect(href).toBeTruthy(); await expect(page.locator(href!)).toHaveCount(1); } // And following one actually moves the page. const before = await page.evaluate(() => window.scrollY); await navLinks.last().click(); await page.waitForTimeout(600); const after = await page.evaluate(() => window.scrollY); expect(after).toBeGreaterThan(before); }); /** * Brand surface. The identity work replaced a favicon-only asset set that * broke iOS home-screen icons, Android PWA installs, and every link preview. * These are silent failures — nothing on the page looks wrong — so they need * a gate. */ /** * Chart benchmark markers, against whatever they actually overlap. * * The national-average marker shipped as var(--brand) on both templates — * identical to the bar fill it sits on, so it measured 1.00:1 wherever it * crossed a bar and was visible only for schools BELOW the benchmark. * * The WCAG journey below never saw it, and could not have: it composites * backgrounds by walking the ANCESTOR chain, while these markers are * absolutely positioned over a SIBLING. Ancestor-walking is structurally blind * to overlap. This test asks the stacking order instead — * document.elementsFromPoint returns what is genuinely beneath a point — which * is the same question a reader's eye asks. */ const MARKER_PROBE = `(() => { const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null; const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; }; const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1}); const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);}; return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); }; const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); }; const markers = [...document.querySelectorAll('[class*="natTick"], [class*="NatLine"]')] .filter(el => el.getBoundingClientRect().width > 0); const out = { count: markers.length, failures: [], unmeasured: 0 }; for (const el of markers) { /* * Scroll it in first. elementsFromPoint takes VIEWPORT coordinates and * returns an empty stack for anything off-screen — and these markers sit * well down the page. Without this the backdrop silently defaults to * white, which makes a teal-on-teal marker look like teal-on-white and * pass. 'instant' because globals.css sets scroll-behavior: smooth on * html, and a smooth scroll would still be moving when we measure. */ el.scrollIntoView({ block: 'center', behavior: 'instant' }); const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2, cy = r.top + r.height / 2; const own = ps(getComputedStyle(el).backgroundColor); const edge = ps((getComputedStyle(el).boxShadow.match(/rgba?\\([^)]*\\)/) || [])[0] || ''); if (!own) continue; // What is genuinely underneath, by stacking order rather than by ancestry. const stack = document.elementsFromPoint(cx, cy); if (stack.length === 0) { out.unmeasured++; continue; } // never assume white let backdrop = null; for (const under of stack) { if (under === el || el.contains(under)) continue; const c = ps(getComputedStyle(under).backgroundColor); if (c && c.a > 0.9) { backdrop = c; break; } } if (!backdrop) { out.unmeasured++; continue; } // A knockout marker only needs ONE of its two parts to separate. const best = Math.max(RT(ov(own, backdrop), backdrop), edge ? RT(ov(edge, backdrop), backdrop) : 0); if (best < 3) { out.failures.push((el.className || '?').toString().split(' ')[0] + ' ' + best.toFixed(2) + ':1 over rgb(' + [backdrop.r, backdrop.g, backdrop.b].map(Math.round).join(',') + ')'); } } return out; })()`; async function firstSchoolLink(page: Page, query: string): Promise { await page.goto(`/?search=${encodeURIComponent(query)}`); const link = schoolLinks(page).first(); await expect(link).toBeVisible({ timeout: 15_000 }); return (await link.getAttribute('href'))!; } for (const scheme of ['light', 'dark'] as const) { test(`chart benchmark markers stay visible in the ${scheme} theme`, async ({ browser }) => { const context = await browser.newContext({ colorScheme: scheme }); const page = await context.newPage(); const failures: string[] = []; // One primary (SATs bars) and one secondary (Attainment 8 bar) — the two // templates carry separate implementations of the same marker. for (const query of ['primary', 'academy']) { const href = await firstSchoolLink(page, query); await page.goto(href); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); await page.waitForTimeout(900); // bar widths animate in from an effect const res = (await page.evaluate(MARKER_PROBE)) as { count: number; failures: string[]; unmeasured: number }; test.skip(res.count === 0 && query === 'primary', 'no benchmark markers rendered — environment has no national averages'); // A marker we could not resolve a backdrop for is a hole in the check, // not a pass. Fail loudly rather than quietly measuring nothing. expect(res.unmeasured, `${href}: ${res.unmeasured} of ${res.count} markers had no resolvable backdrop`).toBe(0); failures.push(...res.failures.map(f => `${href} → ${f}`)); } await context.close(); expect(failures, `benchmark markers under 3:1 in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]); }); } test('the brand asset set is complete and served', async ({ page }) => { const response = await page.goto('/'); expect(response?.ok()).toBe(true); // The share card: without it, every link pasted into a chat renders bare. const ogImage = page.locator('meta[property="og:image"]'); await expect(ogImage).toHaveCount(1); const ogUrl = await ogImage.getAttribute('content'); expect(ogUrl).toBeTruthy(); // metadataBase pins canonical URLs to the production host, which is correct // for prod but means the absolute URL points off-environment on staging. // Fetch the path against whichever environment we're actually testing. const og = await page.request.get(new URL(ogUrl!).pathname + new URL(ogUrl!).search); expect(og.ok()).toBe(true); expect(og.headers()['content-type']).toContain('image/png'); /* * iOS ignores SVG touch icons, so this must be a real raster. * * Read the href off the page rather than hardcoding a path. The route moved * when the generated app/apple-icon.tsx became a static app/apple-icon.png: * a generated icon is served at /apple-icon, a static one at /apple-icon.png * with a content hash. This test kept asking for /apple-icon and got a 404 — * reporting a missing icon while the icon was present and correctly linked. */ const appleLink = page.locator('link[rel="apple-touch-icon"]'); await expect(appleLink).toHaveCount(1); const appleHref = await appleLink.getAttribute('href'); expect(appleHref).toBeTruthy(); const apple = await page.request.get(appleHref!); expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true); expect(apple.headers()['content-type']).toContain('image/png'); // Android needs a maskable PNG or the install prompt has no icon. for (const icon of ['/icon-192.png', '/icon-512.png', '/icon-maskable-512.png']) { const res = await page.request.get(icon); expect(res.ok(), `${icon} should be served`).toBe(true); } }); test('theme colour matches the page background in both themes', async ({ browser }) => { // A mismatch here paints a stripe of the wrong colour across the top of the // screen on mobile. Previously the dark themeColor was declared with no dark // styling behind it at all. for (const colorScheme of ['light', 'dark'] as const) { const context = await browser.newContext({ colorScheme }); const page = await context.newPage(); await page.goto('/'); const declared = await page .locator(`meta[name="theme-color"][media*="${colorScheme}"]`) .getAttribute('content'); expect(declared, `theme-color declared for ${colorScheme}`).toBeTruthy(); const painted = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim() ); expect(painted.toLowerCase()).toBe(declared!.toLowerCase()); await context.close(); } }); test('the dark theme actually repaints the page', async ({ browser }) => { // Guards the token layer: if a component reintroduces a hardcoded colour, // the surface below stays light while everything around it flips. const read = async (colorScheme: 'light' | 'dark') => { const context = await browser.newContext({ colorScheme }); const page = await context.newPage(); await page.goto('/'); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); const values = await page.evaluate(() => { const s = getComputedStyle(document.body); return { bg: s.backgroundColor, fg: s.color }; }); await context.close(); return values; }; const light = await read('light'); const dark = await read('dark'); expect(dark.bg).not.toBe(light.bg); expect(dark.fg).not.toBe(light.fg); }); /** * Typography and palette integrity. * * The identity PR shipped with every font-family silently falling back to * Times: the font variables landed on while the tokens referencing * them were declared on :root, so --font-display computed to the * guaranteed-invalid value. Nothing threw, no test failed, and the build was * green — the only symptom was visual. These assertions make that class of * failure loud. */ test('the brand typefaces actually load and apply', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); const fonts = await page.evaluate(() => { const root = getComputedStyle(document.documentElement); // Only the FIRST family in the stack is the one actually asked for; the // rest are fallbacks and always end in a generic like sans-serif. const first = (el: Element) => getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim(); const headings = [...document.querySelectorAll('h1, h2')].map(first); return { body: first(document.body), headings, bodyStack: getComputedStyle(document.body).fontFamily, displayToken: root.getPropertyValue('--font-display').trim(), uiToken: root.getPropertyValue('--font-ui').trim(), proseToken: root.getPropertyValue('--font-prose').trim(), }; }); // An empty token means the var() chain broke, which is the exact failure // mode this guards — the computed font-family would look plausible either // way, because an invalid font-family just inherits. expect(fonts.displayToken, '--font-display resolved').not.toBe(''); expect(fonts.uiToken, '--font-ui resolved').not.toBe(''); expect(fonts.proseToken, '--font-prose resolved').not.toBe(''); // Inter carries body copy, every control and every figure; Manrope carries // headings and key messaging. Both must actually resolve, not merely be // named in a stack that never loads. expect(fonts.body, 'body uses Inter').toBe('Inter'); expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0); expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope'); // The Times fallback is the specific failure that shipped once. Match the // family name only — a stack legitimately ends in sans-serif, so anchoring // on /serif$/ would flag a perfectly healthy page. expect(fonts.bodyStack).not.toMatch(/\bTimes\b/); }); /** * The wordmark is the one piece of brand chrome a user reads on every page, * and it is set as two spans so "compare" can take the brand colour. A * refactor that drops the second span, or reverts the lowercase styling, * changes the brand without failing anything else. */ test('the header carries the schoolcompare lockup', async ({ page }) => { await page.goto('/'); const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i }); await expect(home).toBeVisible({ timeout: 15_000 }); // Assert on rendered text rather than CSS-module class names, which are // hashed at build time and change on any unrelated edit. await expect(home).toHaveText(/^\s*schoolcompare\s*$/); /* * The mark is raster artwork, not an inline SVG — it has been since the * supplied logo replaced the reconstruction, and this locator went on * looking for an that no longer exists. * * Assert on naturalWidth rather than visibility: a whose sources * all 404 still lays out and still passes toBeVisible(), so the weaker * assertion would go green on a broken lockup. */ const mark = home.locator('img').first(); await expect(mark).toBeVisible(); const markState = await mark.evaluate((el: HTMLImageElement) => ({ loaded: el.complete && el.naturalWidth > 0, src: el.currentSrc, })); expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true); }); test('no visible text falls back to the browser default black', async ({ page }) => { // Form controls don't inherit colour from their parent, so a missing // declaration renders pure black — subtle in light mode, invisible in dark. await page.goto('/'); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); const blacks = await page.evaluate(() => [...document.querySelectorAll('body *')] .filter((el) => { const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return false; if (el.closest('.leaflet-tile-pane')) return false; return getComputedStyle(el).color === 'rgb(0, 0, 0)'; }) .map((el) => el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(' ')[0]) .slice(0, 10) ); expect(blacks, `elements rendering pure black: ${blacks.join(', ')}`).toEqual([]); }); test('rendered colours all come from the token palette', async ({ page }) => { // Turns the manual design audit into a gate: anything painted with a colour // the token layer doesn't define has escaped the system, and will not // follow the dark theme. await page.goto('/rankings'); await expect(page.locator('table, [class*="rankings"]').first()).toBeVisible({ timeout: 15_000 }); const strays = await page.evaluate(() => { const root = getComputedStyle(document.documentElement); const palette = new Set(); for (const sheet of document.styleSheets) { let rules: CSSRuleList; try { rules = sheet.cssRules; } catch { continue; } for (const rule of rules) { const r = rule as CSSStyleRule; if (r.selectorText !== ':root' || !r.style) continue; for (const prop of r.style) { if (!prop.startsWith('--')) continue; const v = root.getPropertyValue(prop).trim(); if (v) palette.add(v.toLowerCase()); } } } const norm = (c: string) => { const d = document.createElement('div'); d.style.color = c; document.body.appendChild(d); const v = getComputedStyle(d).color; d.remove(); return v; }; const allowed = new Set([...palette].filter((v) => /^#|^rgb/.test(v)).map(norm)); const found: string[] = []; for (const el of document.querySelectorAll('body *')) { const box = el.getBoundingClientRect(); if (box.width < 2 || box.height < 2) continue; if (el.closest('.leaflet-tile-pane')) continue; // OSM tiles are imagery, not palette const s = getComputedStyle(el); const checks: Array<[string, string]> = [['color', s.color]]; if (s.backgroundColor !== 'rgba(0, 0, 0, 0)') checks.push(['background', s.backgroundColor]); for (const [prop, value] of checks) { if (!value || value.startsWith('rgba(') || allowed.has(value)) continue; const cls = (el.getAttribute('class') || '(none)').split(' ')[0]; const entry = `${value} as ${prop} on ${cls}`; if (!found.includes(entry)) found.push(entry); } } return found.slice(0, 12); }); expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]); }); /** * Contrast, in both themes. * * The status hues were originally specced against --bg-primary, but they are * used as chip text on their own tint, which sits on darker surfaces — so the * real ratios were 3.85–4.44:1, under AA, on every school row and result card. * The page ground is the easy case; the tinted chip is the one that fails. * * Waits for transitions to settle before measuring: several components carry * `transition: color`, and reading mid-transition reports colours that were * never on screen at rest. */ const CONTRAST_PROBE = `(() => { const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null; const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; }; const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1}); const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);}; return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); }; const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); }; const BG = el => { const ls=[]; let n=el; while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor); if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; } const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1}; let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base; for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; }; const out=[], seen=new Set(); for (const el of document.querySelectorAll('body *')) { if (el.closest('.leaflet-container')) continue; const r=el.getBoundingClientRect(), s=getComputedStyle(el); if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue; if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue; const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue; const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc); const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700; const need=(px>=24||(px>=18.66&&bold))?3:4.5; if (ratio >= need) continue; const key=(el.getAttribute('class')||'')+s.color; if (seen.has(key)) continue; seen.add(key); out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+ ') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+ el.textContent.trim().slice(0,28)+'"'); } return out.slice(0, 12); })()`; for (const scheme of ['light', 'dark'] as const) { test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => { const context = await browser.newContext({ colorScheme: scheme }); const page = await context.newPage(); const failures: string[] = []; for (const path of ['/', '/rankings', '/admissions']) { await page.goto(path); await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 }); // Let `transition: color` settle — the longest in the app is 0.4s. await page.waitForTimeout(700); const found = (await page.evaluate(CONTRAST_PROBE)) as string[]; failures.push(...found.map((f) => `${path} → ${f}`)); } await context.close(); expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]); }); } // ── Last distance offered ──────────────────────────────────────────────── // // Coverage is partial by nature: only 57 local authorities publish cut-off // distances at all, and staging may not yet hold the extract. These tests // therefore locate a school that HAS a figure and assert the invariants that // make it safe to show, rather than asserting any particular school has one. /** URNs known to be in the collected cut-off dataset, spread across LAs so a * single council's data gap cannot skip the whole check. */ const CUTOFF_CANDIDATE_URNS = [ 139837, 100010, 100011, 100028, 100029, 130342, // Camden 100458, 135587, // Islington (banded) 101099, 100553, 102574, 100769, // mixed ]; async function schoolWithCutoff(page: Page, minPublishedYears = 1) { for (const urn of CUTOFF_CANDIDATE_URNS) { const res = await page.request.get(`/api/schools/${urn}`); if (!res.ok()) continue; const body = await res.json(); if (body?.admission_distance?.distance_m == null) continue; const published = (body.admission_distance_history ?? []) .filter((h: { distance_m?: number | null }) => h.distance_m != null); if (published.length < minPublishedYears) continue; return { urn, distance: body.admission_distance, history: published, phase: body.school_info?.phase, }; } return null; } test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => { const found = await schoolWithCutoff(page); test.skip(found === null, 'no school in the sample has a published cut-off distance yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); const label = page.getByText(/Last distance offered/).first(); await expect(label).toBeVisible(); // The year is the difference between a fact and a floating number: a cut-off // is the outcome of one admissions round and means nothing without it. await expect(label).toContainText(`September ${found!.distance.year}`); // And the figure itself, in the unit councils publish in. await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible(); }); test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => { const found = await schoolWithCutoff(page); test.skip(found === null, 'no school in the sample has a published cut-off distance yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(/not a fixed catchment/)).toBeVisible(); // The old secondary template asserted "distance cut-off data is not // available for this school" on every page, including ones whose council // does publish it. Showing both at once would be a flat contradiction. await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0); // A banded school's figure is the widest of several routes, and must say so. if ((found!.distance.route_count ?? 1) > 1) { await expect(page.getByText(/admission routes at this school/)).toBeVisible(); } }); test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => { // buildNavItems and the section composers gate on the same condition; when // they drift the nav links to an anchor that was never rendered. const found = await schoolWithCutoff(page); test.skip(found === null, 'no school in the sample has a published cut-off distance yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#admissions')).toHaveCount(1); }); test('no school page shows an implausible cut-off distance', async ({ page }) => { // The source carries transcription errors up to 533 miles, filtered in // stg_school_distance. This asserts the filter is actually reaching the API, // because a visibly absurd figure on a live page is the failure that would // cost the site its credibility. let checked = 0; for (const urn of CUTOFF_CANDIDATE_URNS) { const res = await page.request.get(`/api/schools/${urn}`); if (!res.ok()) continue; const d = (await res.json())?.admission_distance; if (d?.distance_m == null) continue; checked += 1; expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`) .toBeGreaterThanOrEqual(25); expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`) .toBeLessThanOrEqual(25000); } test.skip(checked === 0, 'no cut-off distances available to check yet'); }); // ── The Distance view, map and postcode check ──────────────────────────── test('a school with several published years gets the Distance view', async ({ page }) => { const found = await schoolWithCutoff(page, 2); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); const isSecondary = /secondary/i.test(found!.phase ?? ''); if (!isSecondary) { // Primary pages carry a segmented control; the detail sits behind it. const distanceTab = page.getByRole('button', { name: 'Distance' }); await expect(distanceTab).toBeVisible(); await distanceTab.click(); await expect(distanceTab).toHaveAttribute('aria-pressed', 'true'); } // Every published year must appear as a row, whichever template rendered it. for (const h of found!.history as { year: number }[]) { await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible(); } }); test('the year table never leaves a gap unexplained', async ({ page }) => { // A blank row would tell a parent nothing; each gap must name its reason. const found = await schoolWithCutoff(page, 2); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); if (!/secondary/i.test(found!.phase ?? '')) { await page.getByRole('button', { name: 'Distance' }).click(); } const statuses = await page.locator('[class*="cutoffPill"]').allTextContents(); expect(statuses.length).toBeGreaterThan(0); for (const s of statuses) { expect(s.trim(), 'every row carries a status').not.toBe(''); } // The stronger claim the data cannot support must never appear. await expect(page.getByText(/every applicant was offered/i)).toHaveCount(0); }); test('the postcode check answers with a distance and per-year verdicts', async ({ page }) => { const found = await schoolWithCutoff(page, 2); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); if (!/secondary/i.test(found!.phase ?? '')) { await page.getByRole('button', { name: 'Distance' }).click(); } const input = page.getByLabel('Your postcode'); await expect(input).toBeVisible(); await input.fill('SW1A 1AA'); await page.getByRole('button', { name: 'Check' }).click(); // Either a verdict or a plain error — never a silent no-op. const result = page.getByRole('status'); const error = page.getByRole('alert'); await expect(result.or(error)).toBeVisible({ timeout: 20_000 }); if (await result.isVisible()) { await expect(result).toContainText(/mile|m\b/); } }); test('the postcode check states its limits before it is used', async ({ page }) => { // The caveat is not revealed with the answer — it is there while the parent // is deciding whether to trust the answer at all. const found = await schoolWithCutoff(page, 2); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); await page.goto(`/school/${found!.urn}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); if (!/secondary/i.test(found!.phase ?? '')) { await page.getByRole('button', { name: 'Distance' }).click(); } await expect(page.getByText(/An indication only/)).toBeVisible(); await expect(page.getByText(/not a catchment boundary/)).toBeVisible(); }); test('the distance section never scrolls the page sideways', async ({ page }) => { const found = await schoolWithCutoff(page, 2); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet'); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/school/${found!.urn}`); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); if (!/secondary/i.test(found!.phase ?? '')) { await page.getByRole('button', { name: 'Distance' }).click(); } // The year table is deliberately wider than a phone; its own wrapper has to // absorb that, or the whole page slides under the reader's thumb. const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1); });