Merge pull request 'v0.55.0 — add favicon (#122)' (#126) from feat/favicon into master
CI / build + unit + e2e (push) Successful in 9m52s
CI / build + unit + e2e (push) Successful in 9m52s
This commit was merged in pull request #126.
This commit is contained in:
@@ -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 `<link rel="icon">` 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 `<link href>` (`/icon.svg?icon.<hash>.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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#93c5fd"/>
|
||||
<path d="M32 5 L36 20 L59 33 L36 38 L35 47 L48 56 L35 52 L32 59 L29 52 L16 56 L29 47 L28 38 L5 33 L28 20 Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
+11
-2
@@ -50,9 +50,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
+19
-17
@@ -48,9 +48,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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 —
|
||||
|
||||
@@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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 —
|
||||
|
||||
+20
-2
@@ -16,9 +16,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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');
|
||||
});
|
||||
|
||||
+7
-2
@@ -47,9 +47,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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.
|
||||
|
||||
@@ -21,9 +21,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
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
|
||||
|
||||
@@ -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<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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 —
|
||||
|
||||
+7
-2
@@ -23,9 +23,14 @@ interface StateBody {
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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.
|
||||
|
||||
+7
-2
@@ -18,9 +18,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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 }) => {
|
||||
|
||||
@@ -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<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');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user