import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { test, expect, type Page } from '@playwright/test'; import { authenticate } from './auth'; // v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. test.beforeEach(async ({ page }) => { await authenticate(page); }); const APP_URL = '/'; // Read at test time so the assertion tracks package.json automatically. const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string }; // Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx. const MIN_ZOOM = 0; // v0.70.0: lowered 3 → 0 so the whole earth is visible when fully zoomed out const MAX_ZOOM = 11; // LFRS Nantes Atlantique — the marker-click target of this file (and e2e/map-interactions.spec.ts, // e2e/relocation.spec.ts). The v0.70.0 default view ([0,0] z2) shows both Africa + Europe with // overlapping markers (nearest neighbour LFRB Brest is only ~13–19 px away at z2), so every click // test first frames LFRS at z5 — its nearest marker is then ~104 px away and the projected center // cannot hit a neighbouring circle. const LFRS: [number, number] = [-1.61073, 47.153198]; async function frameLfrs(page: Page): Promise { await page.evaluate((c) => (window as any).__flightMap.jumpTo({ center: c, zoom: 5 }), LFRS); // Wait until LFRS's marker is actually rendered at the new camera before clicking it. The // coordinate is passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope // values don't exist (same trap as e2e/relocation.spec.ts). await expect.poll(async () => page.evaluate((c) => { const map = (window as any).__flightMap; const p = map.project(c); return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record }>) .some((f) => f.properties?.icao === 'LFRS'); }, LFRS), { timeout: 15_000 }, ).toBe(true); } async function waitForAirportCount(page: Page): Promise { const countEl = page.getByTestId('airport-count'); await expect.poll(async () => { const text = (await countEl.textContent()) ?? ''; const m = text.match(/(\d+)/); return m ? parseInt(m[1], 10) : -1; }).toBeGreaterThan(0); const text = (await countEl.textContent()) ?? ''; return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10); } test('page loads with the map and world airport count', async ({ page }) => { await page.goto(APP_URL); await expect(page.getByTestId('map')).toBeVisible(); const count = await waitForAirportCount(page); expect(count).toBeGreaterThan(0); // v0.70.0 default view: null island [0,0] at zoom 2 (Africa + Europe both visible). The map // instance exists by the time the airport count renders — it is created before airports load. const view = await page.evaluate(() => { const m = (window as any).__flightMap; return { lng: m.getCenter().lng, lat: m.getCenter().lat, z: m.getZoom() }; }); expect(view.z).toBeCloseTo(2); expect(view.lng).toBeCloseTo(0); expect(view.lat).toBeCloseTo(0); }); test('header shows the app version sourced from package.json', async ({ page }) => { await page.goto(APP_URL); const header = page.getByTestId('app-header'); await expect(header).toContainText(`v${pkg.version}`); // ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md. await expect(page.getByTestId('app-version')).toHaveAttribute( 'href', 'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md', ); }); // v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button // (mirrors e2e/admin.spec.ts' logout assertion). test('top bar Log out drops back to the sign-in screen', async ({ page }) => { await page.goto(APP_URL); const header = page.getByTestId('app-header'); await expect(header).toBeVisible({ timeout: 30_000 }); await page.getByTestId('logout-btn').click(); await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 }); await expect(header).toHaveCount(0); }); test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => { await page.goto(APP_URL); await waitForAirportCount(page); // Wait until the map instance exists and airport circles are actually rendered // (the GeoJSON source is parsed in a worker, so features arrive after addSource). await expect.poll(async () => page.evaluate( () => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'], })?.length ?? 0, ), { timeout: 15_000 }, ).toBeGreaterThan(0); // Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why // the v0.70.0 default view needs this — then project it to screen space via the dev hook and click. await frameLfrs(page); const pt = await page.evaluate((c) => { const map = (window as any).__flightMap; const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5 const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }, LFRS); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); await expect(popup).toContainText('Nantes Atlantique Airport'); await expect(popup).toContainText('LFRS'); }); test('wheel zoom is clamped between min and max zoom', async ({ page }) => { // Zoom clamping is pure camera behavior, so block basemap tile fetches: it keeps // the headless main thread idle and makes the test fast and independent of // tile-server latency. Styles/fonts/sprites are self-hosted now — anything from // openfreemap.org is a tile request, so abort all of them. await page.route('**://*.openfreemap.org/**', (route) => route.abort()); await page.goto(APP_URL); const getZoom = () => page.evaluate(() => (window as any).__flightMap?.getZoom() ?? -1); // Wait until the map instance exists and its style is fully applied, so the // camera state is stable before we start wheeling. await expect.poll( () => page.evaluate(() => { const m = (window as any).__flightMap; return m && m.isStyleLoaded() ? m.getZoom() : -1; }), { timeout: 15_000 }, ).toBeGreaterThan(0); // Wheel events must land on the canvas. const box = (await page.getByTestId('map').boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); // Position the camera close to a limit so the wheeling under test has short // travel — headless tile rendering is slow across many zoom levels. const jumpTo = async (zoom: number) => { await page.evaluate((z) => (window as any).__flightMap.jumpTo({ zoom: z }), zoom); await expect.poll(getZoom, { timeout: 5_000 }).toBeCloseTo(zoom); }; // MapLibre consumes all accumulated wheel delta in a single render frame, capped // at one zoom level per frame — so keep feeding large deltas while polling until // the camera reaches (or passes) the limit; frames consume them as they run. const wheelToLimit = async (deltaY: number, atLimit: (z: number) => boolean) => { await expect.poll(async () => { if ((await getZoom()) === -1) return 'pending'; if (atLimit(await getZoom())) return 'done'; for (let i = 0; i < 10; i++) await page.mouse.wheel(0, deltaY); return 'pending'; }, { timeout: 20_000 }).toBe('done'); }; // Wait until the camera stops moving so we assert on the settled value, not a // mid-animation one (each wheel eases for ~200ms). const waitForStableZoom = async () => { let prev = await getZoom(); let stable = 0; await expect.poll(async () => { const z = await getZoom(); stable = Math.abs(z - prev) < 1e-9 ? stable + 1 : 0; prev = z; return stable >= 3 ? 'stable' : 'pending'; }, { timeout: 5_000 }).toBe('stable'); }; // Zoom in well past MAX_ZOOM with several large deltas. await jumpTo(MAX_ZOOM - 2); await wheelToLimit(-600, (z) => z >= MAX_ZOOM - 1e-9); await waitForStableZoom(); expect(await getZoom()).toBeCloseTo(MAX_ZOOM); // Zoom out to the effective minimum. MapLibre won't zoom out past the point where // the world no longer covers the viewport height (fit floor = log2(h/512)), so with // MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see // the whole Earth", which is exactly what min zoom should give. const mapBox = await page.getByTestId('map').boundingBox(); if (!mapBox) throw new Error('map bounding box unavailable'); const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512)); await jumpTo(minLimit + 1); await wheelToLimit(600, (z) => z <= minLimit + 1e-9); await waitForStableZoom(); const zoom = await getZoom(); expect(zoom).toBeCloseTo(minLimit); expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM); expect(zoom).toBeLessThanOrEqual(MAX_ZOOM); }); test('style switcher defaults to Fiord, switches styles, and keeps the airports layer', async ({ page }) => { // Styles/fonts/sprites are self-hosted; only tiles come from openfreemap — abort // them for speed (the GeoJSON airport layer renders without basemap tiles). await page.route('**://*.openfreemap.org/**', (route) => route.abort()); await page.goto(APP_URL); const select = page.getByTestId('style-switch'); await expect(select).toBeVisible(); await expect(select.locator('option')).toHaveCount(5); // Fiord is the default style. await expect(select).toHaveValue('fiord'); await waitForAirportCount(page); const renderedCircles = () => page.evaluate( () => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'], })?.length ?? 0, ); await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0); const layerIds = () => page.evaluate(() => (window as any).__flightMap?.getStyle()?.layers?.map((l: { id: string }) => l.id) ?? []); const before = await layerIds(); expect(before.length).toBeGreaterThan(0); // Switch to Dark. await select.selectOption('dark'); await expect(select).toHaveValue('dark'); // The applied style must actually change (each OFM style has a different layer set). await expect.poll(layerIds, { timeout: 15_000 }).not.toEqual(before); // Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every // source/layer not in the new style, so the airports layer must be re-established — // count still shown and markers rendered again. await expect(page.getByTestId('airport-count')).toContainText(/world airports/); await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0); // Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this. await frameLfrs(page); // Clicking a marker after the switch opens exactly one popup (the click handler is // re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our // custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so // retry the click until the popup opens instead of asserting on a single shot (the marker is // clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners). await expect.poll(async () => { const pt = await page.evaluate(() => { const map = (window as any).__flightMap; const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }); await page.mouse.click(pt.x, pt.y); return (await page.getByTestId('airport-popup').count()) > 0; }, { timeout: 15_000 }).toBe(true); const popups = page.getByTestId('airport-popup'); await expect(popups.first()).toBeVisible(); await expect(popups).toHaveCount(1); }); test('style choice persists across reloads via localStorage', async ({ page }) => { await page.route('**://*.openfreemap.org/**', (route) => route.abort()); await page.goto(APP_URL); const select = page.getByTestId('style-switch'); await expect(select).toHaveValue('fiord'); await select.selectOption('positron'); const stored = await page.evaluate(() => localStorage.getItem('flight-ops-style')); expect(stored).toBe('positron'); // Reload: the persisted choice is restored after mount (SSR-safe path — server and // first paint use the default, then the effect applies the saved style). await page.reload(); await expect(select).toHaveValue('positron'); }); test('falls back to remote OFM styles when the local asset pack is absent', async ({ page }) => { // Simulates a fresh clone without the generated asset pack (no volume mount): // the manifest probe gets a 404, so the app must load its style from // openfreemap.org. Only style fetches are allowed through; all other OFM traffic // (tiles/fonts/sprites) is aborted for speed — the GeoJSON airport layer needs none of it. // Routes match in reverse declaration order: manifest 404 > styles continue > abort-all. await page.route('**://*.openfreemap.org/**', (route) => route.abort()); await page.route('**://*.openfreemap.org/styles/**', (route) => route.continue()); await page.route('**/map-assets-manifest.json', (route) => route.fulfill({ status: 404 })); await page.goto(APP_URL); // The map must have been created from the REMOTE style — its glyphs still point at OFM. const glyphs = () => page.evaluate(() => (window as any).__flightMap?.getStyle()?.glyphs ?? ''); await expect.poll(glyphs, { timeout: 15_000 }).toContain('tiles.openfreemap.org'); // The app is fully usable without the pack: airport count renders… const count = await waitForAirportCount(page); expect(count).toBeGreaterThan(0); // …and markers are rendered and clickable. await expect.poll(async () => page.evaluate( () => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'], })?.length ?? 0, ), { timeout: 15_000 }, ).toBeGreaterThan(0); // Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this. await frameLfrs(page); const pt = await page.evaluate((c) => { const map = (window as any).__flightMap; const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5 const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }, LFRS); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); await expect(popup).toContainText('Nantes Atlantique Airport'); await expect(popup).toContainText('LFRS'); });