Files
school_compare/e2e/tests/journeys.spec.ts
T

2878 lines
129 KiB
TypeScript
Raw Normal View History

import { test, expect, Locator, 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/"]');
}
/**
* A scroll offset that has stopped moving.
*
* The carousel arrows scroll with `behavior: 'smooth'`, so a reading taken
* straight after a click lands mid-animation. Measured against staging: the
* animation runs ~700ms, and a poll for "has it moved at all" is satisfied
* 50ms in, at 13px of a 1300px journey. A test that then records an offset,
* does something, and records again is measuring the tail of the arrow's
* animation rather than the effect of whatever it did in between.
*
* Two identical readings in a row is the cheapest sound definition of settled.
*/
async function settledScrollLeft(scroller: Locator): Promise<number> {
let previous = -1;
await expect
.poll(async () => {
const current = await scroller.evaluate((node: HTMLElement) => Math.round(node.scrollLeft));
const settled = current === previous;
previous = current;
return settled;
}, { timeout: 10_000 })
.toBe(true);
return previous;
}
/**
* 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.
*/
/**
* 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<string> {
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 <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*$/);
/*
* 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 <svg> that no longer exists.
*
* Assert on naturalWidth rather than visibility: a <picture> 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<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([]);
});
}
// ── 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
];
/**
* Whether the last-distance-offered feature is switched on here.
*
* Read from the data rather than from /api/flags, which the public proxy
* denies on purpose — the endpoint names unreleased features. The observable
* effect is the field's presence: the flag is off iff no candidate school
* carries an `admission_distance` key at all.
*
* The distinction that matters: `admission_distance: null` means this school
* has no published cut-off, and the key being ABSENT means cut-offs are not
* being published at all.
*/
async function distanceFeatureIsOn(page: Page): Promise<boolean> {
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
if ('admission_distance' in (await res.json())) return true;
}
return false;
}
async function schoolWithCutoff(page: Page) {
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;
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
}
return null;
}
test('when the distance feature is on, a school with a cut-off is findable', async ({ page }) => {
/*
* The gate that stops the other distance journeys passing vacuously.
*
* They all skip when schoolWithCutoff() finds nothing, which is right when
* the feature is off — but it means a feature that is *supposed* to be on
* and is silently broken shows up as a green run full of skips. This test
* fails in that case.
*/
test.skip(!(await distanceFeatureIsOn(page)),
'the admission_distance flag is off in this environment');
expect(await schoolWithCutoff(page),
'the distance feature is on, but no candidate school has a cut-off — '
+ 'the flag is on and the data or the query behind it is broken')
.not.toBeNull();
});
test('with the distance feature off, the section is absent rather than empty', async ({ page }) => {
// Shipping dark means the page renders as it did before the feature existed,
// not as a feature with its content removed.
test.skip(await distanceFeatureIsOn(page),
'the admission_distance flag is on in this environment');
// A school that exists, found rather than hardcoded — a 404 page would
// satisfy the absent-heading assertion without proving anything.
//
// A plain loop, not Array.find: find's predicate is synchronous, so an async
// one returns a Promise, every Promise is truthy, and it would always hand
// back the first URN whether or not that school exists.
let urn: number | null = null;
for (const candidate of CUTOFF_CANDIDATE_URNS) {
if ((await page.request.get(`/api/schools/${candidate}`)).ok()) {
urn = candidate;
break;
}
}
expect(urn, 'no candidate school resolves in this environment').not.toBeNull();
await page.goto(`/school/${urn}`);
await expect(page.locator('h1')).toBeVisible();
await expect(page.getByRole('heading', { name: /How far away are you\?/ }))
.toHaveCount(0);
});
/**
* A secondary school carrying an EES admissions row, which is what makes its
* Admissions section render while the distance feature is dark.
*/
async function secondarySchoolWithAdmissions(page: Page) {
const list = await page.request.get('/api/schools?phase=secondary&page_size=40');
if (!list.ok()) return null;
const body = await list.json();
for (const s of (body?.schools ?? []).slice(0, 25)) {
const res = await page.request.get(`/api/schools/${s.urn}`);
if (!res.ok()) continue;
const detail = await res.json();
if (detail?.admissions == null) continue;
return { urn: s.urn as number };
}
return null;
}
test('with the distance feature off, a secondary page makes no claim about publication', async ({ page }) => {
/*
* Shipping dark must not put words in the council's mouth. The secondary
* template is the only one that words the absence, and "X has not published
* a cut-off distance for this school" is false wherever X does publish and
* we are simply withholding it.
*
* This is why the API omits the key rather than sending null: absent means
* "cut-offs are not published at all", null means "this school has none".
* Only the second is a fact about the school, and only the second is sayable.
*/
test.skip(await distanceFeatureIsOn(page),
'the admission_distance flag is on in this environment');
const found = await secondarySchoolWithAdmissions(page);
test.skip(found === null, 'no secondary school in the sample has an admissions row');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// The Admissions section is still there — this is not a test that the whole
// section vanished, which would pass for the wrong reason.
await expect(page.locator('#admissions')).toHaveCount(1);
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
await expect(page.getByText(/Contact the admissions authority/)).toHaveCount(0);
});
test('/api/flags is not reachable from the public internet', async ({ page }) => {
// It names every unreleased feature and whether it is on. Next reads it
// server-side over the Docker network; the public proxy must deny it.
const res = await page.request.get('/api/flags');
expect(res.status()).toBe(404);
});
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.
// No trailing \b: the tile's support figure is an adjacent text node, so
// the element reads "0.88 miles 1.4 km" and a word boundary after "miles"
// is not guaranteed. The leading shape is what matters — a decimal figure
// in miles, never a metric one.
await expect(page.getByText(/\d+\.\d+ miles/).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('the public API serves the latest cut-off only', async ({ page }) => {
/*
* Earlier years are held back as a paid feature. This endpoint is public and
* unauthenticated, so withholding them in the UI alone would be decoration:
* anyone could read the history out of the network tab. The mart still holds
* every year — this asserts what leaves the process, not what was collected.
*/
let checked = 0;
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;
checked += 1;
expect(body, `URN ${urn} still exposes a cut-off history`)
.not.toHaveProperty('admission_distance_history');
}
test.skip(checked === 0, 'no cut-off distances available to check yet');
});
test('a school page shows no year-by-year cut-off record', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
// The render side of the same rule, so a future component cannot put the
// history back without the API.
await expect(page.getByText(/Last distance offered, by year/)).toHaveCount(0);
await expect(page.getByText(/too few to read as a trend/)).toHaveCount(0);
/*
* No "Distance" tab in the admissions segmented control.
*
* Scoped to the control, and exact, because getByRole matches accessible
* names by case-insensitive SUBSTRING: an unscoped { name: 'Distance' }
* matched "Show this distance on a map" — the map toggle added by this same
* feature — and failed a page that was entirely correct. Naming the group
* this assertion is about also means unrelated copy elsewhere on the page
* can never break it again.
*/
const viewToggle = page.getByRole('group', { name: 'Admissions view' });
const tabs = await viewToggle.getByRole('button').allTextContents();
// Read the tabs positively rather than asserting an absence against a
// locator that might resolve to nothing: if the group selector ever stops
// matching, an absence check passes for the wrong reason, which is how the
// bug this test is guarding would slip back in unnoticed.
expect(tabs, 'admissions view toggle did not resolve').toContain('This year');
expect(tabs.map((s) => s.trim()), `admissions tabs: ${tabs.join(', ')}`)
.not.toContain('Distance');
});
test('the postcode check answers for the published year, and names it', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
// Needs coordinates as well as a figure; not every school has both.
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
await page.getByLabel('Your postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Check', exact: true }).click();
const result = page.getByRole('status');
const error = page.getByRole('alert');
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
if (await result.isVisible()) {
// A verdict without its year is a number a parent cannot place.
await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`));
await expect(result).toContainText(/inside|beyond|too close/);
// Both figures in the sentence must be in the same unit. The verdict used
// to render the home distance in metres and the cut-off in miles — "69 m
// away — inside the September 2026 cut-off of 0.17 miles" — which asks the
// reader to convert between units to check a comparison already made for
// them. Miles throughout: the unit councils publish and quote.
const headline = (await result.textContent()) ?? '';
const verdictLine = headline.split('.')[0];
expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/);
expect((verdictLine.match(/miles/g) ?? []).length,
`verdict should quote two figures in miles: ${verdictLine}`).toBe(2);
}
});
test('the postcode check states its limits before it is used', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
const caveat = page.getByText(/Distance is the last criterion applied/);
await expect(caveat).toBeVisible();
await expect(caveat).toContainText(/not a catchment boundary/);
await expect(caveat).toContainText(/walking route/);
await expect(caveat).toHaveCount(1);
});
test('the distance section never scrolls the page sideways', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
});
test('no single section dominates the height of a school page', async ({ page }) => {
/*
* The admissions views are stacked in one grid cell so switching them never
* shifts layout, which means the card is sized by its TALLEST view while the
* hidden ones keep their box. A distance view carrying a chart, a table and a
* map was added there and measured 1402px against the tile grid's 316px; the
* DEFAULT view rendered as four tiles adrift in ~1080px of blank card, and
* Admissions alone came to half the height of every section on the page
* (1503px against 526px for the next largest).
*
* Measuring the card's internals cannot catch it: the tile grid is
* `flex: 1`, so it absorbs the stretch and every box still looks full. What
* a reader actually sees is one section wildly out of proportion with its
* neighbours, so that is what this asserts.
*/
const found = await schoolWithCutoff(page);
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 });
await page.waitForTimeout(1200); // charts settle, and they carry real height
const sections = await page.evaluate(() =>
[...document.querySelectorAll('section[id]')]
.map((s) => ({ id: s.id, h: Math.round(s.getBoundingClientRect().height) }))
.filter((s) => s.h > 0));
expect(sections.length).toBeGreaterThanOrEqual(3);
const heights = sections.map((s) => s.h).sort((a, b) => a - b);
const median = heights[Math.floor(heights.length / 2)];
const worst = sections.reduce((a, b) => (a.h > b.h ? a : b));
// Generous: a rich section may fairly run to twice a plain one. Nearly three
// times over is the shape of a layout fault, not of denser content.
expect(
worst.h / median,
`#${worst.id} is ${worst.h}px against a ${median}px median: `
+ sections.map((s) => `${s.id}=${s.h}`).join(', '),
).toBeLessThan(2.5);
});
/*
* England-only corpus.
*
* GIAS ships the whole UK plus overseas and offshore establishments, none of
* which carry comparable DfE performance data. dim_school and dim_location
* exclude them (vars.non_england_school_type_codes in dbt_project.yml), which
* keeps them out of the site, the filter lists and the sitemap together.
*
* These assert the published surface, not the warehouse: the dbt test
* assert_england_only_schools guards the marts, and these guard what the
* environment actually serves once the marts have been rebuilt.
*/
const WELSH_AUTHORITIES = [
'Blaenau Gwent', 'Bridgend', 'Caerphilly', 'Cardiff', 'Carmarthenshire',
'Ceredigion', 'Conwy', 'Denbighshire', 'Flintshire', 'Gwynedd',
'Isle of Anglesey', 'Merthyr Tydfil', 'Monmouthshire', 'Neath Port Talbot',
'Newport', 'Pembrokeshire', 'Powys', 'Rhondda Cynon Taf', 'Swansea',
'Torfaen', 'Vale of Glamorgan', 'Wrexham',
];
const NON_ENGLAND_AUTHORITIES = [
'BFPO Overseas Establishments', 'Fieldwork Overseas Establishments',
'Gibraltar Overseas Establishments', 'Guernsey Offshore Establishments',
'Isle of Man Offshore Establishments', 'Jersey Offshore Establishments',
'Scotland Offshore Establishments',
];
const NON_ENGLAND_TYPES = [
'Welsh establishment', 'Offshore schools',
"Service children's education", 'British schools overseas',
];
test('the local authority filter offers no Welsh or overseas authority', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { local_authorities: las } = await res.json();
expect(Array.isArray(las)).toBeTruthy();
// Guards against the list being empty, which would pass the check below
// for the wrong reason.
expect(las.length).toBeGreaterThan(100);
const leaked = [...WELSH_AUTHORITIES, ...NON_ENGLAND_AUTHORITIES]
.filter((la) => las.includes(la));
expect(leaked, `non-England authorities still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('the school type filter offers no non-England establishment type', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { school_types: types } = await res.json();
expect(Array.isArray(types)).toBeTruthy();
expect(types.length).toBeGreaterThan(10);
const leaked = NON_ENGLAND_TYPES.filter((t) => types.includes(t));
expect(leaked, `non-England types still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('searching a Welsh authority by name returns no schools', async ({ page }) => {
// Cardiff held 144 Welsh establishments and nothing else, so the authority
// should now be absent from the corpus entirely rather than merely thinned.
const res = await page.request.get('/api/schools?local_authority=Cardiff&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
});
test('a Welsh school GIAS does not type as Welsh is gone too', async ({ page }) => {
// Beechwood College, Sully, CF64 5SE. GIAS types it "Special post 16
// institution" (32), not "Welsh establishment" (30), so the establishment
// type filter alone left it behind — it was the last Welsh school standing.
const res = await page.request.get('/api/schools?local_authority=Vale%20of%20Glamorgan&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
const page404 = await page.goto('/school/142458-beechwood-college');
expect(page404?.status()).toBe(404);
});
test('English schools with Welsh postcodes are kept', async ({ page }) => {
// Royal Mail postcode areas straddle the border: these Gloucestershire
// schools carry NP16/NP25 postcodes but are English schools with English
// data. A postcode-based filter would have wrongly deleted them, so this
// guards the fix from being "simplified" into one.
for (const urn of [115639, 115655, 137382]) {
const res = await page.request.get(`/api/schools/${urn}`);
expect(res.ok(), `URN ${urn} should still resolve`).toBeTruthy();
const { school_info } = await res.json();
expect(school_info.local_authority).toBe('Gloucestershire');
}
});
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
// Paired on purpose: the Welsh assertion alone would also pass if the whole
// site were down, which is the failure this test most needs to distinguish.
const english = await page.request.get('/api/schools?search=primary&per_page=1');
expect(english.ok()).toBeTruthy();
const [first] = (await english.json()).schools ?? [];
expect(first, 'no English school available to compare against').toBeTruthy();
const good = await page.goto(`/school/${first.urn}-x`);
expect(good?.status(), 'an English school should still resolve').toBeLessThan(400);
// Adamsdown Primary School, Cardiff — a Welsh establishment (URN 401559).
const welsh = await page.goto('/school/401559-adamsdown-primary-school');
expect(welsh?.status(), 'a Welsh school should no longer resolve').toBe(404);
});
async function sitemapChildren(page: Page): Promise<string[]> {
const res = await page.request.get('/sitemap.xml');
expect(res.ok()).toBeTruthy();
const index = await res.text();
expect(index).toContain('<sitemapindex');
return [...index.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
}
test('the sitemap index names children that all resolve', async ({ page }) => {
const index = await (await page.request.get('/sitemap.xml')).text();
// An index holds <sitemap> entries only; mixing in <url> is invalid.
expect(index).not.toContain('<url>');
const locs = await sitemapChildren(page);
expect(locs.length).toBeGreaterThanOrEqual(2);
for (const loc of locs) {
expect(loc.startsWith('https://www.schoolcompare.co.uk/sitemaps/')).toBeTruthy();
const child = await page.request.get(new URL(loc).pathname);
expect(child.ok(), `${loc} should resolve`).toBeTruthy();
expect(await child.text()).toContain('<urlset');
}
});
test('the sitemap submits no Welsh or overseas school', async ({ page }) => {
const locs = await sitemapChildren(page);
let total = 0;
for (const loc of locs) {
const xml = await (await page.request.get(new URL(loc).pathname)).text();
total += (xml.match(/<url>/g) ?? []).length;
// 401559 (Adamsdown, Cardiff) and 402426 (ACT Schools, Cardiff) were both
// submitted before the England-only filter landed.
expect(xml).not.toContain('/school/401559');
expect(xml).not.toContain('/school/402426');
}
expect(total, 'sitemap looks empty or truncated').toBeGreaterThan(1000);
});
test('the sitemap invents no priority or changefreq', async ({ page }) => {
const [first] = await sitemapChildren(page);
expect(first).toBeTruthy();
const xml = await (await page.request.get(new URL(first).pathname)).text();
// Google ignores both. They were noise dressed as signal.
expect(xml).not.toContain('<priority>');
expect(xml).not.toContain('<changefreq>');
});
/*
* Canonical URLs (spec 2026-08-20, W1).
*
* Every indexable route declares exactly one canonical, on the www host, with
* no query string. The homepage's eleven search params filter a result set
* rather than making a new document, so they all collapse onto "/".
*/
const CANONICAL_ROUTES: Array<[string, string]> = [
['/', 'https://www.schoolcompare.co.uk/'],
['/rankings', 'https://www.schoolcompare.co.uk/rankings'],
['/admissions', 'https://www.schoolcompare.co.uk/admissions'],
];
/**
* Next normalises canonical URLs against `trailingSlash: false`, so the root
* ships as `https://www.schoolcompare.co.uk` with no slash while every other
* route keeps its path. Both forms address the same document, and which one
* Next emits is its business, not something worth pinning a test to.
*
* The first cut hardcoded the slash and failed only on the homepage — the
* same gap as the doubled brand: it asserted the metadata object rather than
* what the page actually renders.
*/
function sameUrl(a: string | null, b: string): boolean {
const strip = (u: string) => u.replace(/\/+$/, '');
return strip(a ?? '') === strip(b);
}
for (const [path, expected] of CANONICAL_ROUTES) {
test(`${path} declares exactly one canonical, on the www host`, async ({ page }) => {
await page.goto(path);
const hrefs = await page.locator('link[rel="canonical"]').evaluateAll(
(els) => els.map((e) => e.getAttribute('href')));
expect(hrefs, `${path} should declare one canonical`).toHaveLength(1);
expect(sameUrl(hrefs[0], expected),
`${path} canonical was ${hrefs[0]}, expected ${expected}`).toBe(true);
});
}
test('a filtered homepage still canonicalises to the bare root', async ({ page }) => {
await page.goto('/?search=primary&phase=primary&sort=name&page=2');
const href = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(sameUrl(href, 'https://www.schoolcompare.co.uk/'),
`filtered homepage canonical was ${href}`).toBe(true);
});
test('a school page canonicalises to its own slug on the www host', async ({ page }) => {
const res = await page.request.get('/api/schools?search=primary&per_page=1');
expect(res.ok()).toBeTruthy();
const [first] = (await res.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
await page.goto(`/school/${first.urn}-x`);
const href = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(href).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\/\d+-/);
});
test('a bare /compare is indexable, a parameterised one is not', async ({ page }) => {
await page.goto('/compare');
await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
const [a, b] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${a},${b}`);
const robots = await page.locator('meta[name="robots"]').first()
.getAttribute('content');
expect(robots).toContain('noindex');
expect(robots).toContain('follow');
// noindex but follow: the links out to each school page still count, so the
// canonical must still be present and point at the bare path.
const canonical = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(canonical).toBe('https://www.schoolcompare.co.uk/compare');
});
/*
* Staging must not be indexable (spec 2026-08-20, W1 hygiene).
*
* These journeys only ever run against staging — deploy.yml passes
* STAGING_BASE_URL, and promote.yml only smoke-polls production without
* Playwright — so asserting the noindex header here is safe.
*/
test('staging answers noindex, and stays crawlable so the noindex is seen', async ({ page }) => {
const res = await page.request.get('/');
expect(res.ok()).toBeTruthy();
const tag = res.headers()['x-robots-tag'];
expect(tag, 'staging must send X-Robots-Tag').toBeTruthy();
expect(tag).toContain('noindex');
// The other half, and the reason this is one test rather than two: a
// Disallow would stop Google fetching the page at all, so it would never
// see the noindex above. The two only work together.
//
// Scoped to the `*` group. The first cut matched `Disallow: /` anywhere in
// the file and tripped over the AI-crawler groups Cloudflare injects —
// ClaudeBot, GPTBot, Amazonbot and friends all carry a blanket disallow,
// deliberately, and none of them is Googlebot.
const robots = await (await page.request.get('/robots.txt')).text();
expect(blocksEverything(robots, '*'),
'the * group must not disallow the whole site, or the noindex is never seen')
.toBe(false);
});
/** True when `agent`'s group in a robots.txt disallows the entire site. */
function blocksEverything(robots: string, agent: string): boolean {
let current: string | null = null;
let blocked = false;
for (const raw of robots.split('\n')) {
const line = raw.split('#')[0].trim();
if (!line) continue;
const [key, ...rest] = line.split(':');
const value = rest.join(':').trim();
const k = key.trim().toLowerCase();
if (k === 'user-agent') current = value;
else if (current === agent && k === 'disallow' && value === '/') blocked = true;
}
return blocked;
}
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
const list = await page.request.get('/api/schools?search=primary&per_page=1');
const [first] = (await list.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
const res = await page.request.get(`/school/${first.urn}-x`);
expect(res.headers()['x-robots-tag']).toContain('noindex');
});
/*
* W8 — the C1 pages must ship a description, and it must differentiate.
*
* Baseline was 0.43% CTR at position 6.1 on "compare school performance",
* against 9.16% for the brand query from the same neighbourhood. The SERP is
* owned by the DfE's own service, so a description that paraphrases it earns
* nothing. Google may rewrite a snippet, but it cannot use one we never sent.
*/
test('every C1 page ships a description, and none opens its title with the brand', async ({ page }) => {
for (const path of ['/', '/compare', '/rankings', '/admissions']) {
await page.goto(path);
const desc = await page.locator('meta[name="description"]').first()
.getAttribute('content');
expect(desc, `${path} must ship a description`).toBeTruthy();
expect(desc!.length, `${path} description too short to be worth reading`)
.toBeGreaterThan(100);
const title = await page.title();
expect(title.toLowerCase().startsWith('schoolcompare'),
`${path} spends its most valuable pixels on the brand`).toBe(false);
}
});
test('the homepage snippet names what gov.uk does not publish', async ({ page }) => {
await page.goto('/');
const desc = await page.locator('meta[name="description"]').first()
.getAttribute('content');
// Admissions distance is the one fact the DfE service has no equivalent for.
expect(desc).toMatch(/close you had to live|distance/i);
});
/*
* The location layer (spec 2026-08-21, W2).
*
* Location intent sat at position 49.5 with one click across the whole 16-month
* baseline — the site published no page about a place. These assert the four
* families render, stay in their own namespaces, and reach the sitemap.
*/
async function firstPlaceOfKind(page: Page, kind: string) {
const res = await page.request.get('/api/places');
expect(res.ok()).toBeTruthy();
const { places } = await res.json();
const hit = places.find((p: { kind: string }) => p.kind === kind);
expect(hit, `no ${kind} in the registry`).toBeTruthy();
return hit as { kind: string; slug: string; name: string; count: number };
}
/**
* The round trip. Place pages always linked down to school pages; school
* pages linked nowhere on the site, so the ~27k of them that carry most of
* the inbound authority stranded it — their only anchor pointed at the
* school's own website.
*
* Asserting both directions is the point. A one-way link is what already
* existed and is not what this journey is for.
*/
test('a school page links back into the location layer, and the place page links down', async ({ page }) => {
const town = await firstPlaceOfKind(page, 'town');
// Start from the place page and take its first school, so the pair is
// guaranteed to be genuinely related rather than a hardcoded guess.
await page.goto(`/schools/${town.slug}`);
const schoolHref = await page.locator('a[href^="/school/"]').first()
.getAttribute('href');
expect(schoolHref, 'the town page listed no school to follow').toBeTruthy();
await page.goto(schoolHref!);
// Down: the school page must offer a link back to the town it sits in.
const backToTown = page.locator(`a[href="/schools/${town.slug}"]`);
await expect(backToTown).toHaveCount(1);
await expect(backToTown).toBeVisible();
// The anchor says what it leads to, which is worth more than "see more".
await expect(backToTown).toContainText(town.name, { ignoreCase: true });
await expect(backToTown).toContainText(/\d+ schools?/);
// And the breadcrumb resolves the school into a real hierarchy.
const blocks = await page.locator('script[type="application/ld+json"]')
.allTextContents();
const graph = blocks.join(' ');
expect(graph).toContain('"BreadcrumbList"');
// The narrower type, not the EducationalOrganization parent it used to be.
expect(graph).toContain('"School"');
/*
* The phase variants are the pages this most needs to reach: ~950 of them
* were once reachable by nothing at all, absent from every sitemap and
* unlinked from the place page. Conditional because not every school sits
* in a town that publishes one.
*/
const phaseLink = page.locator(`a[href^="/schools/${town.slug}/"]`).first();
if (await phaseLink.count()) {
const phaseHref = await phaseLink.getAttribute('href');
expect((await page.request.get(phaseHref!)).status()).toBe(200);
await expect(phaseLink).toContainText(/primary|secondary/);
}
// Following it lands on a real page, not a 404.
await backToTown.click();
await page.waitForURL(new RegExp(`/schools/${town.slug}$`));
await expect(page.locator('h1')).toContainText(town.name, { ignoreCase: true });
});
for (const [kind, prefix, article] of [
['town', '/schools/', 'a'],
['authority', '/schools/authority/', 'an'],
['outcode', '/schools/near/', 'an'],
] as const) {
test(`${article} ${kind} page renders with its school count`, async ({ page }) => {
const place = await firstPlaceOfKind(page, kind);
await page.goto(`${prefix}${place.slug}`);
await expect(page.locator('h1')).toContainText(place.name, { ignoreCase: true });
await expect(page.locator('a[href^="/school/"]').first()).toBeVisible();
});
}
test('a town and an authority sharing a name are different pages', async ({ page }) => {
// 67 real collisions, and the authority is the larger set in only 43 — so
// one namespace would have published near-duplicates.
const { places } = await (await page.request.get('/api/places')).json();
const townSlugs = new Set(
places.filter((p: { kind: string }) => p.kind === 'town')
.map((p: { slug: string }) => p.slug));
const clash = places.find((p: { kind: string; slug: string }) =>
p.kind === 'authority' && townSlugs.has(p.slug));
test.skip(!clash, 'no town/authority name collision in this environment');
const townRes = await page.request.get(`/api/places/town/${clash.slug}`);
const laRes = await page.request.get(`/api/places/authority/${clash.slug}`);
expect(townRes.ok() && laRes.ok()).toBeTruthy();
const townUrns = (await townRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
const laUrns = (await laRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
expect(townUrns).not.toEqual(laUrns);
});
test('a place below the threshold has no page', async ({ page }) => {
// Crosby holds one school; publishing it would be a page with nothing to say.
const res = await page.request.get('/api/places/town/crosby');
expect(res.status()).toBe(404);
});
test('place pages declare a canonical and reach the sitemap', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
const canonical = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(canonical).toBe(`https://www.schoolcompare.co.uk/schools/${place.slug}`);
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toContain(`/schools/${place.slug}`);
});
test('the place page ships ItemList structured data that parses', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
const raw = await page.locator('script[type="application/ld+json"]').first()
.textContent();
const parsed = JSON.parse(raw!);
const types = (parsed['@graph'] ?? []).map((n: { '@type': string }) => n['@type']);
expect(types).toContain('ItemList');
expect(types).toContain('BreadcrumbList');
});
test('a place page states the local average against England', async ({ page }) => {
// The one number a list cannot give, and the reason these pages are not
// a name dropped into a template.
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
await expect(page.getByTestId('local-vs-england')).toContainText(/across England/i);
});
test('a place page links its phase variants, and they resolve', async ({ page }) => {
// "primary schools in beccles" is the query shape the baseline showed. The
// first cut submitted only the bare place URL and linked nothing, leaving
// ~950 variant pages reachable by nothing at all.
const res = await page.request.get('/api/places');
const { places } = await res.json();
const town = places.find((p: { kind: string }) => p.kind === 'town');
expect(town).toBeTruthy();
const detail = await (await page.request.get(`/api/places/town/${town.slug}`)).json();
test.skip(!(detail.place.phases ?? []).length, 'no phase clears the threshold here');
await page.goto(`/schools/${town.slug}`);
const phase = detail.place.phases[0];
const link = page.locator(`a[href="/schools/${town.slug}/${phase}"]`).first();
await expect(link).toBeVisible();
await link.click();
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
});
/*
* The table shipped with one column of scores. A parent shortlisting from a
* town page needs to know whether a school takes their child's age, whether
* it is a faith school, and — for a primary — whether it has a nursery,
* before a percentage means anything.
*
* These assert the column headings rather than the values: nursery_provision
* and parliamentary_constituency are optional mart columns, and on an
* environment whose pipeline has not rebuilt them the API degrades them to
* absent. A value assertion would then fail for a data reason, not a code one.
*/
async function phasedPlace(page: Page, phase: 'primary' | 'secondary') {
const place = await firstPlaceOfKind(page, 'town');
const detail = await (await page.request.get(`/api/places/town/${place.slug}`)).json();
test.skip(!(detail.place.phases ?? []).includes(phase),
`no ${phase} page clears the threshold here`);
return place;
}
test('a primary place page names each school as well as scoring it', async ({ page }) => {
const place = await phasedPlace(page, 'primary');
await page.goto(`/schools/${place.slug}/primary`);
for (const heading of ['Ages', 'Religious character', 'Nursery', 'Constituency']) {
await expect(page.getByRole('columnheader', { name: heading, exact: true }))
.toBeVisible();
}
// age_range rides in on SCHOOL_COLUMNS and predates the optional columns,
// so it is the one attribute safe to assert a value for anywhere.
await expect(page.locator('table tbody td').filter({ hasText: /^\d+–\d+$/ }).first())
.toBeVisible();
});
test('a secondary place page does not ask about nurseries', async ({ page }) => {
const place = await phasedPlace(page, 'secondary');
await page.goto(`/schools/${place.slug}/secondary`);
await expect(page.getByRole('columnheader', { name: 'Ages', exact: true }))
.toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Nursery', exact: true }))
.toHaveCount(0);
});
test('the measure stays beside the school name, not behind a swipe', async ({ page }) => {
// Six columns overflow a phone; .tableWrap turns that into a horizontal
// scroll. With the measure last, the number the page exists for is the one
// off the screen.
const place = await phasedPlace(page, 'primary');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/schools/${place.slug}/primary`);
const second = page.locator('table thead th').nth(1);
await expect(second).toContainText(/reading, writing/i);
await expect(second).toBeInViewport();
});
test('phase variants are submitted in the places sitemap', async ({ page }) => {
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
});
test('authority phase variants are submitted, and in their own namespace', async ({ page }) => {
// 302 of these were in the sitemap for weeks and every one 404'd: the spec
// called for the route, the plan built the bare authority page and dropped
// it, and the sitemap — written from the registry — kept submitting them.
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toMatch(/\/schools\/authority\/[a-z0-9-]+\/primary</);
});
test('every place link a place page emits resolves', async ({ page }) => {
/*
* The guard that was missing. Each family built its own links, so a URL
* shape belonging to one namespace was used by all four: an authority page
* offered "Primary schools in Barnet" pointing at /schools/barnet/primary,
* the *town*. For 87 of 151 authorities that 404'd; for the other 64 it
* quietly served a different set of schools under the same name.
*
* Only /schools links are followed. The per-school links are the same
* component the school-page journeys already cover, and there are hundreds
* of them on a page.
*/
for (const kind of ['town', 'authority', 'outcode'] as const) {
const place = await firstPlaceOfKind(page, kind);
const prefix = kind === 'authority' ? '/schools/authority/'
: kind === 'outcode' ? '/schools/near/' : '/schools/';
await page.goto(`${prefix}${place.slug}`);
const hrefs = [...new Set(
await page.locator('a[href^="/schools"]').evaluateAll(
(els) => els.map((e) => e.getAttribute('href')!)))];
expect(hrefs.length, `${kind} page links no other place`).toBeGreaterThan(0);
for (const href of hrefs) {
const res = await page.request.get(href);
expect(res.status(), `${kind} page links ${href}`).toBe(200);
}
}
});
test('an outcode page offers no phase link, because no such page exists', async ({ page }) => {
// Nobody searches "primary schools in SW11", so the spec gives outcodes no
// phase route. The registry computed the variants anyway and the page
// linked them, putting two 404s on each of 1,720 outcode pages.
const place = await firstPlaceOfKind(page, 'outcode');
const detail = await (await page.request.get(
`/api/places/outcode/${place.slug}`)).json();
expect(detail.place.phases).toEqual([]);
await page.goto(`/schools/near/${place.slug}`);
await expect(page.getByRole('navigation', { name: 'By phase' })).toHaveCount(0);
});
test('no page title repeats the brand', async ({ page }) => {
// The root layout appends '| schoolcompare' to a plain-string title. Any
// route whose title already carries the brand must opt out with
// `absolute`, or it ships '... | schoolcompare | schoolcompare' — which is
// how ~2,600 place pages first went out.
const res = await page.request.get('/api/places');
const { places } = await res.json();
const town = places.find((p: { kind: string }) => p.kind === 'town');
for (const path of ['/', '/rankings', '/admissions', `/schools/${town.slug}`]) {
await page.goto(path);
const title = await page.title();
const brands = (title.match(/schoolcompare/gi) ?? []).length;
expect(brands, `${path} repeats the brand: ${title}`).toBeLessThanOrEqual(1);
}
});
test('a place straddling a boundary names every authority it sits in', async ({ page }) => {
// A quarter of outcodes and a third of towns cross an authority boundary —
// SW19 is mostly Merton but partly Wandsworth. Naming only the largest
// asserts something false about the place.
const { places } = await (await page.request.get('/api/places')).json();
const outcode = places.find((p: { kind: string }) => p.kind === 'outcode');
expect(outcode).toBeTruthy();
// Find any place the registry reports as straddling.
let straddling: { kind: string; slug: string } | null = null;
for (const p of places.filter((p: { kind: string }) => p.kind === 'outcode').slice(0, 40)) {
const d = await (await page.request.get(`/api/places/outcode/${p.slug}`)).json();
if ((d.place.authorities ?? []).length > 1) { straddling = p; break; }
}
test.skip(!straddling, 'no straddling outcode found in the sample');
const detail = await (await page.request.get(
`/api/places/outcode/${straddling!.slug}`)).json();
await page.goto(`/schools/near/${straddling!.slug}`);
for (const a of detail.place.authorities) {
if (a.slug) {
await expect(page.locator(`a[href="/schools/authority/${a.slug}"]`).first())
.toBeVisible();
} else {
// No page of its own — City of London and the Isles of Scilly are
// under the threshold. Named, deliberately not linked.
await expect(page.locator('header p')).toContainText(a.name);
await expect(page.getByRole('link', { name: a.name })).toHaveCount(0);
}
}
});
test('a place page lists its schools alphabetically', async ({ page }) => {
// Someone on a place page is usually looking for a school they can name,
// so the order should serve scanning for it. /rankings is where the
// league-table ordering lives.
const { places } = await (await page.request.get('/api/places')).json();
const town = places.find((p: { kind: string; count: number }) =>
p.kind === 'town' && p.count >= 5);
expect(town).toBeTruthy();
await page.goto(`/schools/${town.slug}`);
/*
* Per table, not per page.
*
* An unphased place page renders one table per phase, and an all-through
* school legitimately appears in both — so the page's school links are not
* one alphabetical run and never were. This assertion used to collect them
* all together and only passed because no town it picked happened to hold an
* all-through school; when the data gave Abbots Langley one, Breakspeare
* School showed up in the primary table and again in the secondary, and the
* test failed on correct behaviour.
*/
const tables = page.locator('table');
const tableCount = await tables.count();
expect(tableCount).toBeGreaterThan(0);
let checked = 0;
for (let i = 0; i < tableCount; i++) {
const names = await tables.nth(i).locator('a[href^="/school/"]').allTextContents();
if (names.length < 2) continue; // a one-row table says nothing about order
const sorted = [...names].sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase()));
expect(names, `table ${i + 1} is not alphabetical`).toEqual(sorted);
checked++;
}
expect(checked, 'no table had enough rows to check the ordering').toBeGreaterThan(0);
});
test('the rankings page still orders by score, not name', async ({ page }) => {
// Alphabetical is a place-page decision, not a site-wide one.
const res = await page.request.get('/api/rankings?metric=rwm_expected_pct&phase=primary');
expect(res.ok()).toBeTruthy();
const scores = ((await res.json()).rankings ?? [])
.map((r: { rwm_expected_pct: number | null }) => r.rwm_expected_pct)
.filter((v: number | null) => v != null);
expect(scores).toEqual([...scores].sort((a: number, b: number) => b - a));
});
/*
* Analytics on the location layer.
*
* Umami counts a pageview for every one of these URLs already. What it cannot
* say is which *kind* of location page earns engagement, because all four
* families share the /schools/ prefix — and that is the question that decides
* whether to keep investing in them.
*/
/** Capture Umami events, with the real script blocked so it cannot clobber
* the stub. Must be called before the first navigation. */
async function captureEvents(page: Page) {
const events: Array<{ name: string; data: Record<string, unknown> }> = [];
await page.route('**/analytics.schoolcompare.co.uk/**', (route) => route.abort());
await page.exposeFunction('__capture',
(name: string, data: Record<string, unknown>) => { events.push({ name, data }); });
await page.addInitScript(() => {
(window as unknown as { umami: unknown }).umami = {
track: (name: string, data: unknown) =>
(window as unknown as { __capture: (n: string, d: unknown) => void })
.__capture(name, data),
};
});
return events;
}
test('a location page reports which kind of place it is', async ({ page }) => {
const events = await captureEvents(page);
const place = await firstPlaceOfKind(page, 'authority');
await page.goto(`/schools/authority/${place.slug}`);
await expect.poll(() => events.find((e) => e.name === 'place_viewed'),
{ timeout: 10_000 }).toBeTruthy();
const event = events.find((e) => e.name === 'place_viewed')!;
expect(event.data.kind).toBe('authority');
expect(event.data.slug).toBe(place.slug);
expect(event.data.phase).toBe('all');
});
test('a school reached from a location page is attributed to it, not to direct', async ({ page }) => {
/*
* The defect this was written for. getNavigationSource had no case for
* /schools/, so every school view that came through the location layer was
* filed as 'direct' — the bucket you read as "typed the URL". The one
* measurement that says whether ~3,900 SEO pages work was reporting the
* wrong answer, confidently.
*/
const events = await captureEvents(page);
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
await page.locator('a[href^="/school/"]').first().click();
await page.waitForURL(/\/school\//);
await expect.poll(() => events.find((e) => e.name === 'school_viewed'),
{ timeout: 10_000 }).toBeTruthy();
expect(events.find((e) => e.name === 'school_viewed')!.data.from).toBe('place');
});
/*
* School autosuggest (spec 2026-08-26).
*/
async function autosuggestIsOn(page: Page): Promise<boolean> {
await page.goto('/');
return (await page.getByRole('combobox').count()) > 0;
}
test('the suggest endpoint answers from Typesense', async ({ page }) => {
// Not flagged — the endpoint is live even while the UI is dark, so it can
// be smoke-tested before the feature is switched on.
const res = await page.request.get('/api/suggest?q=brecknock');
expect(res.ok()).toBeTruthy();
const { suggestions } = await res.json();
expect(Array.isArray(suggestions)).toBeTruthy();
if (suggestions.length) {
// Local authority is what tells two "St Mary's" apart.
expect(suggestions[0]).toHaveProperty('school_name');
expect(suggestions[0]).toHaveProperty('local_authority');
}
});
test('a one-character query is answered, not rejected', async ({ page }) => {
// The keystroke path never errors on ordinary input.
const res = await page.request.get('/api/suggest?q=b');
expect(res.status()).toBe(200);
expect((await res.json()).suggestions).toEqual([]);
});
test('the suggest response is cacheable', async ({ page }) => {
const res = await page.request.get('/api/suggest?q=brecknock');
expect(res.headers()['cache-control'] ?? '').toContain('s-maxage');
});
test('typing a school name suggests it, and choosing it opens that school', async ({ page }) => {
test.skip(!(await autosuggestIsOn(page)),
'the school_autosuggest flag is off in this environment');
// A school certain to exist in any environment with data.
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
test.skip(!schools?.length, 'no schools in this environment');
const name = schools[0].school_name as string;
await page.goto('/');
await page.getByRole('combobox').first().fill(name.slice(0, 12));
const option = page.getByRole('option').first();
await expect(option).toBeVisible();
await option.click();
await expect(page).toHaveURL(/\/school\/\d+/);
});
test('the whole dropdown is reachable, not clipped by the hero', async ({ page }) => {
/*
* The hero panel had overflow: hidden to clip its artwork to the rounded
* corners, and it clipped the dropdown too — 320px of list against 145px of
* panel below the input, so roughly half was cut off with nothing to say so.
*
* toBeVisible() does not catch this: it checks the box is non-empty and not
* visibility:hidden, and an ancestor's overflow clips neither. The invariant
* that does catch it is that the LAST option is the thing actually painted
* at its own coordinates — which fails for clipping and for occlusion alike.
*/
test.skip(!(await autosuggestIsOn(page)),
'the school_autosuggest flag is off in this environment');
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
test.skip(!schools?.length, 'no schools in this environment');
await page.goto('/');
await page.getByRole('combobox').first().fill(
(schools[0].school_name as string).slice(0, 6));
const options = page.getByRole('option');
await expect(options.first()).toBeVisible();
const count = await options.count();
const painted = await options.nth(count - 1).evaluate((el) => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return { inside: el.contains(hit) || el === hit, bottom: Math.round(r.bottom) };
});
expect(painted.inside,
`the last option is not painted at its own coordinates (bottom ${painted.bottom}) `
+ '— an ancestor is clipping or covering the dropdown').toBeTruthy();
});
test('the dropdown does not survive into the results it produced', async ({ page }) => {
/*
* The bug that took the staging gate down, and it was not a test problem:
* after a search the results-page bar still holds the term, so the dropdown
* reopened on top of the results and swallowed the click on the first one.
* Playwright reported it as "<li role=option> intercepts pointer events"; a
* reader would simply have found their first result unclickable.
*/
test.skip(!(await autosuggestIsOn(page)),
'the school_autosuggest flag is off in this environment');
await page.goto('/');
await page.getByRole('combobox').first().fill('school');
await expect(page.getByRole('option').first()).toBeVisible();
await page.getByRole('button', { name: /Search/i }).first().click();
await page.waitForURL(/search=school/);
await expect(page.getByRole('listbox')).toHaveCount(0);
// And the results underneath are actually reachable, which is the point.
await page.locator('a[href^="/school/"]').first().click({ timeout: 15_000 });
await expect(page).toHaveURL(/\/school\//);
});
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
test.skip(await autosuggestIsOn(page),
'the school_autosuggest flag is on in this environment');
await page.goto('/');
await expect(page.getByRole('combobox')).toHaveCount(0);
// And the box still works: the existing search must be untouched.
await page.getByPlaceholder(/School name or postcode/i).first().fill('abbey');
await page.getByRole('button', { name: /Search/i }).first().click();
await expect(page).toHaveURL(/search=abbey/);
});
// ── Destination measures ───────────────────────────────────────────────────
//
// Two failure modes have to be told apart here, and conflating them is how
// this suite would either hide a regression or block the promotion pipeline:
//
// * the backend does not serve the `destinations` field at all — a code
// regression, or a deploy that did not land. FAILS.
// * the field is served but every school is empty — the annual EES DAG has
// not run on this environment yet. SKIPS, loudly.
//
// The second is a data-load precondition, not a defect, and it is true for
// every commit between this merging and the DAG being triggered. Failing on it
// would redden the staging gate for unrelated work. This is not the quiet skip
// 4f01fbd removed from the distance journeys: that one hid a broken feature
// behind a flag check, whereas the assertion that the code is deployed and
// correctly shaped still runs here on every commit.
async function secondaryWithDestinations(page: Page): Promise<{
urn: string; destinations: any;
}> {
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).toBeGreaterThan(0);
let served = false;
for (const urn of urns.slice(0, 25)) {
const detail = await page.request.get(`/api/schools/${urn}`);
if (!detail.ok()) continue;
const data = await detail.json();
// The key must exist, even as null. Its absence means the backend in front
// of us does not know about destinations at all.
if ('destinations' in data) served = true;
if (data.destinations?.ks4) return { urn, destinations: data.destinations };
}
expect(served,
'GET /api/schools/{urn} served no `destinations` key at all — the backend '
+ 'is missing this feature, not merely missing its data').toBeTruthy();
test.skip(true,
'No school has destination data yet: the annual EES DAG has not run on '
+ 'this environment. The API shape is correct, so this is a data-load '
+ 'precondition rather than a regression.');
throw new Error('unreachable');
}
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
const { urn } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
await expect(section.getByRole('heading', { name: 'After Year 11' })).toBeVisible();
// The section must date its own cohort: destinations run about two GCSE
// years behind the results above them, and an undated figure reads as stale.
await expect(section).toContainText(/20\d{2}\/\d{2}/);
});
test('the destinations bar is absent entirely whenever a figure is withheld', async ({ page }) => {
const { urn, destinations } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
const allGroup = destinations.ks4.groups.all;
const suppressed = (allGroup?.categories ?? [])
.filter((c: { status: string }) => c.status === 'suppressed');
if (suppressed.length > 0) {
// R1: a bar drawn from the published segments leaves a gap whose width is
// the withheld figure, readable straight off the axis.
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
await expect(section.getByText(/withheld/i).first()).toBeVisible();
} else {
const published = (allGroup?.categories ?? [])
.filter((c: { status: string }) => c.status === 'published');
await expect(section.locator('[data-destination-segment]'))
.toHaveCount(published.length);
}
});
test('switching to disadvantaged pupils never reveals a withheld figure', async ({ page }) => {
const { urn, destinations } = await secondaryWithDestinations(page);
const disadvantaged = destinations.ks4.groups.disadvantaged;
test.skip(!disadvantaged, 'this school publishes no disadvantaged breakdown');
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
const radio = section.getByRole('radio', { name: /disadvantaged/i });
await expect(radio).toBeVisible();
await radio.click();
const suppressed = (disadvantaged.categories ?? [])
.filter((c: { status: string }) => c.status === 'suppressed');
if (suppressed.length > 0) {
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
// The residual must appear nowhere on the page — it is the withheld figure.
const cohort: number = disadvantaged.cohort;
const publishedTotal = (disadvantaged.categories ?? [])
.filter((c: { status: string }) => c.status === 'published')
.reduce((sum: number, c: { pupils: number }) => sum + c.pupils, 0);
const residual = cohort - publishedTotal;
const text = (await section.textContent()) ?? '';
expect(text).not.toMatch(new RegExp(`\\b${residual}\\b`));
}
});
test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&per_page=100');
const body = await res.json();
const noSixthForm = (body.schools ?? [])
.filter((s: { phase?: string; has_sixth_form?: boolean }) =>
s.phase === 'Secondary' && s.has_sixth_form === false)
.map((s: { urn: number }) => String(s.urn));
test.skip(noSixthForm.length === 0, 'no sixth-form-less secondary in this dataset');
await page.goto(`/school/${noSixthForm[0]}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Absence is the correct statement, so there must be no placeholder either.
await expect(page.locator('#post16-destinations')).toHaveCount(0);
await expect(page.getByText(/destination data coming soon/i)).toHaveCount(0);
});
test('the destinations section never claims a pupil stayed at this school', async ({ page }) => {
const { urn } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
// The published file records the TYPE of place a leaver went to, never which
// one, so the page can never say a pupil stayed on here.
const text = (await section.textContent()) ?? '';
expect(text).not.toMatch(/stayed on (here|at this school)/i);
});
2026-09-02 16:22:52 +01:00
/**
* The About page and the blog exist to give the site a named human author.
* These journeys assert the load-bearing parts of that — a name, a face, the
* honesty claim, and a resolvable Person entity — rather than exact copy,
* which will be edited.
*
* Both are behind flags (about_page, blog), so each has a lit journey and a
* dark one. Flag state is read from the observable effect rather than from
* /api/flags, which the public proxy denies on purpose — the same approach
* distanceFeatureIsOn() takes above.
2026-09-02 16:22:52 +01:00
*/
async function aboutPageIsOn(page: Page): Promise<boolean> {
return (await page.request.get('/about')).ok();
}
async function blogIsOn(page: Page): Promise<boolean> {
return (await page.request.get('/blog')).ok();
}
test('with the about page off, it is absent rather than empty', async ({ page }) => {
test.skip(await aboutPageIsOn(page), 'the about_page flag is on in this environment');
// Dark means the URL does not exist, not that it renders empty: a 404 is
// what stops a crawler keeping the page in its index.
expect((await page.request.get('/about')).status()).toBe(404);
// A footer link into a 404 is the failure this flag has to avoid.
await page.goto('/');
await expect(page.locator('footer a[href="/about"]')).toHaveCount(0);
// And a sitemap must never advertise a URL that 404s.
const sitemap = await page.request.get('/content-sitemap.xml');
expect(await sitemap.text()).not.toContain('/about');
});
test('with the blog off, it is absent rather than empty', async ({ page }) => {
test.skip(await blogIsOn(page), 'the blog flag is on in this environment');
expect((await page.request.get('/blog')).status()).toBe(404);
expect((await page.request.get('/blog/rss.xml')).status()).toBe(404);
await page.goto('/');
await expect(page.locator('footer a[href="/blog"]')).toHaveCount(0);
const sitemap = await page.request.get('/content-sitemap.xml');
expect(await sitemap.text()).not.toContain('/blog');
// The admin panel is deliberately NOT flagged: posts have to be writable
// before the blog is readable, or there is nothing to turn on.
expect((await page.request.get('/admin')).status()).not.toBe(404);
});
2026-09-02 16:22:52 +01:00
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
test.skip(!(await aboutPageIsOn(page)), 'the about_page flag is off in this environment');
2026-09-02 16:22:52 +01:00
await page.goto('/');
const aboutLink = page.locator('footer a[href="/about"]');
await expect(aboutLink).toBeVisible();
await aboutLink.click();
await page.waitForURL(/\/about$/);
await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor');
await expect(page.locator('img[alt*="Tudor"]')).toBeVisible();
// The credibility claim is lived experience plus stated provenance, not
// expertise. If this sentence ever disappears the positioning has drifted.
await expect(page.getByText(/not an education expert/i)).toBeVisible();
const jsonLd = await page
.locator('script[type="application/ld+json"]')
.first()
.textContent();
expect(jsonLd).toContain('"Person"');
// First name only — a surname here would be the one place it leaks.
expect(jsonLd).not.toMatch(/familyName/);
});
test('the blog lists posts and each one renders with a byline', async ({ page }) => {
test.skip(!(await blogIsOn(page)), 'the blog flag is off in this environment');
await page.goto('/blog');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const postLinks = page.locator('a[href^="/blog/"]');
// Data invariant: staging must carry at least one published post. If this
// fails, the environment has no content rather than the code being broken.
expect(await postLinks.count()).toBeGreaterThan(0);
await postLinks.first().click();
await page.waitForURL(/\/blog\/.+/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/^By Tudor/)).toBeVisible();
const jsonLd = await page
.locator('script[type="application/ld+json"]')
.first()
.textContent();
expect(jsonLd).toContain('"BlogPosting"');
});
test('the admin panel is not indexable', async ({ page }) => {
const response = await page.request.get('/admin');
expect(response.headers()['x-robots-tag']).toContain('noindex');
});
test('the content sitemap lists the about page and is advertised in robots', async ({ page }) => {
const sitemap = await page.request.get('/content-sitemap.xml');
// Served whatever the flags say: robots.txt names it unconditionally, and
// with both dark it is a valid empty urlset rather than a 404.
expect(sitemap.ok()).toBeTruthy();
if (await aboutPageIsOn(page)) {
expect(await sitemap.text()).toContain('/about');
}
// The school corpus sitemap is proxied from FastAPI; this one is Next's.
// robots.txt must advertise both or the blog never gets discovered.
const robots = await page.request.get('/robots.txt');
const body = await robots.text();
expect(body).toContain('/sitemap.xml');
expect(body).toContain('/content-sitemap.xml');
});
/**
* Other schools nearby.
*
* The section is absent by design where fewer than two schools qualify, and the
* arrows are absent where three cards fit, so this asserts each part of the
* contract only where it applies.
*
* Two things here cannot be tested anywhere else: the arrows' disabled state,
* which jsdom cannot measure because it has no layout, and the scroll position
* surviving a selection, which is DOM state rather than React state.
*/
test('nearby schools link on to other schools and into compare', async ({ page }) => {
await searchByName(page, 'Primary');
await schoolLinks(page).first().click();
await page.waitForURL(/\/school\//);
const section = page.locator('#nearby');
if ((await section.count()) === 0) {
test.skip(true, 'No qualifying similar schools for this school');
}
// Every card is a real link to another school page — including the ones
// behind the arrows, which is the whole reason this is a scroller and not a
// paginated widget.
const links = section.locator('a[href^="/school/"]');
const linkCount = await links.count();
expect(linkCount).toBeGreaterThanOrEqual(2);
expect(linkCount).toBeLessThanOrEqual(6);
expect(await links.first().getAttribute('href')).toMatch(/^\/school\/\d{6}-/);
await expect(section.getByText(/miles away/).first()).toBeVisible();
const scroller = section.locator('ul').first();
// The carousel, where this school had more than three matches.
const forward = section.getByRole('button', { name: 'More schools' });
if (await forward.count()) {
const back = section.getByRole('button', { name: 'Previous schools' });
await expect(back).toBeDisabled();
await forward.click();
expect(await settledScrollLeft(scroller)).toBeGreaterThan(8);
await expect(back).toBeEnabled();
}
// The compare hand-off, and the row must not jump back to the start when the
// footer re-renders underneath it.
//
// Click the LAST card's button, not the first. Playwright scrolls a target
// into view before clicking it, so clicking card one while the row is paged
// to the end scrolls the container back to the start — and the assertion
// below then measures Playwright's own scrolling rather than the app's.
// That is what this test did on its first staging run: 537 → 2, reproduced
// afterwards on a static page with no React on it at all.
//
// A few pixels of snap or sub-pixel adjustment are fine; a reset to the
// start is not, which is the whole point of the check.
const offsetBefore = await settledScrollLeft(scroller);
await section.getByRole('button', { name: /Add to compare/ }).last().click();
await expect(
section.getByRole('button', { name: /Added to compare/ }).first(),
).toBeVisible();
const offsetAfter = await settledScrollLeft(scroller);
expect(Math.abs(offsetAfter - offsetBefore)).toBeLessThanOrEqual(8);
});
/**
* The nearby-schools section at MOBILE.md's three reference widths.
*
* MOBILE.md asks for exactly this check and records that it was not written
* because "Playwright isn't currently in the project dependency set". That is
* no longer true — this suite is Playwright — so the check exists now, scoped
* to the page this feature touches.
*/
for (const width of [360, 390, 430]) {
test(`nearby schools survives a ${width}px viewport`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await searchByName(page, 'Primary');
await schoolLinks(page).first().click();
await page.waitForURL(/\/school\//);
const section = page.locator('#nearby');
if ((await section.count()) === 0) {
test.skip(true, 'No qualifying similar schools for this school');
}
// 1. Nothing bleeds past the right edge.
expect(
await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth),
).toBe(0);
// 2. No arrows on touch widths — swiping does the job, and they would take
// 96px from a 328px card.
await expect(section.getByRole('button', { name: 'More schools' })).toHaveCount(0);
// 3. Every tap target in the section clears 44px. A card title's own box is
// shorter, but its hit area is the whole card via ::after.
const failing = await section.evaluate((root: HTMLElement) =>
Array.from(root.querySelectorAll('a, button'))
.filter((el) => (el as HTMLElement).offsetParent)
.map((el) => {
const card = el.closest('li');
const box = el.matches('h3 a') && card
? card.getBoundingClientRect()
: el.getBoundingClientRect();
return {
text: (el as HTMLElement).innerText.trim().slice(0, 24),
w: box.width,
h: box.height,
};
})
.filter((o) => o.w < 44 || o.h < 44),
);
expect(failing).toEqual([]);
});
}