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
CI / build + unit + e2e (pull_request) Successful in 13m33s
This commit is contained in:
+5
-2
@@ -9,8 +9,11 @@ import { expect } from '@playwright/test';
|
||||
|
||||
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||
|
||||
export async function mintPlayerSession(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session');
|
||||
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
|
||||
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
|
||||
// usual local player, exactly as before.
|
||||
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
|
||||
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user