[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
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:
Oleksandr Bezdieniezhnykh
2026-05-11 05:58:55 +03:00
parent 73e2cfb1eb
commit cdebfccada
16 changed files with 2422 additions and 1 deletions
+575
View File
@@ -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()
})
})