Files
Flight-Ops/e2e/panes.spec.ts
T
2026-09-22 14:00:53 +00:00

195 lines
10 KiB
TypeScript
Raw 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.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
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<Record<string, unknown>>;
packages: Array<Record<string, unknown>>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// 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);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. 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) as { status: string } | undefined)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
// and repairPlane 409s on non-available planes, which would break activateWorld.
async function flyHomeViaApi(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before acting on the starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
}
if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
}
}
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
// pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('left-pane')).toBeVisible();
// Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands.
const cashEl = page.getByTestId('wallet-cash');
await expect(cashEl).toBeVisible({ timeout: 15_000 });
const cashText = (await cashEl.textContent()) ?? '';
expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0);
// Reputation present and numeric.
await expect(page.getByTestId('reputation')).toBeVisible();
expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
// Fleet: the starter ATR 72-600 — location asserted against /api/state truth, because a
// human player (or an earlier spec) may have moved it anywhere by now.
const row = page.getByTestId('fleet-list').locator('.plane-row').first();
await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
await expect(row).toContainText('ATR 72-600');
const truth = (await getState(page)).planes.find((p) => p.id === 'plane-starter') as unknown as {
status: string;
locationName: string;
destName: string | null;
};
if (truth.status === 'flying' && truth.destName) {
await expect(row).toContainText(`${truth.locationName} → ${truth.destName}`); // in-flight leg
} else {
await expect(row).toContainText(truth.locationName);
}
// v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible();
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead, so neither pane has a
// Refuel heading or row anymore.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
});
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
// Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
// 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
await activateWorld(page);
try {
await page.goto('/');
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
const sample = async () => {
const text = ((await clock.textContent()) ?? '').trim();
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
const t0 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t1 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
expect(t1).toBeGreaterThan(t0);
expect(t2).toBeGreaterThan(t1);
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
// still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
expect(total).toBeGreaterThanOrEqual(50_000);
expect(total).toBeLessThanOrEqual(200_000);
} finally {
await deactivateWorld(page);
}
});
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market');
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
});