PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m40s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m19s
The geolocation shortcut lived in the discovery strip below the results,
away from the search. Move it directly under the hero search input, paired
with the postcode hint, so the two ways to find nearby schools ("type a
postcode" / "use my location") read as one idea and are visible at first
glance.
- FilterBar gains optional onNearMe/geoState/geoError props and renders the
teal "Use my location" pill (with spinner + error) in hero mode; the
geolocation flow itself still lives in HomeView.
- Remove the now-duplicate near-me button and its dead CSS from the
discovery section.
- Refresh the search hint copy to pair with the button.
- e2e: assert the "use my location" shortcut renders in the hero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
8.4 KiB
TypeScript
188 lines
8.4 KiB
TypeScript
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('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();
|
|
});
|
|
|
|
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('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
|
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
|
// map's fullscreen button must fall back to a CSS overlay.
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.addInitScript(() => {
|
|
// @ts-expect-error deliberate API removal
|
|
delete Element.prototype.requestFullscreen;
|
|
});
|
|
|
|
await searchByName(page, 'B1 1BB');
|
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Switch to the map view, then open the map fullscreen.
|
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
|
await openFs.click();
|
|
|
|
// The button flips to its exit state once the overlay is up.
|
|
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
|
await expect(exitFs).toBeVisible();
|
|
await exitFs.click();
|
|
await expect(openFs).toBeVisible();
|
|
});
|
|
|
|
test('comparing two schools shows 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);
|
|
});
|