fix(e2e): task-10 review fixes — Nth tick, retry-safe preambles, queue-DTO shape (#44)
CI / build + unit + e2e (pull_request) Failing after 34m10s
CI / build + unit + e2e (pull_request) Failing after 34m10s
This commit is contained in:
@@ -72,3 +72,18 @@ export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
|||||||
expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
|
expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
|
||||||
return after;
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tick the save's task queue until it is empty (free skips are legal 200s; every tick decrements
|
||||||
|
// every ACTIVE row and PENDING rows auto-start, so a non-empty queue always shrinks — the bound
|
||||||
|
// only fails a pathological state). Every spec that queues actions starts from this on retry:
|
||||||
|
// the CI DB persists across attempts, and a failed one may have left a row behind — an in-transit
|
||||||
|
// JUMP would 409 every new queue (lib/game/tasks.ts's in-transit rule).
|
||||||
|
export async function drainQueue(page: Page): Promise<void> {
|
||||||
|
for (let ticks = 0; ; ticks++) {
|
||||||
|
const body = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[]; tasks?: { id: number }[] } | CrewDto[];
|
||||||
|
const tasks = Array.isArray(body) ? [] : (body.tasks ?? []);
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
expect(ticks).toBeLessThan(60);
|
||||||
|
expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-15
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { jumpToFreshSystem, resolveAction } from './helpers';
|
import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers';
|
||||||
|
|
||||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||||
// Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING).
|
// Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING).
|
||||||
@@ -172,6 +172,7 @@ test('world map shows the generated starting system and its neighbours', async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = before.systems.find((s) => s.current)!.id;
|
const currentId = before.systems.find((s) => s.current)!.id;
|
||||||
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
@@ -199,6 +200,7 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = before.systems.find((s) => s.current)!.id;
|
const currentId = before.systems.find((s) => s.current)!.id;
|
||||||
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
@@ -247,6 +249,7 @@ test('local system shows the current system bodies with staggered moons and expl
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
|
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
const target = before.planets.find((p) => !p.explored)!;
|
const target = before.planets.find((p) => !p.explored)!;
|
||||||
|
|
||||||
@@ -280,6 +283,7 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
|
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
|
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
|
||||||
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
|
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
|
||||||
const found = new Map<string, number>(); // kind|name -> qty
|
const found = new Map<string, number>(); // kind|name -> qty
|
||||||
@@ -338,6 +342,7 @@ test('CREW tab renders real roster cards, each showing both traits and an origin
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
|
test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
||||||
// a build costs three parts. Every scan/jump is queued with a default crew and ticked to
|
// a build costs three parts. Every scan/jump is queued with a default crew and ticked to
|
||||||
// completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping
|
// completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping
|
||||||
@@ -433,6 +438,7 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
|
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
// Research costs two parts, building WEAPONS three — scan until the hold has five. Every
|
// Research costs two parts, building WEAPONS three — scan until the hold has five. Every
|
||||||
// scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on
|
// scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on
|
||||||
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
|
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
|
||||||
@@ -508,6 +514,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => {
|
test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
// Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable;
|
// Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable;
|
||||||
// jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED.
|
// jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED.
|
||||||
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
|
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
|
||||||
@@ -601,6 +608,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||||
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
|
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
|
||||||
|
|
||||||
@@ -684,6 +692,7 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
||||||
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
|
// 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.
|
// message), never on absolute counts — earlier attempts leave their events behind.
|
||||||
// The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED
|
// The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED
|
||||||
@@ -748,18 +757,6 @@ const tickOnce = async (page: Page) => {
|
|||||||
expect((await page.request.post('/api/turn')).status()).toBe(200);
|
expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tick the save's task queue until it is empty. Free skips are legal 200s and every tick
|
|
||||||
// decrements every ACTIVE row (PENDING rows auto-start), so a non-empty queue always shrinks —
|
|
||||||
// the bound only fails a pathological state, not a slow one.
|
|
||||||
async function drainQueue(page: Page): Promise<void> {
|
|
||||||
for (let ticks = 0; ; ticks++) {
|
|
||||||
const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
|
||||||
if (tasks.length === 0) return;
|
|
||||||
expect(ticks).toBeLessThan(60);
|
|
||||||
await tickOnce(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => {
|
test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => {
|
||||||
await drainQueue(page);
|
await drainQueue(page);
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
|
||||||
@@ -799,13 +796,16 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow
|
|||||||
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
|
||||||
expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams
|
expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams
|
||||||
|
|
||||||
// The explore's body: an unexplored planet in the current system (jump first if it is exhausted).
|
// The explore's body: an unexplored planet in the current system. A jump's fallback target can
|
||||||
|
// be a fully-explored system (jumpToFreshSystem prefers undiscovered, not unexplored), so loop
|
||||||
|
// like the other preambles instead of a single if.
|
||||||
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
|
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
|
||||||
if (!sys.planets.some((p) => !p.explored)) {
|
for (let guard = 0; !sys.planets.some((p) => !p.explored) && guard < 10; guard++) {
|
||||||
await jumpToFreshSystem(page);
|
await jumpToFreshSystem(page);
|
||||||
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
|
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
|
||||||
}
|
}
|
||||||
const planet = sys.planets.find((p) => !p.explored)!;
|
const planet = sys.planets.find((p) => !p.explored)!;
|
||||||
|
expect(planet).toBeDefined(); // the world keeps producing fresh unscanned bodies
|
||||||
|
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
|
||||||
const currentId = world.systems.find((s) => s.current)!.id;
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
@@ -874,6 +874,9 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow
|
|||||||
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k);
|
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k);
|
||||||
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k);
|
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k);
|
||||||
}
|
}
|
||||||
|
// The Nth tick resolves both — the loop above covered ticks 1..N-1 only (mirrors how the other
|
||||||
|
// scenarios end on their resolving tick).
|
||||||
|
await tickOnce(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both resolved at their own durations: the jump landed, each event is stamped on its task's
|
// Both resolved at their own durations: the jump landed, each event is stamped on its task's
|
||||||
|
|||||||
+9
-4
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { jumpToFreshSystem, resolveAction } from './helpers';
|
import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers';
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// 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
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
@@ -13,9 +13,10 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
|||||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
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 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 CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
// Mirrors the task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks}
|
// Mirrors the QueueDto of lib/game/save.ts — GET /api/crew is the {crew, tasks} object (Task 6 /
|
||||||
// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered.
|
// JC-7): the queue's display shape (resolved names, no raw payload), ACTIVE block first, then
|
||||||
interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }
|
// 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 CrewState { crew: CrewDto[]; tasks: TaskDto[] }
|
||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
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 ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||||
@@ -26,6 +27,10 @@ interface EventDto { id: number; turn: number; type: string; message: string; cr
|
|||||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
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.
|
// Baseline: the app serves and reports its version.
|
||||||
const resBefore = await page.request.get('/api/version');
|
const resBefore = await page.request.get('/api/version');
|
||||||
expect(resBefore.status()).toBe(200);
|
expect(resBefore.status()).toBe(200);
|
||||||
|
|||||||
Reference in New Issue
Block a user