CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
import { execSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
// 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 flight-ci) instead of the default one.
|
|
const repoRoot = path.join(__dirname, '..');
|
|
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
|
|
|
interface StateBody {
|
|
account: { cash: number; reputation: number };
|
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
|
planes: Array<Record<string, unknown>>;
|
|
packages: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|
const res = await page.request.get('/api/state');
|
|
expect(res.status()).toBe(200);
|
|
expect(res.headers()['content-type']).toContain('application/json');
|
|
const body = (await res.json()) as StateBody;
|
|
|
|
expect(typeof body.account.cash).toBe('number');
|
|
expect(typeof body.account.reputation).toBe('number');
|
|
|
|
expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
|
|
|
|
expect(Array.isArray(body.planes)).toBe(true);
|
|
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
|
|
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
|
|
}
|
|
|
|
expect(Array.isArray(body.packages)).toBe(true);
|
|
expect(body.packages.length).toBeGreaterThan(0);
|
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
|
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
|
|
}
|
|
});
|
|
|
|
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
|
|
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
|
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 resBefore = await page.request.get('/api/state');
|
|
expect(resBefore.status()).toBe(200);
|
|
const before = (await resBefore.json()) as StateBody;
|
|
|
|
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
|
|
|
// Wait for the app to come back, capturing the post-restart snapshot.
|
|
let after: StateBody | undefined;
|
|
await expect
|
|
.poll(async () => {
|
|
try {
|
|
const res = await page.request.get('/api/state');
|
|
if (res.status() === 200) after = (await res.json()) as StateBody;
|
|
return !!after;
|
|
} catch {
|
|
return false; // connection refused / 502 while the container starts
|
|
}
|
|
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
|
.toBe(true);
|
|
|
|
expect(after).toBeDefined();
|
|
const a = after!;
|
|
|
|
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
|
|
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
|
|
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
|
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
|
expect(a.account.cash).toBe(before.account.cash);
|
|
expect(a.account.reputation).toBe(before.account.reputation);
|
|
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
|
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
|
});
|
|
|
|
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
|
|
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
|
|
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 String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
|
|
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
|
|
.toBe('available');
|
|
|
|
// Stage a two-leg queue on the starter…
|
|
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
|
|
expect(q1.status()).toBe(200);
|
|
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
|
|
expect(q2.status()).toBe(200);
|
|
|
|
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
|
|
|
// Wait for the app to come back, capturing the post-restart snapshot.
|
|
let after: StateBody | undefined;
|
|
await expect
|
|
.poll(async () => {
|
|
try {
|
|
const res = await page.request.get('/api/state');
|
|
if (res.status() === 200) after = (await res.json()) as StateBody;
|
|
return !!after;
|
|
} catch {
|
|
return false; // connection refused / 502 while the container starts
|
|
}
|
|
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
|
.toBe(true);
|
|
|
|
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
|
|
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
|
|
|
|
// Cleanup: cancel both legs so the starter is left with an empty queue.
|
|
for (let i = 0; i < 2; i++) {
|
|
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
|
expect(c.status()).toBe(200);
|
|
}
|
|
});
|