diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d4d1747..161486f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Prepare e2e network run: | set -u + # Interrupted runs skip the end-of-run cleanup, leaking a USED DB volume into later + # runs (tests assume seeded state) — drop it so every run starts fresh. + docker volume rm flight-ci_flight-db 2>/dev/null || true SUBNET=172.39.0.0/24 if docker network inspect flight-ci-e2e >/dev/null 2>&1; then cur=$(docker network inspect flight-ci-e2e --format '{{(index .IPAM.Config 0).Subnet}}') diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8fe22..2d6e2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - e2e — new `e2e/hover.spec.ts` (3 tests, same SCALE/self-heal/`window.__flightMap` conventions as the other specs): fleet-row hover → `planes-highlight` renders exactly plane-starter, clears on mouseleave; open-package hover → `airports-highlight` shows both endpoint ICAOs (hook-created EDDF→LEMD package), clears on mouseleave; on-board block of a flying plane → `routes-highlight` shows the leg's planeId (sim paused mid-flight so the ~10 s leg can't land under the assertions, then resumed + fly-home cleanup) - Verified: `tsc --noEmit` clean; unit suite **238/238** green locally (no lib changes); e2e not run locally by design — CI verifies against an isolated stack - **E2e hover spec mechanics fixed after CI run #32** (`e2e/hover.spec.ts`, no app changes): the fleet-row wait now polls up to 30 s with a diagnostic that distinguishes "server has no plane-starter" from "DOM didn't render" (the browser's /api/state poll can lag node-side requests right after page load, leaving the pane in its loading state); the open-package test fits the map to both endpoint airports before asserting — `queryRenderedFeatures` only returns features inside the current viewport and the default Frankfurt-centered view leaves LEMD off-screen +- **Decisive hover test-1 diagnostics (run #33 transient)**: the fleet-row poll now reports on every failed attempt what the NODE side sees (`/api/state` plane ids + cash via `page.request`) vs what the BROWSER last polled — new dev hook `window.__flightLastSnapshot` set in `lib/useGameState.ts` after each successful fetch (planeIds/cash/timeScale/timestamp, client-only) — plus the fleet section's HTML (`fleet-list` outerHTML or left-pane text), so a future failure can distinguish server divergence from a render bug +- **CI removes the stale e2e DB volume at run start** (`.gitea/workflows/ci.yml`, "Prepare e2e network"): `docker volume rm flight-ci_flight-db 2>/dev/null || true` — an interrupted run skips the end-of-run cleanup and leaks its used game state into later runs (run #33's instance showed spent cash from a pre-#26 seed), while tests assume seeded state ## v0.14.0 — 2026-09-19 08:45 - **Time-scale slider reworked to a 10-step ladder up to 1000× (ISS-006).** The old power-of-two ladder `1/2/4/8/16/32/64` is replaced by the discrete values `1, 2, 5, 10, 25, 50, 100, 250, 500, 1000` (each step roughly doubles the speed). The server clamp range widens from `[1, 64]` to `[1, 1000]` (`MAX_TIME_SCALE` in `lib/sim/engine.ts`, enforced by `clampTimeScale`; out-of-range values are still clamped, never rejected), and the OpenAPI spec's `timeScale` bounds + description follow. The slider mapping helpers in `lib/timeScale.ts` now place off-ladder scales at the nearest ladder step **by log distance** (the old `log2` formula assumed a pure power-of-two ladder); round-trip, monotonicity and endpoint-exactness are preserved diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index a5dcb68..aa939b1 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -32,6 +32,7 @@ interface PackageState { } interface StateBody { + account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; packages: PackageState[]; @@ -166,16 +167,25 @@ test('hovering a fleet row highlights the plane on the map', async ({ page }) => const row = page.locator('li[data-testid="plane-starter"]'); // The browser's /api/state polls can lag node-side requests right after a fresh page load // (useGameState keeps loading=true until the first success → no fleet rows yet), so poll with - // diagnostics: on timeout the message distinguishes "server has no plane-starter" from - // "DOM didn't render". (The value is returned, not thrown — expect.poll fails immediately - // on a callback throw instead of retrying.) + // diagnostics: on timeout the message compares what the NODE side sees vs what the BROWSER + // last polled, plus the fleet section's HTML — distinguishing server divergence from a render + // bug. (The value is returned, not thrown — expect.poll fails immediately on a callback throw + // instead of retrying.) await expect .poll(async () => { if ((await row.count()) > 0 && (await row.isVisible())) return 'visible'; const body = await getState(page); - const serverHasStarter = body.planes.some((p) => p.id === 'plane-starter'); - const paneHtml = (await page.locator('[data-testid="left-pane"]').first().innerHTML()).slice(0, 200); - return `not visible — /api/state has plane-starter: ${serverHasStarter}; left-pane HTML: ${paneHtml}`; + const browserSnap = await page.evaluate(() => (window as any).__flightLastSnapshot ?? null); + const fleetHtml = await page.evaluate(() => { + const el = document.querySelector('[data-testid="fleet-list"]'); + if (el) return `fleet-list HTML: ${(el.outerHTML ?? '').slice(0, 300)}`; + const pane = document.querySelector('[data-testid="left-pane"]'); + return `no fleet-list — left-pane text: ${(pane?.innerText ?? '').slice(0, 200)}`; + }); + return ( + `not visible — node /api/state planes=[${body.planes.map((p) => p.id).join(', ')}] cash=${body.account.cash}; ` + + `browser __flightLastSnapshot=${JSON.stringify(browserSnap)}; ${fleetHtml}` + ); }, { timeout: 30_000 }) .toBe('visible'); diff --git a/lib/useGameState.ts b/lib/useGameState.ts index 664a2dc..5aa4398 100644 --- a/lib/useGameState.ts +++ b/lib/useGameState.ts @@ -21,7 +21,18 @@ export interface GameStateResult { export async function fetchState(): Promise { const res = await fetch('/api/state', { cache: 'no-store' }); if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`); - return (await res.json()) as StateSnapshot; + const snapshot = (await res.json()) as StateSnapshot; + // Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what + // the BROWSER received against a fresh node-side fetch. Client-only, harmless in prod. + if (typeof window !== 'undefined') { + (window as unknown as Record).__flightLastSnapshot = { + planeIds: snapshot.planes.map((p) => p.id), + cash: snapshot.account.cash, + timeScale: snapshot.simState.timeScale, + at: Date.now(), + }; + } + return snapshot; } // The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,