Files
school_compare/e2e/tests/journeys.spec.ts
T
TudorandClaude Opus 5 4043270a77
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
fix(home): stop implying we are official, and fix the mobile hero and search
Three things, all reported from the live mobile view.

WE ARE NOT AN OFFICIAL SERVICE

The value prop read "Official & trusted", whose grammatical subject is this
site — it reads as a claim that schoolcompare is an official service. It is
not; it is an independent site that republishes official figures. Now "Built
on official data", which describes the data instead.

Checked every other use of the word: all nine describe the data ("official DfE
figures", "the official figure isn't in") and are correct as they stand. That
one title was the only place the site described itself.

The footer now says it outright — "An independent site. Not affiliated with
the Department for Education or Ofsted." — so it appears on every page rather
than being left to inference. An e2e test asserts both halves: that the
statement is present, and that no text presents the site itself as official.

The disclaimer first measured 4.71:1 against a 4.5 floor. That is a fine
margin for decoration and the wrong one for a line whose job is to be legible
to someone checking whether this is a government site; it is now 5.59:1,
quieter than the copy around it by size rather than by contrast.

THE ARTWORK SAT UNDER THE SEARCH ON PHONES

The DOM keeps .heroContent first so the desktop overlay does not depend on
source order, which left the band stranded at the bottom of the panel, reading
as a strip stuck on the end rather than a hero image. `order` moves it above
the copy on phones; it is decorative and aria-hidden, so no reading order
changes. Measured on a 667px viewport — the shortest phone still in use — the
search button lands at 418px, comfortably inside the fold.

THE SEARCH INPUT WAS UNUSABLE ON PHONES

"Search schools" is a fixed 134px with white-space: nowrap, so it took 48% of
the row. Measured on staging:

  390px   input text space 102px   placeholder needs 194px
  360px                     72px
  320px                     32px

At 320px you could not see what you were typing. The button now wraps to its
own full-width row below 480px, which fixes 360px and up — 390px goes from
102px to 226px of text space. The pill stays a single element, so it keeps its
border, shadow and :focus-within ring, and the button gains a full-width tap
target.

320px is still ~30px short. Closing it needs a shorter placeholder, which three
other tests match on by exact string; left alone deliberately rather than
churn them for a width that is effectively gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:59:12 +01:00

1085 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <picture> 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 <img> fallback. Chromium takes AVIF, so seeing
// the fallback here means the <source> 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.
*/
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.
const apple = await page.request.get('/apple-icon');
expect(apple.ok()).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 <body> 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*$/);
const mark = home.locator('svg').first();
await expect(mark).toBeVisible();
});
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<string>();
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([]);
});
}