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/"]'); } test('home page loads with hero search', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1').first()).toBeVisible(); await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible(); }); test('searching by name returns school results', async ({ page }) => { await searchByName(page, 'primary'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); expect(await schoolLinks(page).count()).toBeGreaterThan(1); }); test('searching by postcode returns nearby schools', async ({ page }) => { await searchByName(page, 'B1 1BB'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); }); test('school detail page renders name and performance data', async ({ page }) => { await searchByName(page, 'primary'); const firstSchool = schoolLinks(page).first(); await expect(firstSchool).toBeVisible({ timeout: 15_000 }); await firstSchool.click(); await page.waitForURL(/\/school\//); await expect(page.locator('h1').first()).toBeVisible(); // The detail page renders at least one *visible* chart canvas. Plain // .first() is wrong here: the admissions card stacks its year/trend views // in one grid cell and keeps the inactive view's canvas visibility:hidden // by design, and that canvas comes first in the DOM. await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); }); test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => { // iOS Safari has no Element.requestFullscreen; the map must fall back to a // CSS overlay. Simulate that by removing the API before any page script runs. await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => { // @ts-expect-error deliberate API removal delete Element.prototype.requestFullscreen; }); await searchByName(page, 'primary'); const firstSchool = schoolLinks(page).first(); await expect(firstSchool).toBeVisible({ timeout: 15_000 }); await firstSchool.click(); await page.waitForURL(/\/school\//); const openMap = page.getByRole('button', { name: 'Open full map' }); await expect(openMap).toBeVisible({ timeout: 15_000 }); await openMap.click(); const closeMap = page.getByRole('button', { name: 'Close map' }); await expect(closeMap).toBeVisible(); await closeMap.click(); await expect(openMap).toBeVisible(); }); test('comparing two schools shows both side by side', async ({ page }) => { // Collect two school URNs from search results, then load the share URL 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))]; expect(urns.length).toBeGreaterThanOrEqual(2); await page.goto(`/compare?urns=${urns[0]},${urns[1]}`); // Both schools' detail links should render in the comparison view await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); }); test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await searchByName(page, 'primary'); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); const hrefs = await schoolLinks(page).evaluateAll((links) => links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '') ); const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))]; // Compare three schools, not two: a "primary" search can return all-through // schools that classify as secondary, and the chips only appear for the // active phase. With three schools across two phases, the auto-selected // majority phase always holds ≥2, so the chip legend is guaranteed to render. expect(urns.length).toBeGreaterThanOrEqual(3); await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`); await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); // The mobile chart legend renders one chip per school in the active phase. const chipGroup = page.getByRole('group', { name: /highlight a school/i }); const chips = chipGroup.getByRole('button'); await expect(chips.first()).toBeVisible({ timeout: 15_000 }); expect(await chips.count()).toBeGreaterThanOrEqual(2); // Tapping a chip focuses that school's line; tapping again releases it. await chips.first().click(); await expect(chips.first()).toHaveAttribute('aria-pressed', 'true'); await chips.first().click(); await expect(chips.first()).toHaveAttribute('aria-pressed', 'false'); }); test('rankings page loads a populated table', async ({ page }) => { await page.goto('/rankings'); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 15_000 }); expect(await rows.count()).toBeGreaterThan(5); }); test('rankings stay populated after picking a specific year', async ({ page }) => { // Years are academic-year codes (e.g. 201819); the API must accept them // as the `year` query param rather than rejecting with a 422. await page.goto('/rankings'); const yearSelect = page.locator('#year-select'); await expect(yearSelect).toBeVisible({ timeout: 15_000 }); // Pick the last option — the most recent explicit year. The default view // already proved this year has rows, so an empty table after selecting it // can only mean the year param was rejected. (The oldest year is no good // here: staging doesn't always carry the full data history.) const yearValue = await yearSelect.locator('option').last().getAttribute('value'); expect(yearValue).toBeTruthy(); await yearSelect.selectOption(yearValue!); await page.waitForURL(/year=/); const rows = page.locator('table tbody tr'); await expect(rows.first()).toBeVisible({ timeout: 15_000 }); expect(await rows.count()).toBeGreaterThan(5); });