2026-07-03 06:50:40 +01:00
|
|
|
|
import { test, expect, Page } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Journey tests for SchoolCompare, run against the staging environment as the
|
|
|
|
|
|
* gate before promotion to production. They assert stable data invariants
|
|
|
|
|
|
* (results exist, key UI renders) rather than exact numbers, so routine data
|
|
|
|
|
|
* refreshes don't break the pipeline.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
async function searchByName(page: Page, query: string) {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
|
|
|
|
|
await searchInput.fill(query);
|
|
|
|
|
|
await searchInput.press('Enter');
|
|
|
|
|
|
await page.waitForURL(/search=|postcode=/);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function schoolLinks(page: Page) {
|
|
|
|
|
|
return page.locator('a[href^="/school/"]');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 23:12:23 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 14:42:09 +01:00
|
|
|
|
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]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 06:50:40 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-06 17:01:45 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* The landing page's claims must match the product and the data.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Every failure this group guards against is silent: a hardcoded fallback that
|
|
|
|
|
|
* renders as if it were live, a number that contradicts a constant elsewhere in
|
|
|
|
|
|
* the codebase, illustrative copy that reads as fact. None of them throw, none
|
|
|
|
|
|
* fail a build, and none show up in a unit test.
|
|
|
|
|
|
*/
|
|
|
|
|
|
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
|
|
|
|
|
|
const info = await (await page.request.get('/api/data-info')).json();
|
|
|
|
|
|
test.skip(info.status !== 'loaded', 'environment has no data loaded');
|
|
|
|
|
|
const expected: number = info.unique_schools;
|
|
|
|
|
|
expect(expected).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
const body = await page.locator('body').innerText();
|
|
|
|
|
|
|
|
|
|
|
|
// The real count, formatted as the page formats it.
|
|
|
|
|
|
expect(body).toContain(expected.toLocaleString('en-GB'));
|
|
|
|
|
|
// The fallback that used to render in its place. `total_schools` is not a
|
|
|
|
|
|
// field this API returns, so reading it yielded undefined and the "24,000+"
|
|
|
|
|
|
// floor was shown on every request while the database held far more.
|
|
|
|
|
|
expect(body).not.toContain('24,000+');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the landing page states the real comparison limit', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
const body = await page.locator('body').innerText();
|
|
|
|
|
|
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
|
|
|
|
|
|
// promise three while a card lower on the same page promised five.
|
|
|
|
|
|
expect(body).toMatch(/five schools/i);
|
|
|
|
|
|
expect(body).not.toMatch(/three schools/i);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the landing page invents no data about real schools', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
const preview = page.locator('table, [class*="comparePreview"]').first();
|
|
|
|
|
|
const body = await page.locator('body').innerText();
|
|
|
|
|
|
// The compare preview's columns carried two real school names beside
|
|
|
|
|
|
// invented results and invented Ofsted grades.
|
|
|
|
|
|
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
|
|
|
|
|
|
await expect(preview).toContainText(/School A/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the home page has a real title', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
// It was the bare string "Home" — the browser tab, the bookmark and the
|
|
|
|
|
|
// search result all read that.
|
|
|
|
|
|
expect(await page.title()).not.toBe('Home');
|
|
|
|
|
|
expect(await page.title()).toMatch(/schoolcompare/i);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-14 22:59:12 +01:00
|
|
|
|
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([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
2026-08-14 21:35:10 +01:00
|
|
|
|
* 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.
|
2026-08-14 17:15:09 +01:00
|
|
|
|
*/
|
2026-08-14 21:35:10 +01:00
|
|
|
|
function heroImageState(page: Page) {
|
|
|
|
|
|
return page.evaluate(() => {
|
|
|
|
|
|
const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
|
|
|
|
|
|
if (!img) return null;
|
2026-08-14 17:15:09 +01:00
|
|
|
|
return {
|
2026-08-14 21:35:10 +01:00
|
|
|
|
// 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() ?? '',
|
2026-08-14 17:15:09 +01:00
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-08-14 21:35:10 +01:00
|
|
|
|
}
|
2026-08-14 17:15:09 +01:00
|
|
|
|
|
2026-08-14 21:35:10 +01:00
|
|
|
|
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
|
2026-08-14 17:15:09 +01:00
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
|
|
await page.goto('/');
|
2026-08-14 21:35:10 +01:00
|
|
|
|
const wide = await heroImageState(page);
|
|
|
|
|
|
expect(wide).not.toBeNull();
|
|
|
|
|
|
expect(wide!.loaded).toBe(true);
|
|
|
|
|
|
expect(wide!.file).toContain('hero-wide');
|
2026-08-14 17:15:09 +01:00
|
|
|
|
|
2026-08-14 21:35:10 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-08-14 17:15:09 +01:00
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
|
|
await page.goto('/');
|
2026-08-14 21:35:10 +01:00
|
|
|
|
const band = await heroImageState(page);
|
|
|
|
|
|
expect(band!.loaded).toBe(true);
|
|
|
|
|
|
expect(band!.file).toContain('hero-band');
|
2026-08-14 17:15:09 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-14 21:35:10 +01:00
|
|
|
|
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') => {
|
2026-08-14 17:15:09 +01:00
|
|
|
|
const ctx = await browser.newContext({ colorScheme });
|
|
|
|
|
|
const p = await ctx.newPage();
|
|
|
|
|
|
await p.goto('/');
|
2026-08-14 21:35:10 +01:00
|
|
|
|
const value = await p.evaluate(() => {
|
|
|
|
|
|
const img = document.querySelector('[class*="heroArt"] img');
|
|
|
|
|
|
return img ? getComputedStyle(img).filter : 'missing';
|
|
|
|
|
|
});
|
2026-08-14 17:15:09 +01:00
|
|
|
|
await ctx.close();
|
|
|
|
|
|
return value;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-14 21:35:10 +01:00
|
|
|
|
// 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/);
|
2026-08-14 17:15:09 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 06:50:40 +01:00
|
|
|
|
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 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-18 21:14:57 +01:00
|
|
|
|
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
|
2026-07-18 21:24:09 +01:00
|
|
|
|
const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`);
|
2026-07-18 21:14:57 +01:00
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
|
|
|
|
const barclay = ((await res.json()).schools ?? []).find(
|
|
|
|
|
|
(s: { urn: number }) => s.urn === RC_URN,
|
|
|
|
|
|
);
|
2026-07-18 21:24:09 +01:00
|
|
|
|
// 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();
|
2026-07-18 21:14:57 +01:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 06:50:40 +01:00
|
|
|
|
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();
|
2026-07-03 14:40:13 +01:00
|
|
|
|
// 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 });
|
2026-07-03 06:50:40 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 09:23:08 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 12:18:12 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-18 19:09:16 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-20 12:53:07 +01:00
|
|
|
|
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();
|
2026-07-20 20:39:16 +01:00
|
|
|
|
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);
|
2026-07-20 12:53:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-07 09:22:54 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-05 21:27:41 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-06 14:09:37 +01:00
|
|
|
|
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
|
|
|
|
|
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
|
|
|
|
|
// map's fullscreen button must fall back to a CSS overlay.
|
|
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
|
|
await page.addInitScript(() => {
|
|
|
|
|
|
// @ts-expect-error deliberate API removal
|
|
|
|
|
|
delete Element.prototype.requestFullscreen;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await searchByName(page, 'B1 1BB');
|
|
|
|
|
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Switch to the map view, then open the map fullscreen.
|
|
|
|
|
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
|
|
|
|
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
|
|
|
|
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
|
|
|
|
|
await openFs.click();
|
|
|
|
|
|
|
|
|
|
|
|
// The button flips to its exit state once the overlay is up.
|
|
|
|
|
|
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
|
|
|
|
|
await expect(exitFs).toBeVisible();
|
|
|
|
|
|
await exitFs.click();
|
|
|
|
|
|
await expect(openFs).toBeVisible();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 23:59:22 +01:00
|
|
|
|
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
2026-07-14 23:12:23 +01:00
|
|
|
|
// Two same-phase (pure primary) schools so both stay on one tab.
|
|
|
|
|
|
const [urn0, urn1] = await twoPrimaryUrns(page);
|
2026-07-03 06:50:40 +01:00
|
|
|
|
|
2026-07-14 23:12:23 +01:00
|
|
|
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
2026-07-03 06:50:40 +01:00
|
|
|
|
// Both schools' detail links should render in the comparison view
|
2026-07-14 23:12:23 +01:00
|
|
|
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
|
|
await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
|
2026-07-13 23:59:22 +01:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-07-16 21:05:16 +01:00
|
|
|
|
// 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 /);
|
2026-07-16 21:27:25 +01:00
|
|
|
|
// ...and its label rail carries the comparison caption.
|
|
|
|
|
|
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
|
2026-07-16 21:05:16 +01:00
|
|
|
|
|
2026-07-13 23:59:22 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-07-03 06:50:40 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-16 14:42:09 +01:00
|
|
|
|
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);
|
2026-07-17 12:40:42 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-07-16 14:42:09 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-17 07:16:04 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-05 22:02:26 +01:00
|
|
|
|
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))];
|
2026-07-06 11:42:11 +01:00
|
|
|
|
// 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);
|
2026-07-05 22:02:26 +01:00
|
|
|
|
|
2026-07-06 11:42:11 +01:00
|
|
|
|
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
2026-07-13 23:59:22 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-07-15 12:32:36 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-07-13 23:59:22 +01:00
|
|
|
|
// The trends chart still renders (inside the Explore trends section)…
|
2026-07-14 23:24:39 +01:00
|
|
|
|
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);
|
2026-07-05 22:02:26 +01:00
|
|
|
|
|
2026-07-13 23:59:22 +01:00
|
|
|
|
// …with the mobile chart legend chips and tap-to-focus behaviour intact.
|
2026-07-05 22:02:26 +01:00
|
|
|
|
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
|
|
|
|
|
const chips = chipGroup.getByRole('button');
|
2026-07-06 11:42:11 +01:00
|
|
|
|
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
|
|
expect(await chips.count()).toBeGreaterThanOrEqual(2);
|
2026-07-05 22:02:26 +01:00
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-21 12:08:50 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 06:50:40 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-07-04 22:13:56 +01:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-07-05 14:29:43 +01:00
|
|
|
|
// 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');
|
2026-07-04 22:13:56 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-07-22 15:38:56 +01:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-02 21:41:18 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
});
|
2026-08-06 12:13:04 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-08-15 09:48:41 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 12:13:04 +01:00
|
|
|
|
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();
|
2026-08-06 13:38:17 +01:00
|
|
|
|
// 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);
|
2026-08-06 12:13:04 +01:00
|
|
|
|
expect(og.ok()).toBe(true);
|
|
|
|
|
|
expect(og.headers()['content-type']).toContain('image/png');
|
|
|
|
|
|
|
2026-08-14 22:34:22 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
2026-08-06 12:13:04 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-06 13:38:17 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
2026-08-06 14:04:36 +01:00
|
|
|
|
// 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();
|
2026-08-07 18:05:49 +01:00
|
|
|
|
const headings = [...document.querySelectorAll('h1, h2')].map(first);
|
2026-08-06 13:38:17 +01:00
|
|
|
|
return {
|
2026-08-06 14:04:36 +01:00
|
|
|
|
body: first(document.body),
|
2026-08-07 18:05:49 +01:00
|
|
|
|
headings,
|
2026-08-06 14:04:36 +01:00
|
|
|
|
bodyStack: getComputedStyle(document.body).fontFamily,
|
2026-08-06 13:38:17 +01:00
|
|
|
|
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
|
2026-08-06 14:04:36 +01:00
|
|
|
|
// mode this guards — the computed font-family would look plausible either
|
|
|
|
|
|
// way, because an invalid font-family just inherits.
|
2026-08-06 13:38:17 +01:00
|
|
|
|
expect(fonts.displayToken, '--font-display resolved').not.toBe('');
|
|
|
|
|
|
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
|
|
|
|
|
|
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
|
|
|
|
|
|
|
2026-08-07 18:05:49 +01:00
|
|
|
|
// 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');
|
2026-08-06 14:04:36 +01:00
|
|
|
|
|
2026-08-07 18:05:49 +01:00
|
|
|
|
// 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.
|
2026-08-06 14:04:36 +01:00
|
|
|
|
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
|
2026-08-06 13:38:17 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-07 18:05:49 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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*$/);
|
|
|
|
|
|
|
2026-08-14 22:34:22 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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();
|
2026-08-07 18:05:49 +01:00
|
|
|
|
await expect(mark).toBeVisible();
|
2026-08-14 22:34:22 +01:00
|
|
|
|
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);
|
2026-08-07 18:05:49 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 13:38:17 +01:00
|
|
|
|
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;
|
2026-08-06 14:04:36 +01:00
|
|
|
|
if (el.closest('.leaflet-tile-pane')) return false;
|
2026-08-06 13:38:17 +01:00
|
|
|
|
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;
|
2026-08-06 14:04:36 +01:00
|
|
|
|
if (el.closest('.leaflet-tile-pane')) continue; // OSM tiles are imagery, not palette
|
2026-08-06 13:38:17 +01:00
|
|
|
|
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([]);
|
|
|
|
|
|
});
|
2026-08-06 15:38:31 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-08-15 22:48:30 +01:00
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-23 10:57:34 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
|
async function schoolWithCutoff(page: Page) {
|
2026-08-15 22:48:30 +01:00
|
|
|
|
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();
|
2026-08-16 13:42:06 +01:00
|
|
|
|
if (body?.admission_distance?.distance_m == null) continue;
|
2026-08-20 18:44:57 +01:00
|
|
|
|
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
2026-08-15 22:48:30 +01:00
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 10:57:34 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-15 22:48:30 +01:00
|
|
|
|
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.
|
2026-08-20 21:01:28 +01:00
|
|
|
|
// 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();
|
2026-08-15 22:48:30 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
// ── The Distance view, map and postcode check ────────────────────────────
|
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
|
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');
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
await page.goto(`/school/${found!.urn}`);
|
|
|
|
|
|
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
|
// 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);
|
2026-08-20 21:01:28 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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');
|
2026-08-16 13:42:06 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
|
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');
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
await page.goto(`/school/${found!.urn}`);
|
2026-08-20 18:44:57 +01:00
|
|
|
|
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 });
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
|
await page.getByLabel('Your postcode').fill('SW1A 1AA');
|
2026-08-20 21:01:28 +01:00
|
|
|
|
await page.getByRole('button', { name: 'Check', exact: true }).click();
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
const result = page.getByRole('status');
|
|
|
|
|
|
const error = page.getByRole('alert');
|
|
|
|
|
|
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
|
|
|
|
|
|
if (await result.isVisible()) {
|
2026-08-20 18:44:57 +01:00
|
|
|
|
// 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/);
|
2026-08-20 20:35:05 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-08-16 13:42:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the postcode check states its limits before it is used', async ({ page }) => {
|
2026-08-20 18:44:57 +01:00
|
|
|
|
const found = await schoolWithCutoff(page);
|
|
|
|
|
|
test.skip(found === null, 'no school in the sample has a published cut-off yet');
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
await page.goto(`/school/${found!.urn}`);
|
2026-08-20 18:44:57 +01:00
|
|
|
|
const section = page.locator('#distance');
|
|
|
|
|
|
test.skip(await section.count() === 0, 'this school has no distance section');
|
|
|
|
|
|
await expect(section).toBeVisible({ timeout: 15_000 });
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
2026-08-20 14:54:02 +01:00
|
|
|
|
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);
|
2026-08-16 13:42:06 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the distance section never scrolls the page sideways', async ({ page }) => {
|
2026-08-20 18:44:57 +01:00
|
|
|
|
const found = await schoolWithCutoff(page);
|
|
|
|
|
|
test.skip(found === null, 'no school in the sample has a published cut-off yet');
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
|
|
await page.goto(`/school/${found!.urn}`);
|
2026-08-20 18:44:57 +01:00
|
|
|
|
const section = page.locator('#distance');
|
|
|
|
|
|
test.skip(await section.count() === 0, 'this school has no distance section');
|
|
|
|
|
|
await expect(section).toBeVisible({ timeout: 15_000 });
|
2026-08-16 13:42:06 +01:00
|
|
|
|
|
|
|
|
|
|
const overflow = await page.evaluate(() =>
|
|
|
|
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
|
|
|
|
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
|
|
|
|
|
|
});
|
2026-08-20 14:54:02 +01:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-08-20 18:44:57 +01:00
|
|
|
|
const found = await schoolWithCutoff(page);
|
2026-08-20 14:54:02 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-20 21:46:28 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-20 22:54:24 +01:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-20 21:46:28 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-20 22:14:32 +01:00
|
|
|
|
async function sitemapChildren(page: Page): Promise<string[]> {
|
2026-08-20 21:46:28 +01:00
|
|
|
|
const res = await page.request.get('/sitemap.xml');
|
|
|
|
|
|
expect(res.ok()).toBeTruthy();
|
2026-08-20 22:14:32 +01:00
|
|
|
|
const index = await res.text();
|
|
|
|
|
|
expect(index).toContain('<sitemapindex');
|
|
|
|
|
|
return [...index.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
|
|
|
|
|
}
|
2026-08-20 21:46:28 +01:00
|
|
|
|
|
2026-08-20 22:14:32 +01:00
|
|
|
|
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>');
|
2026-08-20 21:46:28 +01:00
|
|
|
|
|
2026-08-20 22:14:32 +01:00
|
|
|
|
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>');
|
2026-08-20 21:46:28 +01:00
|
|
|
|
});
|
2026-08-20 22:10:50 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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'],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-21 23:51:34 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 22:10:50 +01:00
|
|
|
|
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);
|
2026-08-21 23:51:34 +01:00
|
|
|
|
expect(sameUrl(hrefs[0], expected),
|
|
|
|
|
|
`${path} canonical was ${hrefs[0]}, expected ${expected}`).toBe(true);
|
2026-08-20 22:10:50 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2026-08-21 23:51:34 +01:00
|
|
|
|
expect(sameUrl(href, 'https://www.schoolcompare.co.uk/'),
|
|
|
|
|
|
`filtered homepage canonical was ${href}`).toBe(true);
|
2026-08-20 22:10:50 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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+-/);
|
|
|
|
|
|
});
|
2026-08-20 22:11:34 +01:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-08-20 23:21:58 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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.
|
2026-08-21 23:51:34 +01:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-08-20 23:21:58 +01:00
|
|
|
|
const robots = await (await page.request.get('/robots.txt')).text();
|
2026-08-21 23:51:34 +01:00
|
|
|
|
expect(blocksEverything(robots, '*'),
|
|
|
|
|
|
'the * group must not disallow the whole site, or the noindex is never seen')
|
|
|
|
|
|
.toBe(false);
|
2026-08-20 23:21:58 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-21 23:51:34 +01:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 23:21:58 +01:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-08-21 00:21:08 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
});
|
2026-08-21 18:19:08 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-21 20:43:07 +01:00
|
|
|
|
|
|
|
|
|
|
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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-27 08:49:14 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-21 20:43:07 +01:00
|
|
|
|
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</);
|
|
|
|
|
|
});
|
2026-08-21 21:42:53 +01:00
|
|
|
|
|
2026-08-22 17:22:11 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-21 21:42:53 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-21 22:19:23 +01:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-08-22 17:22:11 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-21 22:19:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-22 00:09:58 +01:00
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
|
2026-08-26 21:38:16 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
2026-08-22 00:09:58 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
});
|
2026-08-26 20:39:33 +01:00
|
|
|
|
|
2026-08-27 08:23:21 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-26 20:39:33 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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+/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-26 21:07:48 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-26 21:38:16 +01:00
|
|
|
|
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\//);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-26 20:39:33 +01:00
|
|
|
|
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/);
|
|
|
|
|
|
});
|