diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b97f03..6f62eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.55.0 — 2026-09-22 15:38 +- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `` into the head; no layout/metadata changes needed. +- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset. +- **E2E hardening (CI run #167).** Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon `` (`/icon.svg?icon..svg` — matched with `/^\/icon\.svg/`, canonical `GET /icon.svg` check unchanged), and `e2e/hover.spec.ts`'s local `flyHomeViaApi` self-heal is replaced by the established `e2e/panes.spec.ts` pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked `locationIcao` against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin"). +- **E2E hardening (CI run #168).** Retry-cascade fix in `e2e/actions.spec.ts`: its local `flyHomeViaApi` self-heal now also tops plane-starter's tank back to full via the free `POST /api/test/set-fuel` hook after repositioning home. A transient `/api/state` 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0. +- **E2E hardening (ISS-127).** e2e `getState` hardened against transient `/api/state` 500s — internal retry ×3/500 ms keeps poll callbacks total. + ## v0.54.0 — 2026-09-22 15:20 - **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working). - **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks. diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..76e6ffe --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index d4e10f8..0f1b7e2 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -50,9 +50,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.ok()).toBeTruthy(); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. @@ -176,6 +181,10 @@ async function flyHomeViaApi(page: Page): Promise { expect(res.ok()).toBeTruthy(); } } + // Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own + // dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so + // exact-cash assertions never see that cost. + await setFuelFull(page, 'plane-starter'); } diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 64c8d85..a22c8c9 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -48,9 +48,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck — @@ -84,33 +89,30 @@ async function setFuelFull(page: Page, planeId: string): Promise { expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } -// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. +// Self-heal (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an +// interrupted run, let any in-flight leg finish, then reposition plane-starter home via the API. async function flyHomeViaApi(page: Page): Promise { - const s0 = await getState(page); - const starter = s0.planes.find((p) => p.id === 'plane-starter')!; + // v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any + // active repair gate from an interrupted run before acting on the starter. + expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); + let s = await getState(page); // ticks — a shortened gate flips the plane back to available here + let starter = s.planes.find((p) => p.id === 'plane-starter')!; if (starter.status === 'flying' || starter.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (starter.status === 'flying') { - await expect - .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { - timeout: 240_000, - message: 'waiting for an in-flight plane-starter to land', - }) - .toBe(true); + // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available'); + s = await getState(page); + starter = s.planes.find((p) => p.id === 'plane-starter')!; } if (starter.locationIcao !== HUB) { // Make sure the world rate is back at SCALE so the return leg fast-forwards. await setScale(page, SCALE); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) - await expect - .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { - timeout: 240_000, - message: 'waiting for plane-starter to land back at the hub', - }) - .toBe(true); + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB); } } diff --git a/e2e/maintenance.spec.ts b/e2e/maintenance.spec.ts index 0e1c102..c16bc03 100644 --- a/e2e/maintenance.spec.ts +++ b/e2e/maintenance.spec.ts @@ -42,9 +42,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck — diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index ea5c388..3dfd525 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck — diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index 29680ec..e4b416b 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -16,9 +16,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. @@ -192,3 +197,16 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa await expect(page.getByTestId('tab-planes-market')).toBeVisible(); await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true'); }); + +test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => { + await page.goto('/'); + // App Router file convention: app/icon.svg is auto-served and injected into the head. + const link = page.locator('head link[rel~="icon"]'); + await expect(link).toHaveCount(1); + // Production builds append a content-hash query string to the auto-injected icon href. + expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/); + + const res = await page.request.get('/icon.svg'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('image/svg+xml'); +}); diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 8dd0bc5..5d59e15 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -47,9 +47,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index a776a1f..709f1e0 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -21,9 +21,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.ok()).toBeTruthy(); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 2ce26fc..d78ea56 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -70,9 +70,14 @@ interface StateBody { const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck — diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index 3e39b60..b0b89ca 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -23,9 +23,14 @@ interface StateBody { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. diff --git a/e2e/toasts.spec.ts b/e2e/toasts.spec.ts index 3ffd46e..e7773e0 100644 --- a/e2e/toasts.spec.ts +++ b/e2e/toasts.spec.ts @@ -18,9 +18,14 @@ interface StateBody { } async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => { diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts index e617a63..11d1c0b 100644 --- a/e2e/two-planes.spec.ts +++ b/e2e/two-planes.spec.ts @@ -67,9 +67,14 @@ interface StateBody { const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); async function getState(page: Page): Promise { - const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); - return (await res.json()) as StateBody; + // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would + // instantly fail any expect.poll callback, so retry briefly instead. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. diff --git a/package.json b/package.json index 11fd2f1..40299ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.54.0", + "version": "0.55.0", "private": true, "scripts": { "dev": "next dev -p 3000",