PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
leaflet.css colours every link in the map `.leaflet-container a` (#0078A8, specificity 0,1,1), which beat .btn-tertiary (0,1,0), so the card's View button came out in Leaflet's link blue in both themes. Pull it back onto --text-secondary next to the other Leaflet overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3148 lines
142 KiB
TypeScript
3148 lines
142 KiB
TypeScript
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_PER_GROUP in lib/compareLogic.ts is 5 per phase. The hero once
|
||
// promised three while a card lower on the same page promised five.
|
||
expect(body).toMatch(/five primary and five secondary 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 });
|
||
// A new postcode search starts at half a mile.
|
||
await expect(page).toHaveURL(/[?&]radius=0\.5(&|$)/);
|
||
await expect(page.getByRole('combobox', { name: 'Distance' })).toHaveValue('0.5');
|
||
});
|
||
|
||
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
|
||
// The phase options once came from the result set, which the phase filter
|
||
// had already narrowed — so with secondary chosen, primary was not offered.
|
||
await page.goto('/?search=school&phase=secondary');
|
||
const phase = page.getByRole('combobox', { name: 'Phase' });
|
||
await expect(phase).toHaveValue('secondary', { timeout: 15_000 });
|
||
await phase.selectOption('primary');
|
||
await expect(page).toHaveURL(/[?&]phase=primary(&|$)/);
|
||
await expect(phase).toHaveValue('primary');
|
||
});
|
||
|
||
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
|
||
// The search page offers every GIAS phase, but the API only knew the grouped
|
||
// ones and silently dropped the rest — so "Nursery" returned primaries.
|
||
for (const phase of ['Nursery', '16 plus']) {
|
||
const res = await page.request.get(
|
||
`/api/schools?phase=${encodeURIComponent(phase.toLowerCase())}&page_size=50`);
|
||
expect(res.ok()).toBeTruthy();
|
||
const phases = new Set(
|
||
((await res.json()).schools ?? []).map((s: { phase?: string }) => s.phase));
|
||
expect([...phases], `phase=${phase} returned other phases`)
|
||
.toEqual(phases.size ? [phase] : []);
|
||
}
|
||
});
|
||
|
||
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;
|
||
});
|
||
|
||
// A postcode search opens on the map, phones included; open it fullscreen.
|
||
await searchByName(page, 'B1 1BB');
|
||
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();
|
||
});
|
||
|
||
/*
|
||
* The results toolbar: search, filters and the List/Map switch pinned under
|
||
* the header, so none of them scroll away with the results.
|
||
*
|
||
* Positions are read after window.scrollTo, never after a click: Playwright
|
||
* scrolls a click target into view first, which would make "still on screen
|
||
* after scrolling" true of anything.
|
||
*/
|
||
test('the results toolbar stays pinned with its List/Map switch', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await searchByName(page, 'B1 1BB');
|
||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||
|
||
const view = page.getByRole('group', { name: 'Results view' });
|
||
await expect(view).toBeVisible();
|
||
// Desktop opens on the map; the list is the long page that scrolls.
|
||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'List', exact: true }))
|
||
.toHaveAttribute('aria-pressed', 'true');
|
||
|
||
await page.evaluate(() => window.scrollTo(0, 1500));
|
||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
||
const box = await view.boundingBox();
|
||
expect(box, 'the switch left the viewport').not.toBeNull();
|
||
// Just under the 64px header, not scrolled off the top.
|
||
expect(box!.y).toBeGreaterThanOrEqual(64);
|
||
expect(box!.y).toBeLessThan(160);
|
||
await expect(page.getByRole('combobox', { name: 'Distance' })).toBeInViewport();
|
||
|
||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'Map', exact: true }))
|
||
.toHaveAttribute('aria-pressed', 'true');
|
||
});
|
||
|
||
/*
|
||
* The toolbar's line count is set by the screen width, never by the results.
|
||
* It once wrapped wherever it ran out of room, and the List/Map switch only
|
||
* appears when there are results — so the same search took two lines with
|
||
* results and one without.
|
||
*/
|
||
test('the results toolbar keeps its line count whether or not there are results', async ({ page }) => {
|
||
const withResults = '/?postcode=B1%201BB&radius=1';
|
||
// No school type matches this, so the same search returns nothing.
|
||
const without = `${withResults}&school_type=no-such-type`;
|
||
|
||
const lines = async (url: string) => {
|
||
await page.goto(url);
|
||
// By label: the input is a combobox when autosuggest is on.
|
||
const input = page.getByLabel('School name or postcode', { exact: true });
|
||
const filters = page.getByRole('group', { name: 'Filters' });
|
||
await expect(filters).toBeVisible({ timeout: 15_000 });
|
||
const a = (await input.boundingBox())!;
|
||
const b = (await filters.boundingBox())!;
|
||
return b.y >= a.y + a.height ? 2 : 1;
|
||
};
|
||
const view = page.getByRole('group', { name: 'Results view' });
|
||
|
||
for (const [width, expected] of [[1400, 1], [1100, 2]] as const) {
|
||
await page.setViewportSize({ width, height: 800 });
|
||
expect(await lines(withResults), `${width}px with results`).toBe(expected);
|
||
await expect(view).toBeVisible();
|
||
expect(await lines(without), `${width}px without results`).toBe(expected);
|
||
await expect(view).toHaveCount(0);
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Desktop opens a postcode search on the map (mockup B): the list in a pane on
|
||
* the left, the map filling the rest of the screen, and a card on the map for
|
||
* the school picked in either.
|
||
*/
|
||
test('a desktop postcode search opens on the map with the list beside it', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await searchByName(page, 'B1 1BB');
|
||
|
||
await expect(page.getByRole('button', { name: 'Map', exact: true }))
|
||
.toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 });
|
||
const pane = page.locator('[class*="mapListPane"]');
|
||
const card = pane.locator('[data-urn]').first();
|
||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||
// The list view's own row, not a cut-down card: it carries the same View
|
||
// link and Compare button.
|
||
await expect(card.getByRole('link', { name: 'View', exact: true })).toBeVisible();
|
||
await expect(card.getByRole('button', { name: /Compar/ })).toBeVisible();
|
||
await expect(page.locator('.sc-pin').first()).toBeVisible({ timeout: 15_000 });
|
||
|
||
// The count and the sort share one line at the top of the list.
|
||
const heading = await pane.getByRole('heading', { level: 2 }).boundingBox();
|
||
const sort = await pane.getByRole('combobox').boundingBox();
|
||
expect(Math.abs((heading!.y + heading!.height / 2) - (sort!.y + sort!.height / 2)))
|
||
.toBeLessThan(8);
|
||
|
||
// The split runs to the bottom of the screen rather than stopping short.
|
||
const split = await page.locator('[class*="mapViewContainer"]').boundingBox();
|
||
expect(split!.y + split!.height).toBeGreaterThan(800 - 40);
|
||
expect(split!.y + split!.height).toBeLessThanOrEqual(800);
|
||
|
||
// A card picks its pin and opens its card on the map. Clicked in its padding,
|
||
// clear of the name link and the Compare button.
|
||
const name = (await card.locator('a').first().innerText()).trim();
|
||
await card.click({ position: { x: 6, y: 6 } });
|
||
await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
|
||
await expect(page.locator('.sc-popup')).toContainText(name);
|
||
// The card's View link is drawn like the list's, not in Leaflet's link blue.
|
||
// Polled, because .btn transitions its colour.
|
||
const colour = (l: Locator) => l.evaluate(el => getComputedStyle(el).color);
|
||
const listViewColour = await colour(card.getByRole('link', { name: 'View', exact: true }));
|
||
await expect.poll(() => colour(page.locator('.sc-popup').getByRole('link', { name: 'View', exact: true })))
|
||
.toBe(listViewColour);
|
||
|
||
// And from the keyboard: each row has a "Show … on the map" button that
|
||
// appears on focus.
|
||
const second = pane.locator('[data-urn]').nth(1);
|
||
const secondName = (await second.locator('a').first().innerText()).trim();
|
||
const show = second.getByRole('button', { name: `Show ${secondName} on the map` });
|
||
await show.focus();
|
||
await expect(show).toBeVisible();
|
||
await page.keyboard.press('Enter');
|
||
await expect(show).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.sc-popup')).toContainText(secondName);
|
||
});
|
||
|
||
// 402 is the iPhone 17, where the toolbar overflowed (see below).
|
||
for (const width of [360, 390, 402, 430]) {
|
||
test(`a phone opens on the map, with the list a tap away, at ${width}px`, async ({ page }) => {
|
||
await page.setViewportSize({ width, height: 800 });
|
||
await searchByName(page, 'B1 1BB');
|
||
|
||
// On the map, with the floating button offering the list, clear of the
|
||
// bottom tab bar.
|
||
const toList = page.getByRole('button', { name: 'Show list' });
|
||
await expect(toList).toBeInViewport({ timeout: 15_000 });
|
||
await expect(page.locator('.sc-pin').first()).toBeAttached({ timeout: 15_000 });
|
||
const barTop = await page.locator('nav[class*="bottomBar"]')
|
||
.evaluate((el) => el.getBoundingClientRect().top);
|
||
const fabBox = (await toList.boundingBox())!;
|
||
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(barTop);
|
||
|
||
// A pin opens the bottom sheet, stacked under the button, above the bar.
|
||
// dispatchEvent, not click: a pin may sit under the button or the toolbar,
|
||
// and Leaflet listens on the pin itself.
|
||
await page.locator('.sc-pin').first().dispatchEvent('click');
|
||
const sheet = page.locator('[class*="bottomSheet"]');
|
||
await expect(sheet).toBeVisible();
|
||
// The sheet slides up over 0.3s; measure where it comes to rest, not a
|
||
// frame of the animation (which reads as below the tab bar).
|
||
await expect.poll(async () => {
|
||
const box = (await sheet.boundingBox())!;
|
||
return Math.round(box.y + box.height);
|
||
}).toBeLessThanOrEqual(barTop);
|
||
// The dock is anchored at the bottom, so the sheet pushes the button up:
|
||
// measure the button again now that the sheet is in.
|
||
const fabNow = (await toList.boundingBox())!;
|
||
expect((await sheet.boundingBox())!.y).toBeGreaterThanOrEqual(fabNow.y + fabNow.height);
|
||
|
||
// MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
|
||
.toBe(0);
|
||
// scrollWidth alone cannot see this page's overflow: .main clips on x, so
|
||
// the search summary ran 40px off an iPhone 17 screen with scrollWidth
|
||
// still equal to the viewport. Measure the toolbar's own edges instead;
|
||
// the controls row scrolls by design, so only its box is held to the edge.
|
||
const offscreen = await page.evaluate(() => {
|
||
const toolbar = document.querySelector('[class*="resultsToolbar"]');
|
||
return [...(toolbar?.querySelectorAll('*') ?? [])]
|
||
.filter((el) => (el as HTMLElement).offsetParent
|
||
&& !el.parentElement?.closest('[class*="controlsRow"]'))
|
||
.map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40),
|
||
right: Math.round(el.getBoundingClientRect().right) }))
|
||
.filter((o) => o.right > window.innerWidth);
|
||
});
|
||
expect(offscreen).toEqual([]);
|
||
const small = await page.evaluate(() => {
|
||
const toolbar = document.querySelector('[class*="resultsToolbar"]');
|
||
const fabEl = document.querySelector('[class*="viewFab"]');
|
||
const closeEl = document.querySelector('[class*="closeSheetBtn"]');
|
||
return [...(toolbar?.querySelectorAll('a, button, input, select') ?? []), fabEl, closeEl]
|
||
.filter((el): el is HTMLElement => !!el && !!(el as HTMLElement).offsetParent)
|
||
.map((el) => ({ t: el.innerText?.trim().slice(0, 24) || el.getAttribute('aria-label'),
|
||
w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height }))
|
||
.filter((o) => o.w < 44 || o.h < 44);
|
||
});
|
||
expect(small).toEqual([]);
|
||
|
||
// The list: the search folds to a summary, and the pinned toolbar and
|
||
// the button survive a scroll.
|
||
await toList.click();
|
||
const toMap = page.getByRole('button', { name: 'Show map' });
|
||
await expect(toMap).toBeInViewport();
|
||
await expect(schoolLinks(page).first()).toBeVisible();
|
||
const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
|
||
await page.evaluate(() => window.scrollTo(0, 1200));
|
||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
|
||
await expect(summary).toBeInViewport();
|
||
await expect(toMap).toBeInViewport();
|
||
});
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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);
|
||
});
|
||
|
||
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');
|
||
|
||
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);
|
||
});
|
||
|
||
/**
|
||
* Every section in the mobile jump sheet can actually be reached.
|
||
*
|
||
* The sheet is a fixed bottom sheet, and the app has a fixed bottom tab bar.
|
||
* `position: sticky` with a z-index on the sticky nav makes it a stacking
|
||
* context, so the sheet's own z-index orders it only within that context —
|
||
* against the tab bar, the nav's value is what counts. The last item in the
|
||
* sheet was therefore painted over and untappable as soon as the list grew
|
||
* long enough to reach the bar, which adding "Nearby schools" is what did.
|
||
*
|
||
* Bounding boxes are not enough to catch this: the item is in the viewport and
|
||
* the right size, it is simply underneath something. So this asks the question
|
||
* a thumb asks — what is on top at this point.
|
||
*/
|
||
test('every section in the mobile jump sheet is tappable, not under the tab bar', async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await searchByName(page, 'Primary');
|
||
await schoolLinks(page).first().click();
|
||
await page.waitForURL(/\/school\//);
|
||
|
||
// Scroll down so the sticky nav is docked and the sheet has somewhere to open.
|
||
await page.evaluate(() => window.scrollTo({ top: 1200 }));
|
||
// Two controls carry aria-haspopup: the mobile "Section" button and the
|
||
// desktop "All" one, which is display:none here but still in the DOM.
|
||
await page.locator('[aria-haspopup="menu"]:visible').click();
|
||
|
||
const sheet = page.locator('[role="menu"]');
|
||
await expect(sheet).toBeVisible();
|
||
|
||
const covered = await sheet.evaluate((panel: HTMLElement) =>
|
||
Array.from(panel.querySelectorAll('[role="menuitem"]'))
|
||
.map((el) => {
|
||
const box = el.getBoundingClientRect();
|
||
const hit = document.elementFromPoint(
|
||
Math.round(box.left + box.width / 2),
|
||
Math.round(box.top + box.height / 2),
|
||
);
|
||
return { label: (el as HTMLElement).innerText.trim().replace(/\s+/g, ' '), reachable: !!(hit && hit.closest('[role="menuitem"]')) };
|
||
})
|
||
.filter((item) => !item.reachable)
|
||
.map((item) => item.label),
|
||
);
|
||
|
||
expect(covered).toEqual([]);
|
||
});
|
||
|
||
/**
|
||
* 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([]);
|
||
});
|
||
}
|