v1.7.0: multiplayer test coverage — player-scoped fixtures + N-player clock verification (ISS-116, ISS-113)
CI / build + unit + e2e (pull_request) Successful in 13m33s

This commit is contained in:
2026-09-26 20:26:10 +00:00
parent 43c4bb97a9
commit fe84d9a0bb
6 changed files with 408 additions and 3 deletions
+283
View File
@@ -0,0 +1,283 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { applySession, authenticate, mintPlayerSession } from './auth';
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
// player; player B is a second account minted through the same hook with a username override (the
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
//
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
interface PlaneState {
id: string;
status: string;
locationIcao: string;
maxFuel: number;
queue: string[];
ownerId: string;
ownerName: string;
}
interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number };
planes: PlaneState[];
players: PlayerSummary[];
}
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; retry briefly instead of throwing.
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);
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
async function nearestToHub(page: Page): Promise<string> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)[0].icao;
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const sessionId = await mintPlayerSession(page, PLAYER_B);
await applySession(page, sessionId);
return { ctx, page };
}
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
async function healStarterHome(page: Page): Promise<void> {
await clearQueueViaApi(page, STARTER);
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight starter to land during cleanup',
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
}
if (p.locationIcao !== HUB) {
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for the starter to land back at the hub during cleanup',
})
.toBe(HUB);
}
}
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
async function waitForGroundedFleet(page: Page): Promise<void> {
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
timeout: 120_000,
message: 'waiting for the fleet to be fully grounded',
})
.toBe('idle');
}
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
test.beforeEach(async ({ page }) => {
await authenticate(page); // player A — the seeded local player that owns plane-starter
});
test.afterEach(async ({ page }) => {
try {
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
await healStarterHome(page);
await setScale(page, WORLD);
} finally {
if (bCtx) await bCtx.close();
bCtx = null;
}
});
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
test.afterAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
// Verify through the same surface the game uses — mint a local session just for this read.
const sessionId = await mintPlayerSession(page);
await applySession(page, sessionId);
const s = (await getState(page)) as StateBody;
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
} finally {
await ctx.close();
}
});
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
const b = await mintPlayerB(browser);
bCtx = b.ctx;
await waitForGroundedFleet(page);
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
const dest = await nearestToHub(page);
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
await setFuelFull(page, STARTER);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(500);
const s = await getState(page); // tick-on-read — advances while any plane flies
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
const now = s.simState.clockMs;
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
prev = now;
}
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: "waiting for A's plane to land",
})
.toBe('available');
const frozen1 = await getState(page);
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
const frozen2 = await getState(page);
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
});
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
const b = await mintPlayerB(browser);
bCtx = b.ctx;
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
await waitForGroundedFleet(page);
const sA = await getState(page); // A's (local session's) view of the shared world
const sB = await getState(b.page); // B's view — same world…
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
const starter = sA.planes.find((p) => p.id === STARTER)!;
expect(starter.ownerId).toBe('local');
expect(starter.ownerName).toBe(LOCAL_USERNAME);
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
expect(bSummary, 'the players summary lists player B').toBeDefined();
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
expect(flyForeign.status()).toBe(403);
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
expect(sellForeign.status()).toBe(403);
// …and B cannot act on A's.
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA.status()).toBe(403);
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
// so nothing else can touch either economy between reads.
const cA = sA.account.cash;
const cB = sB.account.cash;
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
expect(grant.status()).toBe(200);
const granted = ((await grant.json()) as StateBody).account.cash;
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
const sBAfter = await getState(b.page);
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
for (const s of [sA, sBAfter]) {
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
}
});