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
|
||||
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 { 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.
|
||||
// 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 }) => {
|
||||
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 currentId = before.systems.find((s) => s.current)!.id;
|
||||
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 }) => {
|
||||
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 currentId = before.systems.find((s) => s.current)!.id;
|
||||
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 }) => {
|
||||
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 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 }) => {
|
||||
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
|
||||
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
|
||||
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 }) => {
|
||||
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),
|
||||
// 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
|
||||
@@ -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 }) => {
|
||||
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
|
||||
// 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.
|
||||
@@ -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 }) => {
|
||||
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;
|
||||
// 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 }[];
|
||||
@@ -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 }) => {
|
||||
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[];
|
||||
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 }) => {
|
||||
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
|
||||
// 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
|
||||
@@ -748,18 +757,6 @@ const tickOnce = async (page: Page) => {
|
||||
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 }) => {
|
||||
await drainQueue(page);
|
||||
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[] };
|
||||
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;
|
||||
if (!sys.planets.some((p) => !p.explored)) {
|
||||
for (let guard = 0; !sys.planets.some((p) => !p.explored) && guard < 10; guard++) {
|
||||
await jumpToFreshSystem(page);
|
||||
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
|
||||
}
|
||||
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 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 === '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
|
||||
|
||||
+9
-4
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
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
|
||||
// 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 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 task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks}
|
||||
// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered.
|
||||
interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }
|
||||
// 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 }
|
||||
@@ -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.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);
|
||||
|
||||
Reference in New Issue
Block a user