import { test, expect } from '@playwright/test' // AZ-458 — e2e variants of the SSE-lifecycle and bearer-rotation scenarios // that require the real suite stack (live-GPS simulator embedded in the // `flights/:test` image; annotation-status generator in `annotations/:test`). // // FT-P-09 — annotation-status SSE opens on mount // (QUARANTINE — production AnnotationsPage opens no SSE today) // FT-P-18 — live-GPS SSE opens within 5 s of flight select // NFT-PERF-03 — bearer-rotation reconnect ≤ 5 s after a refresh // NFT-RES-02 — bearer rotation reconnects both live-GPS and annotation-status // within 5 s (QUARANTINE for annotation-status; live-GPS portion // documents the AC-2 drift — passes once production reconnects // the EventSource on token rotation). // // Profile: e2e (gated by docker compose). Skipped in fast/host runs. // // Black-box discipline: assertions inspect the network surface (which // `text/event-stream` requests opened/closed and when) and the DOM where // live-GPS values land. The tests do NOT import production modules. const ALICE_EMAIL = 'op_alice@test.local' const ALICE_PASSWORD = 'TestPassword!23' async function login(page: import('@playwright/test').Page) { await page.goto('/login') await page.getByLabel(/email/i).fill(ALICE_EMAIL) await page.getByLabel(/password/i).fill(ALICE_PASSWORD) await Promise.all([ page.waitForResponse( (r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST', ), page.getByRole('button', { name: /sign in/i }).click(), ]) } test.describe('AZ-458 e2e — SSE lifecycle + bearer rotation', () => { test('FT-P-18 / NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select', async ({ page }) => { test.setTimeout(20_000) await login(page) await page.goto('/flights') // Switch the side panel to GPS mode and select a flight. await page.getByRole('button', { name: /gps/i }).click() const ssePromise = page.waitForRequest( (req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()), { timeout: 5_000 }, ) await page.getByRole('button', { name: /select flight/i }).click() await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click() const req = await ssePromise // Assert AC-1: bearer is in the URL per ADR-008 (?access_token=...). expect(req.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/) }) test('FT-P-19 / NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect', async ({ page }) => { test.setTimeout(20_000) await login(page) await page.goto('/flights') await page.getByRole('button', { name: /gps/i }).click() await page.getByRole('button', { name: /select flight/i }).click() await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click() // Capture the EventSource on the page so the test can observe close(). const closedAt = await page.evaluate(async () => { const original = window.EventSource let lastClosed = -1 const proxy = new Proxy(original, { construct(target, args) { const inst = new target(...(args as ConstructorParameters)) const origClose = inst.close.bind(inst) inst.close = () => { lastClosed = performance.now() origClose() } return inst }, }) window.EventSource = proxy as unknown as typeof EventSource return new Promise((resolve) => { // Wait up to 5 s for the close to land. const start = performance.now() const tick = () => { if (lastClosed > 0) resolve(lastClosed - start) else if (performance.now() - start > 5000) resolve(-1) else requestAnimationFrame(tick) } // Trigger the deselect from the test side via DOM. const evt = new CustomEvent('e2e-deselect') window.dispatchEvent(evt) tick() }) }) // Simulate "deselect" — for the contract test we go back to the params // tab which makes the FlightsPage useEffect tear down the SSE (per // FlightsPage.tsx:65-68 — the effect deps include `mode`). await page.getByRole('button', { name: /params/i }).click() expect(closedAt, 'EventSource close() should fire within 1 s of deselect').toBeLessThan(1000) }) test('NFT-PERF-03 / NFT-RES-02 — live-GPS SSE reconnects with the new bearer within 5 s of rotation (AC-2 drift)', async ({ page }) => { test.setTimeout(30_000) test.fail( true, 'AC-2 drift: FlightsPage useEffect deps do not include the bearer, so SSE does not reconnect on token rotation. Test passes once the production effect re-runs on token change.', ) await login(page) await page.goto('/flights') await page.getByRole('button', { name: /gps/i }).click() await page.getByRole('button', { name: /select flight/i }).click() await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click() const firstReq = await page.waitForRequest( (req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()), { timeout: 5_000 }, ) const firstUrl = firstReq.url() // Trigger a refresh via the test-only endpoint that rotates the bearer. // The admin/:test image exposes /api/admin/test-only/rotate-bearer (matches // the convention used by /api/admin/test-only/reset). If absent, this is // the moment to surface a stack-side gap. const rotated = await page.request.post('/api/admin/test-only/rotate-bearer').catch(() => null) expect(rotated?.ok(), 'admin/:test must expose /test-only/rotate-bearer').toBeTruthy() // Drive AuthContext to absorb the new bearer (refresh path). await page.evaluate(async () => { await fetch('/api/admin/auth/refresh', { credentials: 'include' }) }) const secondReq = await page.waitForRequest( (req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()) && req.url() !== firstUrl, { timeout: 5_000 }, ) expect(secondReq.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/) expect(secondReq.url()).not.toEqual(firstUrl) }) test.skip('FT-P-09 / NFT-PERF-06 — annotation-status SSE opens on mount + closes within 1 s of unmount', () => { // QUARANTINE: src/features/annotations/AnnotationsPage.tsx does not open // any SSE today. Once an annotation-status subscription is added, this // test follows the same shape as FT-P-18/FT-P-19 above but targets // /api/annotations/.../status (or whatever the production URL ends up // being). Leaving the assertion shape here as a planning anchor: // // await login(page) // const annSsePromise = page.waitForRequest( // (req) => /\/api\/annotations\/.*\/status/.test(req.url()), // ) // await page.goto('/annotations') // await annSsePromise }) })