Files
SpaceWanderer/e2e/state.spec.ts
T

103 lines
5.9 KiB
TypeScript

import { execSync } from 'node:child_process';
import path from 'node:path';
import { expect, test } 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 space-wanderer-ci) instead of the default one.
const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
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 }
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.
const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = worldBefore.systems.find((s) => s.current)!.id;
const edge = worldBefore.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
expect(jumpRes.status()).toBe(200);
// 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++) {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const curId = world.systems.find((s) => s.current)!.id;
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
const tId = e[0] === curId ? e[1] : e[0];
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
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
// 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 WorldState;
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);
// The page itself serves again with the landing title.
await page.goto('/');
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
});