134 lines
8.3 KiB
TypeScript
134 lines
8.3 KiB
TypeScript
import { execSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { expect, test } from '@playwright/test';
|
|
import { drainQueue, jumpToFreshSystem, resolveAction } 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 }
|
|
// Mirrors the QueueDto of lib/game/save.ts — GET /api/crew is the {crew, tasks} object (Task 6 /
|
|
// JC-7): the queue's display shape (resolved names, no raw payload), ACTIVE block first, then
|
|
// PENDING, DONE filtered.
|
|
interface TaskDto { id: number; type: string; targetLabel: string; leadName: string; helperNames: string[]; remaining: number; status: string; leadCrewId: number; helperCrewIds: number[] }
|
|
interface CrewState { crew: CrewDto[]; tasks: TaskDto[] }
|
|
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 }
|
|
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
|
|
|
|
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
// Retry-safety: a failed earlier attempt may have left a row queued — an in-transit JUMP would
|
|
// 409 every new queue (lib/game/tasks.ts's in-transit rule), so drain before the baselines.
|
|
await drainQueue(page);
|
|
|
|
// 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)!;
|
|
// The explore POST only queues — no turn bump, explored not yet flipped — so tick it to
|
|
// completion and re-derive the baseline from the post-tick /api/system snapshot: the turn it
|
|
// pins (compared after the restart) must be the resolved one, not the queue's.
|
|
const sysExplored = (await resolveAction(page, '/api/explore', { planetId: targetPlanet.id })) as SystemState;
|
|
expect(sysExplored.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // the scan resolved before the baseline is pinned
|
|
|
|
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
|
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
|
expect(crewBefore.crew.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
|
|
|
|
// Event log baseline: append-only, and nothing writes between this snapshot and the restart — so the
|
|
// latest lines must come back byte-identical (ids included) after it.
|
|
const eventsBefore = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[];
|
|
expect(eventsBefore.length).toBeGreaterThan(0);
|
|
|
|
// 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). The
|
|
// {crew, tasks} object shape (JC-7) means the still-queued task rows survive with it too.
|
|
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
|
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);
|
|
|
|
// Event log survived too: same latest lines, in the same order.
|
|
const eventsAfter = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[];
|
|
expect(eventsAfter).toEqual(eventsBefore);
|
|
|
|
// The page itself serves again with the landing title.
|
|
await page.goto('/');
|
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
|
});
|