import { execSync } from 'node:child_process'; import path from 'node:path'; import { test, expect } from '@playwright/test'; // Repo root — docker-compose.yml lives here; `docker compose restart` runs on // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the // isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one. const repoRoot = path.join(__dirname, '..'); const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock planes: Array>; packages: Array>; } test('GET /api/state returns a seeded snapshot', async ({ page }) => { const res = await page.request.get('/api/state'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); const body = (await res.json()) as StateBody; expect(typeof body.account.cash).toBe('number'); expect(typeof body.account.reputation).toBe('number'); expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only expect(Array.isArray(body.planes)).toBe(true); expect(body.planes.length).toBeGreaterThanOrEqual(1); for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) { expect(body.planes[0], `plane.${key}`).toHaveProperty(key); } expect(Array.isArray(body.packages)).toBe(true); expect(body.packages.length).toBeGreaterThan(0); for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) { expect(body.packages[0], `package.${key}`).toHaveProperty(key); } }); test('state survives a container restart (sim clock + account persisted)', async ({ page }) => { test.setTimeout(180_000); // Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the // post-restart snapshot must match byte-for-byte instead of within one tick bound. await expect .poll(async () => { const res = await page.request.get('/api/state'); if (res.status() !== 200) return 'down'; const body = (await res.json()) as StateBody; return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy'; }, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' }) .toBe('idle'); const resBefore = await page.request.get('/api/state'); expect(resBefore.status()).toBe(200); const before = (await resBefore.json()) as StateBody; execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' }); // Wait for the app to come back, capturing the post-restart snapshot. let after: StateBody | undefined; await expect .poll(async () => { try { const res = await page.request.get('/api/state'); if (res.status() === 200) after = (await res.json()) as StateBody; return !!after; } catch { return false; // connection refused / 502 while the container starts } }, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' }) .toBe(true); expect(after).toBeDefined(); const a = after!; // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock // persisted across sessions"): account and entity ids are byte-identical. With the fleet // grounded above, idle-pause keeps the clock frozen across the restart — exact equality. expect(a.simState.clockMs).toBe(before.simState.clockMs); expect(a.account.cash).toBe(before.account.cash); expect(a.account.reputation).toBe(before.account.reputation); expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort()); }); test('queued legs survive a container restart (v0.46.0)', async ({ page }) => { test.setTimeout(180_000); // Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an // interrupted run may not — wait it out before staging a queue (queueLeg works while flying too). await expect .poll(async () => { const res = await page.request.get('/api/state'); if (res.status() !== 200) return 'down'; const body = (await res.json()) as StateBody; return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing'); }, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' }) .toBe('available'); // Stage a two-leg queue on the starter… const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } }); expect(q1.status()).toBe(200); const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } }); expect(q2.status()).toBe(200); execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' }); // Wait for the app to come back, capturing the post-restart snapshot. let after: StateBody | undefined; await expect .poll(async () => { try { const res = await page.request.get('/api/state'); if (res.status() === 200) after = (await res.json()) as StateBody; return !!after; } catch { return false; // connection refused / 502 while the container starts } }, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' }) .toBe(true); const p = after!.planes.find((x) => x.id === 'plane-starter')!; expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order… // Cleanup: cancel both legs so the starter is left with an empty queue. for (let i = 0; i < 2; i++) { const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(c.status()).toBe(200); } });