mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 20:41:11 +00:00
[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
- AZ-471 CanvasEditor draw + 8-handle resize PASS (FT-P-39 fast + e2e + FT-P-40 8 sub-tests). Three drifts pinned via it.fails(): Ctrl+click multi-select (FT-P-41), Ctrl+wheel zoom-around-cursor (FT-P-42), Ctrl+drag empty-canvas pan (FT-P-43) — all rooted in handleMouseDown's early Ctrl-gate and handleWheel's pan-not-adjusted bug. - AZ-473 PhotoMode 3 ACs all PASS in fast + e2e (FT-P-48 switch filter, FT-P-49 auto-select, FT-P-50 yoloId wire across modes P=0/20/40 — outbound classNum == classId + photoModeOffset). - AZ-478 fast 7 + e2e 2: AC-1 user-visible offline indicator, AC-2 tainted-canvas fallback, AC-3 SSE disconnect banner — all drift today (it.fails fast + test.fail e2e + control PASS for each). Service-worker negative check passes. - AZ-479 AC-1 (bundle <= 2 MB gzipped) promoted from on-demand perf script to per-commit static profile via new STC-PERF01 row + static_check_bundle_size in run-tests.sh. AC-2 (mission-planner exclusion) already covered by STC-S5. AC-3 FCP /flights <= 3 s median (chromium suite-e2e) and AC-4 30-min annotation soak (RUN_LONG_RUNNING=1, chromium) scaffolded as e2e tests. Code review: PASS (0 findings). Fast: 25/25 files, 150 passed / 13 skipped. Static: 25/25 PASS (incl. new STC-PERF01). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from './msw/server'
|
||||
import { jsonResponse, paginate } from './msw/helpers'
|
||||
import {
|
||||
renderWithProviders,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from './helpers/render'
|
||||
import { seedBearer, clearBearer } from './helpers/auth'
|
||||
import { createSSE } from '../src/api/sse'
|
||||
import App from '../src/App'
|
||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
||||
import { FlightProvider, useFlight } from '../src/components/FlightContext'
|
||||
import { seedFlights } from './fixtures/seed_flights'
|
||||
import {
|
||||
AnnotationSource,
|
||||
AnnotationStatus,
|
||||
Affiliation,
|
||||
CombatReadiness,
|
||||
MediaType,
|
||||
MediaStatus,
|
||||
} from '../src/types'
|
||||
import type { Media, AnnotationListItem } from '../src/types'
|
||||
|
||||
// AZ-478 — network-resilience contracts.
|
||||
//
|
||||
// AC-1 (NFT-RES-03): all `/api/*` requests fail with network errors at boot.
|
||||
// The SPA must:
|
||||
// a) render an error state (not silently degrade), and
|
||||
// b) NOT register a service worker / offline cache.
|
||||
// Today (drift): the AuthProvider's refresh fails →
|
||||
// `user` stays null → ProtectedRoute redirects to /login;
|
||||
// the LoginPage form renders, NOT a network-error
|
||||
// indicator. (b) is enforced statically (STC-N3) AND
|
||||
// asserted at runtime here for defence in depth.
|
||||
// AC-2 (NFT-RES-09): annotation download via `canvas.toBlob` on a tainted
|
||||
// canvas throws SecurityError. The page must NOT crash;
|
||||
// a user-visible fallback (alternative download path or
|
||||
// an in-DOM error) must be rendered.
|
||||
// Today (drift): `AnnotationsPage.handleDownload` calls
|
||||
// `canvas.toBlob` without a try/catch — the SecurityError
|
||||
// escapes as an unhandled rejection from the async
|
||||
// handleDownload. No fallback UI is rendered.
|
||||
// AC-3 (NFT-RES-10): when an SSE EventSource fires `error` with
|
||||
// `readyState === 2` (CLOSED), within 2 s a
|
||||
// connection-lost indicator must appear in the DOM with
|
||||
// an i18n-keyed text.
|
||||
// Today (drift): `src/api/sse.ts` calls `onError?.(e)`
|
||||
// but no consumer renders any user-visible indicator,
|
||||
// and there is no `connection-lost` i18n key.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AC-1 — offline at boot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AZ-478 — AC-1 (NFT-RES-03): network offline at boot', () => {
|
||||
let originalServiceWorker: PropertyDescriptor | undefined
|
||||
let unhandledHandler: ((reason: unknown) => void) | null = null
|
||||
const swallowed: unknown[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
// Every /api/* request errors at the network layer (DNS/conn refused).
|
||||
// This drives AuthProvider's refresh down its `.catch` branch.
|
||||
server.use(
|
||||
http.all('/api/*', () => HttpResponse.error()),
|
||||
)
|
||||
|
||||
// Provide a minimal `navigator.serviceWorker` so we can assert
|
||||
// registrations stays empty. JSDOM has no SW by default.
|
||||
originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {
|
||||
getRegistrations: async () => [],
|
||||
register: vi.fn(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Catch the deliberate refresh failure so vitest doesn't error on the
|
||||
// unhandled rejection.
|
||||
swallowed.length = 0
|
||||
unhandledHandler = (reason: unknown) => {
|
||||
swallowed.push(reason)
|
||||
}
|
||||
process.on('unhandledRejection', unhandledHandler)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (unhandledHandler) {
|
||||
process.off('unhandledRejection', unhandledHandler)
|
||||
unhandledHandler = null
|
||||
}
|
||||
if (originalServiceWorker) {
|
||||
Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker)
|
||||
} else {
|
||||
// navigator.serviceWorker is non-configurable in some envs; deletion
|
||||
// may silently no-op — that's fine for cleanup.
|
||||
try {
|
||||
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('SPA does NOT register a service worker (defence in depth, also enforced statically as STC-N3)', async () => {
|
||||
// Arrange / Act — boot the app at "/".
|
||||
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
|
||||
|
||||
// Allow the AuthProvider's refresh promise to reject.
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// Assert — no SW was registered. STC-N3 already gates this at the source
|
||||
// tree, but the runtime check catches a future regression where the
|
||||
// registration is moved to a dynamically-imported module that grep
|
||||
// misses.
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
expect(regs).toEqual([])
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'SPA renders a user-visible network-error indicator when boot APIs are offline',
|
||||
async () => {
|
||||
// Drift: today the fall-through behaviour is "redirect to /login".
|
||||
// The LoginPage form renders; no error banner / offline indicator
|
||||
// exists. Spec requires an in-DOM indicator (e.g., role="alert" with
|
||||
// an i18n-keyed message such as "common.networkError").
|
||||
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
|
||||
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect(banner.textContent ?? '').toMatch(/offline|network|connection/i)
|
||||
},
|
||||
)
|
||||
|
||||
it('control: today the SPA falls through to /login (drift snapshot)', async () => {
|
||||
// Pins current behaviour. When AC-1 lands and the SPA shows a network
|
||||
// banner instead, this test becomes flaky — the redirect may not happen
|
||||
// — and the snapshot has to be updated alongside the AC fix.
|
||||
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
|
||||
|
||||
// The login form's i18n header text is "AZAION".
|
||||
await waitFor(() => expect(screen.getByText('AZAION')).toBeInTheDocument(), {
|
||||
timeout: 2000,
|
||||
})
|
||||
// The login form is rendered (Sign In submit button is the i18n key
|
||||
// login.submit). LoginPage doesn't wire htmlFor between <label> and
|
||||
// <input>, so getByLabelText doesn't resolve — match via the submit
|
||||
// button text instead.
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AC-2 — tainted-canvas fallback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLIGHT = seedFlights[0]
|
||||
|
||||
const imageMedia: Media = {
|
||||
id: 'media-az478',
|
||||
name: 'tainted-fixture.jpg',
|
||||
path: '/media/tainted-fixture.jpg',
|
||||
mediaType: MediaType.Image,
|
||||
mediaStatus: MediaStatus.New,
|
||||
duration: null,
|
||||
annotationCount: 1,
|
||||
waypointId: null,
|
||||
userId: 'user-az478',
|
||||
}
|
||||
|
||||
const seedAnnotation: AnnotationListItem = {
|
||||
id: 'ann-az478',
|
||||
mediaId: imageMedia.id,
|
||||
time: null,
|
||||
createdDate: '2026-05-11T00:00:00Z',
|
||||
userId: 'user-az478',
|
||||
source: AnnotationSource.Manual,
|
||||
status: AnnotationStatus.Created,
|
||||
isSplit: false,
|
||||
splitTile: null,
|
||||
detections: [
|
||||
{
|
||||
id: 'det-az478',
|
||||
classNum: 0,
|
||||
label: 'class-0',
|
||||
confidence: 0.9,
|
||||
affiliation: Affiliation.Hostile,
|
||||
combatReadiness: CombatReadiness.NotReady,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5,
|
||||
width: 0.1,
|
||||
height: 0.1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function rigDownloadEnv() {
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
|
||||
http.get('/api/flights/:id', ({ params }) => {
|
||||
const f = seedFlights.find((x) => x.id === params.id)
|
||||
return f ? jsonResponse(f) : new Response(null, { status: 404 })
|
||||
}),
|
||||
http.get('/api/annotations/settings/user', () =>
|
||||
jsonResponse({
|
||||
id: 'user-settings-az478',
|
||||
userId: 'user-az478',
|
||||
selectedFlightId: FLIGHT.id,
|
||||
annotationsLeftPanelWidth: null,
|
||||
annotationsRightPanelWidth: null,
|
||||
datasetLeftPanelWidth: null,
|
||||
datasetRightPanelWidth: null,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
||||
http.get('/api/annotations/media', () => jsonResponse(paginate([imageMedia], 1, 1000))),
|
||||
http.get('/api/annotations/annotations', () =>
|
||||
jsonResponse(paginate([seedAnnotation], 1, 1000)),
|
||||
),
|
||||
http.get('/api/annotations/classes', () => jsonResponse([])),
|
||||
http.get('/api/annotations/dataset/info', () =>
|
||||
jsonResponse({ totalCount: 0, statusCounts: {} }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const { selectFlight, selectedFlight } = useFlight()
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) selectFlight(FLIGHT)
|
||||
}, [selectFlight, selectedFlight])
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
describe('AZ-478 — AC-2 (NFT-RES-09): tainted-canvas annotation download fallback', () => {
|
||||
let originalToBlob: typeof HTMLCanvasElement.prototype.toBlob
|
||||
let originalImage: typeof globalThis.Image
|
||||
let toBlobCalls: number
|
||||
let unhandledHandler: ((reason: unknown) => void) | null = null
|
||||
const swallowed: unknown[] = []
|
||||
let originalCreateObjectURL: typeof URL.createObjectURL | undefined
|
||||
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
rigDownloadEnv()
|
||||
toBlobCalls = 0
|
||||
|
||||
// JSDOM lacks URL.createObjectURL / revokeObjectURL; AnnotationsPage's
|
||||
// download path uses both for the .txt blob that's emitted BEFORE the
|
||||
// canvas-to-PNG path. Patch the methods on the URL constructor directly
|
||||
// (see _docs/LESSONS.md "Don't replace URL via vi.stubGlobal('URL', ...)"
|
||||
// for why).
|
||||
originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
|
||||
.createObjectURL
|
||||
originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
|
||||
.revokeObjectURL
|
||||
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
|
||||
((blob: Blob) => `blob:az478-${(blob as { type?: string }).type ?? 'unknown'}`) as unknown as typeof URL.createObjectURL
|
||||
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
|
||||
(() => {/* noop */ }) as unknown as typeof URL.revokeObjectURL
|
||||
|
||||
// Stub `Image` so handleDownload's `await new Promise(res => { img.onload = res })`
|
||||
// resolves synchronously with a 640×480 frame. Production code requires
|
||||
// `naturalWidth` / `naturalHeight` to be populated for `drawImage` to fire.
|
||||
originalImage = globalThis.Image
|
||||
globalThis.Image = class FakeImage extends EventTarget {
|
||||
onload: ((e: Event) => unknown) | null = null
|
||||
onerror: ((e: Event) => unknown) | null = null
|
||||
crossOrigin: string | null = null
|
||||
naturalWidth = 640
|
||||
naturalHeight = 480
|
||||
private _src = ''
|
||||
get src(): string { return this._src }
|
||||
set src(v: string) {
|
||||
this._src = v
|
||||
// Fire onload on next microtask so the await Promise sees a resolution.
|
||||
queueMicrotask(() => this.onload?.(new Event('load')))
|
||||
}
|
||||
} as unknown as typeof globalThis.Image
|
||||
|
||||
// Make canvas.getContext return a working stub so handleDownload reaches
|
||||
// the `canvas.toBlob` line. JSDOM's default returns null, which would
|
||||
// short-circuit the function before the SecurityError path is exercised.
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(
|
||||
() =>
|
||||
({
|
||||
clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(),
|
||||
drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(),
|
||||
fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
|
||||
fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1,
|
||||
}) as unknown as CanvasRenderingContext2D,
|
||||
) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
||||
|
||||
// Force `toBlob` to throw SecurityError — this simulates the canvas
|
||||
// having been tainted by a cross-origin draw without CORS headers
|
||||
// (browsers throw `SecurityError` synchronously on `toBlob` /
|
||||
// `toDataURL` against a tainted canvas).
|
||||
originalToBlob = HTMLCanvasElement.prototype.toBlob
|
||||
HTMLCanvasElement.prototype.toBlob = function tainted(): void {
|
||||
toBlobCalls += 1
|
||||
throw new DOMException(
|
||||
'The canvas has been tainted by cross-origin data',
|
||||
'SecurityError',
|
||||
)
|
||||
}
|
||||
|
||||
// Capture the resulting unhandled rejection (production lacks try/catch
|
||||
// around toBlob — see AnnotationsPage.tsx:139). Without this, vitest
|
||||
// exits non-zero even when test assertions pass.
|
||||
swallowed.length = 0
|
||||
unhandledHandler = (reason: unknown) => {
|
||||
const msg = (reason instanceof Error ? reason.message : String(reason)) ?? ''
|
||||
if (/tainted|SecurityError/i.test(msg)) {
|
||||
swallowed.push(reason)
|
||||
return
|
||||
}
|
||||
// Re-throw anything we didn't expect.
|
||||
throw reason
|
||||
}
|
||||
process.on('unhandledRejection', unhandledHandler)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
if (unhandledHandler) {
|
||||
process.off('unhandledRejection', unhandledHandler)
|
||||
unhandledHandler = null
|
||||
}
|
||||
HTMLCanvasElement.prototype.toBlob = originalToBlob
|
||||
globalThis.Image = originalImage
|
||||
if (originalCreateObjectURL === undefined) {
|
||||
delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
|
||||
} else {
|
||||
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
|
||||
originalCreateObjectURL
|
||||
}
|
||||
if (originalRevokeObjectURL === undefined) {
|
||||
delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
|
||||
} else {
|
||||
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
|
||||
originalRevokeObjectURL
|
||||
}
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'tainted-canvas download surfaces an in-DOM fallback (alt download path or role="alert")',
|
||||
async () => {
|
||||
// Drift: today, `canvas.toBlob` throws and the error escapes the async
|
||||
// handleDownload. No alert / fallback link is rendered. Test passes
|
||||
// once production wires a try/catch around toBlob and renders either:
|
||||
// - an `<a download="...txt">` fallback, OR
|
||||
// - a `role="alert"` carrying an i18n-keyed message (e.g.,
|
||||
// "annotations.downloadTaintedCanvas").
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<FlightSeed>
|
||||
<AnnotationsPage />
|
||||
</FlightSeed>
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
|
||||
await userEvent.click(mediaItem)
|
||||
|
||||
// Wait for the annotation to render in the right sidebar, then click it
|
||||
// (only a selected annotation enables the download button).
|
||||
const annRow = await waitFor(() => {
|
||||
const rows = screen.getAllByText('—')
|
||||
if (rows.length === 0) throw new Error('annotation row not yet visible')
|
||||
return rows[0]
|
||||
})
|
||||
await userEvent.click(annRow)
|
||||
|
||||
const downloadBtn = await screen.findByTitle(/download/i)
|
||||
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
|
||||
await userEvent.click(downloadBtn)
|
||||
|
||||
// Assert — `toBlob` was hit (we reached the tainted-canvas branch).
|
||||
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
|
||||
|
||||
// Assert — fallback UI rendered.
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect(banner.textContent ?? '').toMatch(/download|tainted|cross.?origin/i)
|
||||
},
|
||||
)
|
||||
|
||||
it('control: page does NOT crash even though toBlob throws (drift snapshot)', async () => {
|
||||
// Pins the current behaviour: the SecurityError propagates as an
|
||||
// unhandled rejection, captured by our process listener; the React tree
|
||||
// stays mounted (the alternative — a thrown SecurityError taking down
|
||||
// the page — would be a critical regression and would surface as an
|
||||
// uncaught error in the test runner).
|
||||
renderWithProviders(
|
||||
<FlightProvider>
|
||||
<FlightSeed>
|
||||
<AnnotationsPage />
|
||||
</FlightSeed>
|
||||
</FlightProvider>,
|
||||
)
|
||||
|
||||
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
|
||||
await userEvent.click(mediaItem)
|
||||
const annRow = await waitFor(() => {
|
||||
const rows = screen.getAllByText('—')
|
||||
if (rows.length === 0) throw new Error('annotation row not yet visible')
|
||||
return rows[0]
|
||||
})
|
||||
await userEvent.click(annRow)
|
||||
const downloadBtn = await screen.findByTitle(/download/i)
|
||||
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
|
||||
await userEvent.click(downloadBtn)
|
||||
|
||||
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
|
||||
// Tree still mounted — the media list header (i18n key annotations.title
|
||||
// → "Annotations") is still present.
|
||||
await waitFor(() => {
|
||||
// Any element still under document.body proves the page didn't crash.
|
||||
expect(document.body.contains(mediaItem)).toBe(true)
|
||||
})
|
||||
// The unhandled-rejection listener captured exactly one SecurityError.
|
||||
expect(swallowed.length).toBeGreaterThan(0)
|
||||
expect(String(swallowed[0])).toMatch(/SecurityError|tainted/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AC-3 — SSE disconnect indicator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeES extends EventTarget {
|
||||
url: string
|
||||
readyState: 0 | 1 | 2
|
||||
close(): void
|
||||
fireError(): void
|
||||
}
|
||||
|
||||
let constructedEs: FakeES[] = []
|
||||
|
||||
function installFakeEventSource(): () => void {
|
||||
const origCtor = (globalThis as { EventSource?: typeof EventSource }).EventSource
|
||||
constructedEs = []
|
||||
|
||||
class FakeEventSource extends EventTarget {
|
||||
public url: string
|
||||
public readyState: 0 | 1 | 2 = 0
|
||||
public onmessage: ((e: MessageEvent) => void) | null = null
|
||||
public onerror: ((e: Event) => void) | null = null
|
||||
public onopen: ((e: Event) => void) | null = null
|
||||
|
||||
constructor(url: string) {
|
||||
super()
|
||||
this.url = url
|
||||
this.readyState = 1
|
||||
const fakeRef = this as unknown as FakeES
|
||||
fakeRef.fireError = () => {
|
||||
this.readyState = 2 // CLOSED
|
||||
const ev = new Event('error')
|
||||
// Production SSE wiring uses `source.onerror` directly (not addEventListener).
|
||||
this.onerror?.(ev)
|
||||
this.dispatchEvent(ev)
|
||||
}
|
||||
constructedEs.push(fakeRef)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 2
|
||||
}
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSED = 2
|
||||
}
|
||||
;(globalThis as { EventSource?: unknown }).EventSource = FakeEventSource as unknown as typeof EventSource
|
||||
|
||||
return () => {
|
||||
if (origCtor) {
|
||||
;(globalThis as { EventSource?: typeof EventSource }).EventSource = origCtor
|
||||
} else {
|
||||
delete (globalThis as { EventSource?: typeof EventSource }).EventSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal consumer mirroring `AnnotationsSidebar`'s production SSE pattern
|
||||
// (createSSE → onMessage → no error UI). This is the most direct boundary
|
||||
// for asserting "no connection-lost indicator is rendered today".
|
||||
function SseProbe(): React.ReactElement {
|
||||
const [errored, setErrored] = useState(false)
|
||||
useEffect(() => {
|
||||
return createSSE(
|
||||
'/api/annotations/annotations/events',
|
||||
() => { /* drop */ },
|
||||
() => setErrored(true),
|
||||
)
|
||||
}, [])
|
||||
// The probe deliberately renders only the error TEST hook — production
|
||||
// does NOT render any user-visible "connection-lost" banner today. The
|
||||
// AC-3 it.fails() asserts on a banner; this probe's `errored` flag
|
||||
// proves the SSE error path fired (control: spy hit), so the it.fails()
|
||||
// is failing on UI, not on event plumbing.
|
||||
return <div data-testid="sse-probe-errored">{errored ? 'errored' : 'open'}</div>
|
||||
}
|
||||
|
||||
describe('AZ-478 — AC-3 (NFT-RES-10): SSE disconnect surfaces a connection-lost indicator', () => {
|
||||
let restoreEs: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
restoreEs = installFakeEventSource()
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEs?.()
|
||||
restoreEs = null
|
||||
})
|
||||
|
||||
it.fails(
|
||||
'within 2s of SSE error+CLOSED, a user-visible connection-lost indicator with i18n-keyed text is rendered',
|
||||
async () => {
|
||||
// Drift: production has no consumer that maps `onError` → user UI.
|
||||
// `src/api/sse.ts` calls `onError?.(e)` and individual consumers (today
|
||||
// only AnnotationsSidebar / FlightsPage) ignore that callback. There is
|
||||
// no `connection-lost` i18n key (parity sweep returned 0 hits).
|
||||
// Test passes when production wires a `<ConnectionStatus>` (or any
|
||||
// component) that surfaces the disconnected state.
|
||||
renderWithProviders(<SseProbe />)
|
||||
|
||||
// Wait for the SSE to be constructed (production opens it on mount).
|
||||
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
|
||||
|
||||
// Trigger the disconnect — error fires with readyState=CLOSED.
|
||||
const es = constructedEs[0]
|
||||
es.fireError()
|
||||
|
||||
// Spec: indicator must appear within 2 s with an i18n-keyed text.
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||
expect(banner.textContent ?? '').toMatch(/connection|disconnect|offline/i)
|
||||
},
|
||||
)
|
||||
|
||||
it('control: SSE error path fires (probe records errored=true) but no banner is rendered today', async () => {
|
||||
// Pins the current behaviour: the SSE consumer correctly observes the
|
||||
// error/CLOSED transition (the probe's local state flips), but no DOM
|
||||
// node carries an i18n-keyed connection-lost message. Removing this
|
||||
// test alongside the AC-3 fix is the migration path.
|
||||
renderWithProviders(<SseProbe />)
|
||||
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
|
||||
const es = constructedEs[0]
|
||||
|
||||
// Sanity — before the error, the probe renders "open".
|
||||
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('open')
|
||||
|
||||
// Fire the disconnect, then yield React's rerender.
|
||||
fireEvent.error(window) // no-op event so the next microtask fires
|
||||
es.fireError()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('errored'),
|
||||
)
|
||||
|
||||
// Drift: no role="alert" exists.
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user