import { test, expect } from '@playwright/test'; // ISS-129 (v0.57.0): /api/events pushes full state snapshots over SSE — the current snapshot on // connect, then one after every sim tick (~1 s; the background tick runs even while idle since // v0.56.0, so no scale hook is needed). Reached via the same base URL as playwright.config.ts // (E2E_BASE_URL, defaulting to the live instance) — node-side fetch, no browser involved. const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl'; interface StateBody { simState: { clockMs: number }; packages?: Array<{ status: string }>; } // ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history // (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame. const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']); test('GET /api/events streams state snapshots over SSE', async () => { const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.body).not.toBeNull(); const reader = res.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; const clocks: number[] = []; // simState.clockMs of each parsed snapshot event const statusesSeen = new Set(); // package statuses observed in any frame (ISS-133) let maxFrameBytes = 0; try { // Collect two snapshot events: the initial one, then at least one pushed by a background tick. const deadline = Date.now() + 15_000; while (clocks.length < 2 && Date.now() < deadline) { let chunk: ReadableStreamReadResult; try { chunk = await Promise.race([ reader.read(), new Promise((_, reject) => setTimeout(() => reject(new Error('SSE stream stalled')), Math.max(1, deadline - Date.now())), ), ]); } catch { break; // stalled or past the deadline — let the length assertion report it } if (chunk.done) break; // stream ended early — same buffer += decoder.decode(chunk.value, { stream: true }); let sep: number; while ((sep = buffer.indexOf('\n\n')) >= 0) { const frame = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); for (const line of frame.split('\n')) { if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping") const snap = JSON.parse(line.slice('data: '.length)) as StateBody; clocks.push(snap.simState.clockMs); for (const p of snap.packages ?? []) statusesSeen.add(p.status); maxFrameBytes = Math.max(maxFrameBytes, line.length); } } } } finally { await reader.cancel(); // close the stream → server unsubscribes } // The initial snapshot arrives on connect; the second comes from a background tick within ~1 s. // A grounded fleet freezes the clock (#135), so consecutive frames may carry the SAME clockMs — // non-decreasing is what proves real pushes, not replays (advance itself is pinned in sim.spec.ts). expect(clocks.length).toBeGreaterThanOrEqual(2); expect(clocks[1]).toBeGreaterThanOrEqual(clocks[0]); // ISS-133: no terminal package history in any frame (the DB keeps it forever; frames must not), // and a bounded frame size even on an instance with long-running history (~597 KB pre-v0.59.0). for (const status of statusesSeen) { expect(ALLOWED_PACKAGE_STATUSES.has(status)).toBe(true); } expect(maxFrameBytes).toBeLessThan(200_000); });