From ec2d12478e7bbb9e86bd6786c96cfd4bfade52d3 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 2 Aug 2026 21:41:18 +0100 Subject: [PATCH] test(e2e): cover the detail page server/client boundary Two journeys for what the split actually changed: the admissions toggle (the one client island inside an otherwise server-rendered section) and the sticky nav resolving to server-rendered sections. The nav is client-rendered from a server-computed list while the sections render on the server, so a mismatch between the two halves would only show up in a real browser. Existing journeys already cover all-through and special-school pages. Co-Authored-By: Claude Opus 5 --- e2e/tests/journeys.spec.ts | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index a8893eb..3321d18 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -556,3 +556,73 @@ test('compare metric-help popover stays within the mobile viewport', async ({ pa .evaluate((el) => el.scrollWidth > el.clientWidth + 1); expect(bodyOverflowsX).toBe(false); }); + +/** + * The following two journeys cover the server/client split of the detail page. + * The sections are now React Server Components composed in the route and passed + * through a client shell; these assert that the two halves still meet correctly + * in a real browser, which no unit test can prove. + */ + +test('admissions year/trend toggle still switches views after the server/client split', async ({ page }) => { + // Find a school with at least two years carrying an offer rate — the toggle + // only appears then. Data-invariant: uses whatever the environment holds. + const res = await page.request.get('/api/schools?search=primary&per_page=50'); + expect(res.ok()).toBeTruthy(); + const candidates: number[] = ((await res.json()).schools ?? []).map((s: { urn: number }) => s.urn); + + let target: number | null = null; + for (const urn of candidates.slice(0, 12)) { + const detail = await page.request.get(`/api/schools/${urn}`); + if (!detail.ok()) continue; + const history = (await detail.json()).admissions_history ?? []; + const withRate = history.filter( + (h: { first_preference_offer_pct?: number | null }) => h.first_preference_offer_pct != null, + ); + if (withRate.length >= 2) { target = urn; break; } + } + test.skip(target === null, 'no school in this environment has 2+ years of admissions offer data'); + + await page.goto(`/school/${target}`); + await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); + + const yearBtn = page.getByRole('button', { name: 'This year' }); + const trendBtn = page.getByRole('button', { name: /-year trend$/ }); + await expect(yearBtn).toHaveAttribute('aria-pressed', 'true'); + + // The toggle is the one client island inside an otherwise server-rendered + // section: clicking it must swap the two server-rendered views. + await trendBtn.click(); + await expect(trendBtn).toHaveAttribute('aria-pressed', 'true'); + await expect(yearBtn).toHaveAttribute('aria-pressed', 'false'); + + await yearBtn.click(); + await expect(yearBtn).toHaveAttribute('aria-pressed', 'true'); +}); + +test('sticky section nav jumps to server-rendered sections', async ({ page }) => { + const [urn] = await twoPrimaryUrns(page); + await page.goto(`/school/${urn}`); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + + // The nav is client-rendered from a server-computed list, while the sections + // themselves are server-rendered. Every link must resolve to a real section: + // the scroll-spy finds them with document.getElementById, so a mismatch + // between the two halves would dead-end here. + const navLinks = page.locator('nav a[href^="#"]'); + const count = await navLinks.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const href = await navLinks.nth(i).getAttribute('href'); + expect(href).toBeTruthy(); + await expect(page.locator(href!)).toHaveCount(1); + } + + // And following one actually moves the page. + const before = await page.evaluate(() => window.scrollY); + await navLinks.last().click(); + await page.waitForTimeout(600); + const after = await page.evaluate(() => window.scrollY); + expect(after).toBeGreaterThan(before); +});