` fallback, OR
// - a `role="alert"` carrying an i18n-keyed message (e.g.,
// "annotations.downloadTaintedCanvas").
renderWithProviders(
,
)
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(
,
)
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 {errored ? 'errored' : 'open'}
}
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.post('/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 `` (or any
// component) that surfaces the disconnected state.
renderWithProviders()
// 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()
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()
})
})