mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
test(e2e): cover the URL contract, and compile game routes before the race
Adds coverage for this branch's behaviour: the homoglyph 404 through a real percent-encoded request, href assertions on both not-found exits (a visible link to nowhere is still a dead end), and the two halves of InlineScript's contract -- no console error where React renders the script on the client, and an executable type in the served markup everywhere else. That second one is asserted on raw HTML, with no browser, because in a browser the regression is invisible: ThemeToggle re-applies the theme on mount, so <html> ends up correct whether or not the script ran -- just a flash later. Mutating the helper to always emit text/plain previously failed nothing. global-setup fixes a real flake. next dev compiles a route on first request, and 8 workers demanded the same cold compile at once, so every test navigating to a /game URL failed together at the 60s timeout -- reproducible by touching any source file. One serial warm-up pays the compile once: 28 passed in 14s. The fetch is bounded and its failure logged, because a skipped warm-up otherwise looks exactly like the flake it prevents. eslint ignores Playwright's artifact dirs, which are gitignored but were still walked, crashing a lint run concurrent with a test run.
This commit is contained in:
1 parent
f116fec36f
commit
d5c97fab5c
4 files changed
+114
-4
No files matched your search
+10
-1
@@ -5,7 +5,16 @@ import globals from "globals";
|
||||
// FlatCompat bridge (@eslint/eslintrc) is gone.
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [".next/**", ".next-check/**", "src/data/**"],
|
||||
// Playwright's artifact dirs are gitignored but eslint walks them anyway,
|
||||
// and a lint run concurrent with a test run crashes on the files the
|
||||
// reporter is still writing (ENOENT scandir 'test-results').
|
||||
ignores: [
|
||||
".next/**",
|
||||
".next-check/**",
|
||||
"src/data/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
],
|
||||
},
|
||||
...coreWebVitals,
|
||||
{
|
||||
|
||||
@@ -10,6 +10,9 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
testDir: 'tests/e2e',
|
||||
testMatch: '**/*.spec.js',
|
||||
// Compiles the game routes once, before the workers race for them. See the
|
||||
// file for why the suite fails in a block of 8 without it.
|
||||
globalSetup: './tests/e2e/global-setup.js',
|
||||
fullyParallel: true,
|
||||
// The suite is deterministic (no live services); a failure is a real one.
|
||||
retries: 0,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Warms the dev server's heavy routes before any worker runs.
|
||||
//
|
||||
// `next dev` compiles a route on its first request. The game routes are the
|
||||
// expensive ones, and 8 parallel workers otherwise all demand that same cold
|
||||
// compile at once, each waiting behind it -- every test that navigates to a
|
||||
// /game URL then fails together at the 60s timeout. Reproducible on demand:
|
||||
// touch a source file (invalidating the compile cache) and run the suite.
|
||||
//
|
||||
// One serial pass over the three route shapes pays the compile once. A warm
|
||||
// server, or a production one, makes these near-instant no-ops.
|
||||
export default async function globalSetup(config) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
// One per compiled route: the prerendered region page, the dynamic legacy
|
||||
// redirect, and the not-found path through the [region] segment.
|
||||
const routes = ['/', '/game/tphcm', '/game?region=TPHCM', '/game/notaregion'];
|
||||
|
||||
for (const route of routes) {
|
||||
try {
|
||||
// Bounded: a wedged compile would otherwise hang here forever, and
|
||||
// Playwright's globalTimeout is 0, so the run would stall with no output.
|
||||
await fetch(new URL(route, baseURL), {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch (error) {
|
||||
// A warm-up is an optimisation, never a gate: if the server is not
|
||||
// answering, the specs' own waits and retries still apply, and their
|
||||
// failure messages say far more than one thrown here would. Only a
|
||||
// network-level failure lands here -- fetch resolves on any HTTP status,
|
||||
// so a 404 or a 500 is a successful warm-up, not a miss.
|
||||
//
|
||||
// Said out loud, because a skipped warm-up looks exactly like the flake
|
||||
// it exists to prevent: 8 game-route tests timing out together.
|
||||
console.warn(`[global-setup] warm-up of ${route} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,15 @@ test('404s an unknown region', async ({ page }) => {
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('404s a spelling that only uppercases into a region', async ({ page }) => {
|
||||
// U+0131 dotless i: 'hn-badınh'.toUpperCase() is 'HN-BADINH', a real code.
|
||||
// Accepting it would serve a real round at a URL that is its own ISR entry
|
||||
// and its own analytics row -- the split the region path exists to avoid.
|
||||
// Asserted through a real request because the encoding is half the point.
|
||||
const response = await page.goto('/game/hn-bad%C4%B1nh');
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('serves a real region with no imagery instead of 404ing it', async ({ page }) => {
|
||||
// Deliberately NOT a 404. The API answers an uncovered region with a written
|
||||
// message ("... has no street view coverage yet") and the page renders it;
|
||||
@@ -112,17 +121,57 @@ test('serves a real region with no imagery instead of 404ing it', async ({ page
|
||||
// here, so this asserts only the routing half -- that the page exists and
|
||||
// serves. The message itself is the API's contract, covered in
|
||||
// tests/region-request.test.js.
|
||||
const { isRegion, isPlayable } = await import('../../src/lib/regions.js');
|
||||
const { isRegion, isPlayable, regionSlug } = await import('../../src/lib/regions.js');
|
||||
const code = 'TPHCM-CUCHI';
|
||||
expect(isRegion(code), `${code} should still be a region`).toBe(true);
|
||||
expect(isPlayable(code), `${code} gained coverage; pick another fixture`).toBe(false);
|
||||
|
||||
const { regionSlug } = await import('../../src/lib/regions.js');
|
||||
const response = await page.goto(`/game/${regionSlug(code)}`);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(landedAt(page)).toBe(`/game/${regionSlug(code)}`);
|
||||
});
|
||||
|
||||
test('the pre-paint theme script is served executable', async ({ request }) => {
|
||||
// The other half of InlineScript's contract. Asserted on the raw HTML, with
|
||||
// no browser, because in a browser this is invisible: ThemeToggle re-applies
|
||||
// the theme on mount (ThemeToggle.js:33-39), so <html> ends up with the right
|
||||
// class whether or not the script ran -- just a flash later. An executable
|
||||
// type in the served markup is the only thing that proves it runs before
|
||||
// paint, so that is what this pins.
|
||||
for (const path of ['/', '/game/tphcm']) {
|
||||
const html = await (await request.get(path)).text();
|
||||
const headEnd = html.indexOf('</head>');
|
||||
// Asserted, not assumed: indexOf returning -1 would slice to nothing and
|
||||
// quietly turn the checks below into assertions about an empty string.
|
||||
expect(headEnd, `${path} served no </head>`).toBeGreaterThan(0);
|
||||
const head = html.slice(0, headEnd);
|
||||
expect(head, `${path} lost its inline theme script`).toContain('classList.toggle');
|
||||
expect(head, `${path} serves the theme script inert -- it would flash`)
|
||||
.toMatch(/<script type="text\/javascript">\(function\(\)\{try\{/);
|
||||
expect(head, `${path} serves the theme script as a data block`)
|
||||
.not.toContain('text/plain');
|
||||
}
|
||||
});
|
||||
|
||||
test('the region 404 renders without a console error', async ({ page }) => {
|
||||
// This route is the only one React client-renders the root layout for (the
|
||||
// server sends Next's error shell), so it is the only one where the layout's
|
||||
// inline theme script reaches React on the client. Left untyped, React warns
|
||||
// "Encountered a script tag while rendering React component" -- pointing at
|
||||
// a script that silently never runs. InlineScript marks it text/plain there.
|
||||
const errors = [];
|
||||
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await page.goto('/game/notaregion');
|
||||
await expect(page.getByRole('heading', { name: 'No such region' })).toBeVisible();
|
||||
|
||||
// The browser logs the 404 response itself as a failed resource load. That
|
||||
// one is the point of the page; everything else is a defect.
|
||||
const real = errors.filter((e) => !e.includes('Failed to load resource'));
|
||||
expect(real, `console errors on the region 404:\n${real.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('the region 404 offers a way out, in the visitor\'s theme', async ({ page }) => {
|
||||
// A thrown notFound() is served from Next's error shell, which carries none
|
||||
// of the root layout's pre-paint theme script -- so without the re-apply in
|
||||
@@ -134,14 +183,25 @@ test('the region 404 offers a way out, in the visitor\'s theme', async ({ page }
|
||||
expect(response.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: 'No such region' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Pick a region' })).toBeVisible();
|
||||
// The way out has to go somewhere: a visible link to nowhere is still a dead
|
||||
// end, and this is the only exit on the page.
|
||||
await expect(page.getByRole('link', { name: 'Pick a region' })).toHaveAttribute('href', '/');
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
// The footer proves the panel ends up inside the root layout, whose flex
|
||||
// column is what centres it. Post-hydration only, and deliberately so: the
|
||||
// server response here is Next's error shell, which carries neither the
|
||||
// layout nor this panel (measured: `curl /game/notaregion` has zero
|
||||
// occurrences of either). First paint is the empty shell; this assertion
|
||||
// cannot see it, and nothing in this suite can.
|
||||
await expect(page.getByText('Made by')).toBeVisible();
|
||||
});
|
||||
|
||||
test('an unmatched path gets the app-wide 404, not a bare Next page', async ({ page }) => {
|
||||
const response = await page.goto('/nosuchpath');
|
||||
expect(response.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: 'Page not found' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Go to the start' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Go to VNGeoGuessr' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Go to VNGeoGuessr' })).toHaveAttribute('href', '/');
|
||||
// The footer proves it rendered inside the root layout: Next's stock page
|
||||
// pushes it off screen with its own full-height wrapper.
|
||||
await expect(page.getByText('Made by')).toBeVisible();
|
||||
|
||||
Reference in new issue
Block a user