import { test, expect, type Page } from '@playwright/test'; // v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // The panes render from the shell's single /api/state poll (lib/useGameState.ts). const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only) const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const STARTER = 'plane-starter'; interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock planes: Array>; packages: Array>; } 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; } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. 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); } async function setHealth(page: Page, planeId: string, fields: Record): Promise { const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } }); expect(res.status()).toBe(200); } // Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock // advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative // cash assertions; deactivateWorld restores full health and no gate afterwards. async function activateWorld(page: Page): Promise { await setHealth(page, STARTER, { health: 50 }); expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200); } // Shorten the repair gate to "now" — the next advancing tick flips the plane back to available. async function deactivateWorld(page: Page): Promise { await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 }); await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', { timeout: 30_000, message: 'waiting for the starter to flip back to available', }) .toBe('available'); } // Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and // reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight, // and repairPlane 409s on non-available planes, which would break activateWorld. async function flyHomeViaApi(page: Page): Promise { // 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: 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 plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string }; 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. await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available'); s = await getState(page); plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string }; } 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/${STARTER}/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 === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB); } } // Leave the live instance in a sane state for the other specs (the fixed world rate) — same // pattern as e2e/sim.spec.ts. test.afterEach(async ({ page }) => { await setScale(page, WORLD); }); test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => { await page.goto('/'); await expect(page.getByTestId('left-pane')).toBeVisible(); // Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands. const cashEl = page.getByTestId('wallet-cash'); await expect(cashEl).toBeVisible({ timeout: 15_000 }); const cashText = (await cashEl.textContent()) ?? ''; expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0); // Reputation present and numeric. await expect(page.getByTestId('reputation')).toBeVisible(); expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0); // Fleet: the starter ATR 72-600 — location asserted against /api/state truth, because a // human player (or an earlier spec) may have moved it anywhere by now. const row = page.getByTestId('fleet-list').locator('.plane-row').first(); await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 }); await expect(row).toContainText('ATR 72-600'); const truth = (await getState(page)).planes.find((p) => p.id === 'plane-starter') as unknown as { status: string; locationName: string; destName: string | null; }; if (truth.status === 'flying' && truth.destName) { await expect(row).toContainText(`${truth.locationName} → ${truth.destName}`); // in-flight leg } else { await expect(row).toContainText(truth.locationName); } // v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t). const loadout = page.getByTestId('plane-loadout-plane-starter'); await expect(loadout).toBeVisible(); expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/); // Sim clock renders (UTC wall-clock of the game time). await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); // ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes, // per-plane cargo rows now render inside the fleet rows. await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0); // ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a // Refuel heading or row anymore. await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0); // ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane. await expect(page.getByTestId('pause-toggle')).toHaveCount(0); await expect(page.getByTestId('time-scale-slider')).toHaveCount(0); }); test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling // Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane // 409s on non-available planes — it must be grounded before activateWorld. await flyHomeViaApi(page); await setScale(page, WORLD); // self-heal may have left the fast test rate behind // Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances. await activateWorld(page); try { await page.goto('/'); const clock = page.getByTestId('sim-clock'); await expect(clock).toBeVisible({ timeout: 15_000 }); // "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). const sample = async () => { const text = ((await clock.textContent()) ?? '').trim(); expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/); return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z')); }; // Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds, // so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only // rendering a sample pair could straddle zero snapshot updates and show no change at all. const t0 = await sample(); await new Promise((r) => setTimeout(r, 1_300)); const t1 = await sample(); await new Promise((r) => setTimeout(r, 1_300)); const t2 = await sample(); expect(t1).toBeGreaterThan(t0); expect(t2).toBeGreaterThan(t1); // Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while // still catching a frozen or wildly mis-scaled clock. const total = t2 - t0; expect(total).toBeGreaterThanOrEqual(50_000); expect(total).toBeLessThanOrEqual(200_000); } finally { await deactivateWorld(page); } }); test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => { await page.goto('/'); const right = page.getByTestId('right-pane'); await expect(right).toBeVisible(); await expect(right).toContainText('Jobs & Market'); // v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active. await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('tab-planes-market')).toBeVisible(); await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true'); });