test(e2e): event log entries + restart persistence

smoke: jump (shared helper) + scan, then assert both events are in
GET /api/events with the newer one first, and that the EVENT LOG terminal
renders both T<turn> // <MESSAGE> lines. Idempotent across retries — asserts
on this run's own entries by turn/message, never absolute counts. state: the
latest five log lines come back identical (ids included) after the mid-run
container restart.
This commit is contained in:
2026-09-29 02:47:36 +00:00
parent 29e8ac6909
commit 0b435a583b
2 changed files with 45 additions and 0 deletions
+35
View File
@@ -13,6 +13,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
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 }
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
@@ -362,6 +363,40 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
});
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
// message), never on absolute counts — earlier attempts leave their events behind.
const afterJump = await jumpToFreshSystem(page);
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
const jumpTurn = afterJump.turn;
// Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first.
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
if (!sys.planets.some((p) => !p.explored)) {
await jumpToFreshSystem(page); // fresh systems always have unscanned bodies
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
}
const planet = sys.planets.find((p) => !p.explored)!;
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
expect(exploreRes.status()).toBe(200);
const explored = (await exploreRes.json()) as ExploreResult;
// API: this run's jump + scan are in the log, and the newer one (the scan) comes first.
const events = (await (await page.request.get('/api/events')).json()) as EventDto[];
const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore');
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`);
expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump
expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased).
await page.goto('/');
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
const panel = page.getByRole('tabpanel');
await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible();
await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible();
});
test('GET /api/version reports the app version as JSON', async ({ page }) => {
const res = await page.request.get('/api/version');
expect(res.status()).toBe(200);
+10
View File
@@ -17,6 +17,7 @@ interface ShipSystemDto { id: number; type: string; level: number; capacity: num
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);
@@ -59,6 +60,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
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' });
@@ -105,6 +111,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
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();