import { execSync } from 'node:child_process'; import path from 'node:path'; import { expect, test } from '@playwright/test'; import { jumpToFreshSystem } from './helpers'; // 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 space-wanderer-ci) instead of the default one. const repoRoot = path.join(__dirname, '..'); const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; // Mirrors the /api/system response shape (lib/game/save.ts) — kept local so the spec stays self-contained. interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] } interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); // Baseline: the app serves and reports its version. const resBefore = await page.request.get('/api/version'); expect(resBefore.status()).toBe(200); const before = (await resBefore.json()) as { version: string }; expect(before.version).toMatch(/^\d+\.\d+\.\d+$/); // Game-state baseline: jump once so there is real progress that must survive the restart. await jumpToFreshSystem(page); // Exploration baseline: scan one body so explored state + inventory must survive too. // (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.) let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState; for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) { await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState; } const targetPlanet = sysBefore.planets.find((p) => !p.explored)!; const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } }); expect(exploreRes.status()).toBe(200); const sysExplored = (await exploreRes.json()) as SystemState; // Crew baseline: the roster (starting crew + any finds so far) must survive too. const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded // Ship baseline: the systems (levels/capacities) + cargo hold must survive too. const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded // Tech baseline: the researched flags must survive too. const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded // Mission board baseline: contracts, states and progress must survive too. The GET tops the board // up first — a no-op here because every action leaves at least MIN_AVAILABLE AVAILABLE contracts. const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); // Wait for the app to come back serving. let after: { version: string } | undefined; await expect .poll(async () => { try { const res = await page.request.get('/api/version'); if (res.status() === 200) after = (await res.json()) as { version: string }; return !!after; } catch { return false; // connection refused / 502 while the container starts } }, { timeout: 120_000, message: 'waiting for /api/version after docker compose restart' }) .toBe(true); expect(after).toBeDefined(); expect(after!.version).toBe(before.version); // same build, not a re-seeded stack // Game state survived: same turn counter and current system as before the restart. const worldAfter = (await (await page.request.get('/api/world')).json()) as { turn: number; systems: { id: number; current: boolean }[] }; expect(worldAfter.turn).toBe(sysExplored.turn); expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId); // Exploration state survived too: the scanned body is still explored. const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState; expect(sysAfter.turn).toBe(sysExplored.turn); expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // Crew roster survived too: same members, traits and origins (no re-seed on restart). const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewAfter).toEqual(crewBefore); // Ship state survived too: same systems, levels/capacities and cargo hold. const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(shipAfter).toEqual(shipBefore); // Tech tree survived too: same researched flags (no re-seed on restart). const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techAfter).toEqual(techBefore); // Mission board survived too: same contracts, states and progress (no re-seed or re-top-up on restart). const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(missionsAfter).toEqual(missionsBefore); // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); });