213 lines
11 KiB
TypeScript
213 lines
11 KiB
TypeScript
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');
|
||
});
|