import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. // Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0) // (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on // the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also // works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now. // Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here. const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px // away at the initial zoom, so a click on its projected center cannot hit another circle. const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; interface StateBody { planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>; } async function getState(page: Page): Promise { // 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 // endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed. async function setFuelFull(page: Page, planeId: string): Promise { const s = await getState(page); const p = s.planes.find((x) => x.id === planeId)!; expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck — // if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale); } // Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial). async function nearestToHub(page: Page): Promise<{ icao: string }> { const res = await page.request.get('/api/airports'); expect(res.ok()).toBeTruthy(); const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon); const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon); if (d < dn) nearest = f.properties; } return { icao: nearest.icao }; } // Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and // reposition plane-starter home via the API — repositioning needs no package (v0.12.0 manual // flight) — so every test starts from the canonical state even after an interrupted previous run. async function flyHomeViaApi(page: Page): Promise { let s = await getState(page); let plane = s.planes.find((p) => p.id === 'plane-starter')!; if (plane.status === 'flying' || plane.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (plane.status === 'flying') { // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing // before deciding whether a repositioning flight is needed. await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); s = await getState(page); plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.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.id}/fly`, { data: { destIcao: HUB } }); expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); } } // Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so // fast-forward + reposition home here; then restore the fixed world rate for later specs. test.afterEach(async ({ page }) => { await flyHomeViaApi(page); await setScale(page, WORLD); }); test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test('left pane Relocate section relocates a grounded plane', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg await flyHomeViaApi(page); // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). await setFuelFull(page, 'plane-starter'); const nearest = await nearestToHub(page); await page.goto('/'); const planeSel = page.getByTestId('relocate-plane'); await expect(planeSel).toBeVisible({ timeout: 15_000 }); // pane renders once the first snapshot lands await planeSel.selectOption('plane-starter'); // Destination options come from the one-time /api/airports fetch (sorted by name) — selectOption // waits until the matching option exists, so this doubles as the load wait. const destSel = page.getByTestId('relocate-dest'); await destSel.selectOption(nearest.icao); const goBtn = page.getByTestId('relocate-go'); await expect(goBtn).toBeEnabled(); await goBtn.click(); // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), // so poll until it took effect on the server. await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === nearest.icao; }, { timeout: 15_000, message: 'waiting for the relocate click to take effect (status "flying" + dest set)' }).toBe(true); }); test('airport popup offers Relocate options for grounded planes', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS await flyHomeViaApi(page); // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). await setFuelFull(page, 'plane-starter'); await page.goto('/'); // Wait until the airport count renders (airports loaded) and the map has a snapshot containing // the plane — the popup's Relocate row is built from that same latest snapshot. const countEl = page.getByTestId('airport-count'); await expect.poll(async () => { const text = (await countEl.textContent()) ?? ''; const m = text.match(/(\d+)/); return m ? parseInt(m[1], 10) : -1; }, { timeout: 15_000 }).toBeGreaterThan(0); await expect.poll(async () => page.evaluate(() => { const m = (window as any).__flightMap; if (!m || !m.getSource('planes')) return false; try { return m.querySourceFeatures('planes').some((f: { properties?: Record }) => f.properties?.id === 'plane-starter'); } catch { return false; // source not ready yet — keep polling } }), { timeout: 15_000 }, ).toBe(true); // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is // passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist. const pt = await page.evaluate(({ lon, lat }) => { const map = (window as any).__flightMap; const p = map.project([lon, lat]); const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }, POPUP_AIRPORT); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); await expect(popup).toContainText(POPUP_AIRPORT.icao); // The Relocate row: a select of grounded planes + the button (disabled until one is picked). const planeSel = page.getByTestId('airport-popup-plane'); await expect(planeSel).toBeVisible(); // Target THE PLANE OPTION specifically — locator('option') also matches the placeholder and // trips strict mode (CI run #51). await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/); const goBtn = page.getByTestId('airport-popup-relocate'); await expect(goBtn).toBeDisabled(); // placeholder selected await planeSel.selectOption('plane-starter'); await expect(goBtn).toBeEnabled(); await goBtn.click(); await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao; }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true); }); test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup await flyHomeViaApi(page); // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). await setFuelFull(page, 'plane-starter'); // ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via // the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the // shortest leg takes minutes of real time) — "still flying its original destination" below is // deterministic regardless of how fast the UI moves. const nearest = await nearestToHub(page); expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg await setScale(page, 1); const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } }); expect(flyRes.ok()).toBeTruthy(); await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === nearest.icao; }, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true); await page.goto('/'); // Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with // the plane in the planes source — the popup's Relocate row is built from that same latest snapshot. const countEl = page.getByTestId('airport-count'); await expect.poll(async () => { const text = (await countEl.textContent()) ?? ''; const m = text.match(/(\d+)/); return m ? parseInt(m[1], 10) : -1; }, { timeout: 15_000 }).toBeGreaterThan(0); await expect.poll(async () => page.evaluate(() => { const m = (window as any).__flightMap; if (!m || !m.getSource('planes')) return false; try { return m.querySourceFeatures('planes').some((f: { properties?: Record }) => f.properties?.id === 'plane-starter'); } catch { return false; // source not ready yet — keep polling } }), { timeout: 15_000 }, ).toBe(true); const pt = await page.evaluate(({ lon, lat }) => { const map = (window as any).__flightMap; const p = map.project([lon, lat]); const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }, POPUP_AIRPORT); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); await expect(popup).toContainText(POPUP_AIRPORT.icao); // The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it // is not grounded; while flying its option shows the DEPARTURE airport. const planeSel = page.getByTestId('airport-popup-plane'); await expect(planeSel).toBeVisible(); await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/); const goBtn = page.getByTestId('airport-popup-relocate'); await expect(goBtn).toBeDisabled(); // placeholder selected await planeSel.selectOption('plane-starter'); await expect(goBtn).toBeEnabled(); await goBtn.click(); // The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to // the flight queue while the plane keeps flying its original destination. await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao); }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true); });