Files
Flight-Ops/e2e/sim.spec.ts
T
kamilkrzywda d2defe4cd3
CI / build + unit + e2e (pull_request) Successful in 14m31s
v0.69.0: remove job ignore feature
2026-09-23 22:01:39 +00:00

217 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it
// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one
// pool, at EDDF.
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
// market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
const STARTER = 'plane-starter';
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
// instantly fail any expect.poll callback, so retry briefly instead.
for (let attempt = 1; ; attempt++) {
const res = await page.request.get('/api/state');
if (res.status() === 200) return (await res.json()) as StateBody;
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
await page.waitForTimeout(500);
}
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
const s = await getState(page);
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const c0 = s.simState.clockMs;
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
const after = await getState(page);
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
});
test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
.toBe('idle');
const before = await getState(page);
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const clockBefore = before.simState.clockMs;
// Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
// only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
// reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
// its removal, so the market can never empty; cap the passes to bound runtime either way (a
// zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
// board comes back on the very next tick.
for (let pass = 0; pass < 3; pass++) {
const s = await getState(page);
const open = s.packages.filter((p) => p.status === 'open');
if (open.length === 0) break; // drained — the pre-fix soft-lock state
for (const p of open) {
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
}
}
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
// pool right away — within ~15 s of real time, with headroom for dind slowness…
let topped: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.some((p) => p.status === 'open')) topped = body;
return !!topped;
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
.toBe(true);
const after = topped!;
expect(after.packages.filter((p) => p.status === 'open').length).toBeGreaterThan(0); // starvation fixed…
expect(after.planes.every((p) => p.status === 'available'), 'no plane may have taken off during the drain').toBe(true); // …with no plane flying…
expect(after.simState.clockMs, 'the grounded clock must stay exactly frozen while the market refills').toBe(clockBefore); // …and the world still frozen
});
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
await activateWorld(page);
try {
await setScale(page, SCALE);
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
const now = (await getState(page)).simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
} finally {
await deactivateWorld(page);
}
});
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
// scale or clock activation needed.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; status: string };
expect(moved.id).toBe(victim!.id);
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
// Expired jobs leave the market…
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
.toBe(true);
// …and the spawner refills the pool back up — never below its target.
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
});
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
// The hook ages the job before its final tick, so it never even appears open in /api/state.
expect(c.status).toBe('expired');
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
.toBe(true);
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
});
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
expect(ok.status()).toBe(200);
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
const res = await page.request.post('/api/test/set-timescale', { data: body });
expect(res.status()).toBe(400);
}
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
expect(notJson.status()).toBe(400);
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
expect(empty.status()).toBe(400);
});