Files
Flight-Ops/e2e/panes.spec.ts
2026-09-22 18:37:06 +00:00

213 lines
11 KiB
TypeScript
Raw Permalink 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> {
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
// instantly fail any expect.poll callback, so retry briefly instead.
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);
}
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);
}
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// repair so the clock runs at FULL scale for tests that need fast-forwarding. 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
// A grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in repair for full scale.
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');
});
test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => {
await page.goto('/');
// App Router file convention: app/icon.svg is auto-served and injected into the head.
const link = page.locator('head link[rel~="icon"]');
await expect(link).toHaveCount(1);
// Production builds append a content-hash query string to the auto-injected icon href.
expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/);
const res = await page.request.get('/icon.svg');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('image/svg+xml');
});