85 lines
3.7 KiB
TypeScript
85 lines
3.7 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 (docker-compose.ci.yml) 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; timeScale: number; paused: boolean };
|
||
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');
|
||
expect(typeof body.simState.timeScale).toBe('number');
|
||
expect(body.simState.paused).toBe(false);
|
||
|
||
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', '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);
|
||
|
||
const resBefore = await page.request.get('/api/state');
|
||
expect(resBefore.status()).toBe(200);
|
||
const before = (await resBefore.json()) as StateBody;
|
||
|
||
execSync('docker compose 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.
|
||
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one
|
||
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the
|
||
// post-restart clock may be ahead — but never by more than that bound.
|
||
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
|
||
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
|
||
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
|
||
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
|
||
);
|
||
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());
|
||
});
|