[AZ-456] Test infrastructure: Vitest + MSW + Playwright + scripts

Scaffolds the Blackbox test project per AZ-456 / environment.md across
the three profiles:

- fast  : Vitest 3.x + jsdom + MSW 2.x + RTL/jest-dom; tests/setup.ts
          boots the MSW Node server with onUnhandledRequest:'error',
          afterEach resets handlers, clears bearer + navigate-to-login
          spy. Default handlers ship for every suite service plus OWM
          and tile stand-ins. Fixtures mirror seed_* in test-data.md.
- e2e   : Playwright ^1.49 with chromium + firefox projects against the
          suite docker-compose stack; owm-stub + tile-stub Bun servers,
          playwright-runner image, seeds.sql for the test-db.
- static: scripts/run-tests.sh extended — tsc --noEmit (test config),
          vite build, ripgrep checks (with grep -r fallback), CSV
          report at test-output/static-report.csv per AC-7 columns.

Smoke tests cover AC-3, AC-4 (fast, 5 tests, PASS) and AC-1, AC-2,
AC-5, AC-8 (e2e, gated by Risk 4 docker availability). Static profile
(13 checks) PASS — STC-SEC1 (no literal OWM key) lifted from
QUARANTINE per AZ-447 with a narrowed pattern.

Files:
  +24 tests/**, +10 e2e/**, +vitest.config.ts, +tsconfig.test.json
  ~package.json (test scripts + devDeps for vitest, @testing-library/*,
   msw, @playwright/test, jsdom, @types/node, @vitest/coverage-v8)
  ~scripts/run-tests.sh, scripts/run-performance-tests.sh — switched
   RESULTS_DIR to test-output/, compose path to project-local
  ~.gitignore — added /test-output/

Verification:
  bun run test:fast        → 11 / 11 PASS
  ./scripts/run-tests.sh   → static 13/13 + fast 11/11 PASS, exit 0

Tracker: AZ-456 → In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:57:04 +03:00
parent e5d9276b19
commit 38eb87fb08
45 changed files with 2377 additions and 157 deletions
+79
View File
@@ -0,0 +1,79 @@
import { http } from 'msw'
import { jsonResponse, noContent, paginate } from '../helpers'
import { seedUsers, opAlice } from '../../fixtures/seed_users'
import { seedClasses } from '../../fixtures/seed_classes'
// Default `/api/admin/*` handlers — auth round-trip, users, classes-write,
// system settings. Tests override per-scenario via `server.use(...)`.
const SEED_BEARER = 'test-bearer-default'
export const adminHandlers = [
http.post('/api/admin/auth/login', async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as { email?: string; password?: string }
const user = seedUsers.find((u) => u.email === body.email) ?? opAlice
return new Response(JSON.stringify({ token: SEED_BEARER, user }), {
status: 200,
headers: {
'Content-Type': 'application/json',
// AC-03 contract — refresh cookie is HttpOnly + Secure + SameSite=Strict.
'Set-Cookie': 'refreshToken=test-refresh; HttpOnly; Secure; SameSite=Strict; Path=/api/admin/auth',
},
})
}),
http.post('/api/admin/auth/refresh', () => {
return jsonResponse({ token: SEED_BEARER })
}),
http.post('/api/admin/auth/logout', () => noContent()),
http.get('/api/admin/users/me', () => jsonResponse(opAlice)),
http.get('/api/admin/users', () => jsonResponse(paginate(seedUsers))),
http.get('/api/admin/users/:id', ({ params }) => {
const user = seedUsers.find((u) => u.id === params.id)
if (!user) return new Response(null, { status: 404 })
return jsonResponse(user)
}),
http.get('/api/admin/classes', () => jsonResponse(seedClasses)),
http.post('/api/admin/classes', async ({ request }) => {
const body = await request.json()
return jsonResponse(body, { status: 201 })
}),
http.put('/api/admin/classes/:id', async ({ request }) => {
const body = await request.json()
return jsonResponse(body)
}),
http.delete('/api/admin/classes/:id', () => noContent()),
http.get('/api/admin/settings', () =>
jsonResponse({
id: 'sys-settings-1',
name: 'Test System',
militaryUnit: null,
defaultCameraWidth: 1920,
defaultCameraFoV: 60,
thumbnailWidth: 256,
thumbnailHeight: 256,
thumbnailBorder: 2,
generateAnnotatedImage: true,
silentDetection: false,
}),
),
http.put('/api/admin/settings', async ({ request }) => {
const body = await request.json()
return jsonResponse(body)
}),
// Test-only suite endpoint — gated behind a non-production build flag in the
// real `admin/` service. The fast-profile MSW just returns 204 so isolation
// helpers can call it uniformly with the e2e profile.
http.post('/api/admin/test-only/reset', () => noContent()),
]
+90
View File
@@ -0,0 +1,90 @@
import { http } from 'msw'
import { jsonResponse, noContent, paginate, sse } from '../helpers'
import { seedMedia } from '../../fixtures/seed_media'
import { seedAnnotations } from '../../fixtures/seed_annotations'
import { seedUserSettings } from '../../fixtures/seed_user_settings'
// Default `/api/annotations/*` handlers — media list, annotation CRUD, dataset,
// status SSE. The annotation status SSE returns a small canned event sequence
// so dataset / annotations tests don't have to register their own stream just
// to mount a component.
export const annotationsHandlers = [
http.get('/api/annotations/media', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('pageSize') ?? String(seedMedia.length))
return jsonResponse(paginate(seedMedia, page, pageSize))
}),
http.get('/api/annotations/media/:id', ({ params }) => {
const m = seedMedia.find((x) => x.id === params.id)
if (!m) return new Response(null, { status: 404 })
return jsonResponse(m)
}),
http.get('/api/annotations/media/:id/annotations', ({ params }) =>
jsonResponse(seedAnnotations.filter((a) => a.mediaId === params.id)),
),
http.get('/api/annotations', () => jsonResponse(seedAnnotations)),
http.post('/api/annotations', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'ann-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
}),
http.patch('/api/annotations/:id/status', async ({ request, params }) => {
const body = (await request.json()) as { status?: number }
return jsonResponse({ id: params.id, status: body.status ?? 10 })
}),
http.delete('/api/annotations/:id', () => noContent()),
http.get('/api/annotations/dataset', () =>
jsonResponse(
seedAnnotations.map((a) => ({
annotationId: a.id,
imageName: `image-${a.mediaId}.jpg`,
thumbnailPath: `/thumbs/${a.mediaId}.jpg`,
status: a.status,
createdDate: a.createdDate,
createdEmail: 'op_alice@test.local',
flightName: 'Flight 1',
source: a.source,
isSeed: false,
isSplit: a.isSplit,
})),
),
),
http.post('/api/annotations/dataset/bulk-status', async ({ request }) => {
const body = (await request.json()) as { ids?: string[]; status?: number }
return jsonResponse({ updated: body.ids?.length ?? 0, status: body.status ?? 30 })
}),
http.get('/api/annotations/dataset/distribution', () =>
jsonResponse([
{ classNum: 0, label: 'class-0', color: '#ff0000', count: 12 },
{ classNum: 1, label: 'class-1', color: '#00ff00', count: 7 },
]),
),
http.get('/api/annotations/status', () =>
sse([
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 20 }, id: '1' },
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 30 }, id: '2' },
]),
),
http.get('/api/annotations/users/:userId/settings', ({ params }) => {
const s = seedUserSettings.find((x) => x.userId === params.userId)
if (!s) return new Response(null, { status: 404 })
return jsonResponse(s)
}),
http.put('/api/annotations/users/:userId/settings', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'user-settings-1', userId: params.userId, ...body })
}),
]
+25
View File
@@ -0,0 +1,25 @@
import { http } from 'msw'
import { jsonResponse } from '../helpers'
// Default `/api/detect/*` handlers — sync image detect.
export const detectHandlers = [
http.post('/api/detect/image', () =>
jsonResponse({
detections: [
{
id: 'det-1',
classNum: 0,
label: 'class-0',
confidence: 0.92,
affiliation: 20,
combatReadiness: 1,
centerX: 0.5,
centerY: 0.5,
width: 0.1,
height: 0.1,
},
],
}),
),
]
+63
View File
@@ -0,0 +1,63 @@
import { http } from 'msw'
import { jsonResponse, noContent, sse } from '../helpers'
import { seedFlights } from '../../fixtures/seed_flights'
import { seedAircraft } from '../../fixtures/seed_aircraft'
// Default `/api/flights/*` handlers. Live-GPS SSE returns a deterministic
// 3-event stream so AC-08 timing assertions have something to drive even
// without per-test overrides.
export const flightsHandlers = [
http.get('/api/flights', () => jsonResponse(seedFlights)),
http.get('/api/flights/:id', ({ params }) => {
const flight = seedFlights.find((f) => f.id === params.id)
if (!flight) return new Response(null, { status: 404 })
return jsonResponse(flight)
}),
http.post('/api/flights', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'flight-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
}),
http.put('/api/flights/:id', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: params.id, ...body })
}),
http.delete('/api/flights/:id', () => noContent()),
http.get('/api/flights/:id/waypoints', ({ params }) =>
jsonResponse([
{
id: 'wp-1',
flightId: params.id,
name: 'WP1',
latitude: 50.45,
longitude: 30.52,
order: 1,
},
]),
),
http.post('/api/flights/:id/waypoints', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'wp-new', flightId: params.id, ...body }, { status: 201 })
}),
http.get('/api/flights/:id/live-gps', ({ params }) =>
sse([
{ event: 'gps', data: { flightId: params.id, lat: 50.45, lon: 30.52, t: 0 }, id: '1' },
{ event: 'gps', data: { flightId: params.id, lat: 50.46, lon: 30.53, t: 1000 }, id: '2' },
{ event: 'gps', data: { flightId: params.id, lat: 50.47, lon: 30.54, t: 2000 }, id: '3' },
]),
),
http.get('/api/flights/aircraft', () => jsonResponse(seedAircraft)),
http.post('/api/flights/aircraft', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
}),
]
+33
View File
@@ -0,0 +1,33 @@
import { adminHandlers } from './admin'
import { flightsHandlers } from './flights'
import { annotationsHandlers } from './annotations'
import { detectHandlers } from './detect'
import { loaderHandlers } from './loader'
import { resourceHandlers } from './resource'
import { owmHandlers } from './owm'
import { tilesHandlers } from './tiles'
// Default-handler registration order is irrelevant (MSW matches by request shape),
// but grouping the exports here gives test files a single import surface for
// the seeded baseline. Per-test overrides land via `server.use(...)`.
export const defaultHandlers = [
...adminHandlers,
...flightsHandlers,
...annotationsHandlers,
...detectHandlers,
...loaderHandlers,
...resourceHandlers,
...owmHandlers,
...tilesHandlers,
]
export {
adminHandlers,
flightsHandlers,
annotationsHandlers,
detectHandlers,
loaderHandlers,
resourceHandlers,
owmHandlers,
tilesHandlers,
}
+16
View File
@@ -0,0 +1,16 @@
import { http } from 'msw'
import { jsonResponse, noContent } from '../helpers'
// Default `/api/loader/*` handlers. The loader service brokers media uploads;
// AC-10 (≤ 500 MB cap) is asserted in tests by overriding the POST handler
// with a 413 stub.
export const loaderHandlers = [
http.post('/api/loader/upload', () => jsonResponse({ id: 'media-uploaded-1', status: 1 }, { status: 201 })),
http.get('/api/loader/jobs/:id', ({ params }) =>
jsonResponse({ id: params.id, status: 'completed', progress: 1.0 }),
),
http.delete('/api/loader/jobs/:id', () => noContent()),
]
+24
View File
@@ -0,0 +1,24 @@
import { http } from 'msw'
import { jsonResponse } from '../helpers'
// OpenWeatherMap stand-in for the fast profile. The e2e profile uses
// `e2e/stubs/owm/` (Docker). Both must return the same shape so tests
// targeting the wind-compute path (E10) behave identically.
export const owmHandlers = [
// The production code is expected to call OWM through a configurable base URL
// (default = api.openweathermap.org); the route-abort guard in the e2e
// profile blocks that host, but tests under fast hit the path-only form so
// MSW intercepts.
http.get('https://api.openweathermap.org/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
http.get('http://owm-stub:8081/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
http.get('/owm/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
]
+27
View File
@@ -0,0 +1,27 @@
import { http, HttpResponse } from 'msw'
import { jsonResponse } from '../helpers'
// Default `/api/resource/*` handlers — image / thumbnail / video binary serving.
// Returns a tiny PNG stub for any image request so layout tests can mount
// without 404 noise.
const ONE_PX_PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
])
export const resourceHandlers = [
http.get('/api/resource/images/:name', () =>
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
),
http.get('/api/resource/thumbnails/:name', () =>
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
),
http.get('/api/resource/videos/:name', () =>
jsonResponse({ url: '/api/resource/videos/stub.mp4' }),
),
]
+26
View File
@@ -0,0 +1,26 @@
import { http, HttpResponse } from 'msw'
// OSM/Esri tile stand-in for the fast profile. Returns a tiny transparent
// PNG so `<img>` / Leaflet tile loads succeed in jsdom without exiting the
// process.
const TILE_PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
])
const tile = () => new HttpResponse(TILE_PNG, { headers: { 'Content-Type': 'image/png' } })
export const tilesHandlers = [
// OSM XYZ scheme: {z}/{x}/{y}
http.get('https://*.tile.openstreetmap.org/:z/:x/:y.png', tile),
http.get('https://tile.openstreetmap.org/:z/:x/:y.png', tile),
// Esri ArcGIS satellite scheme: {z}/{y}/{x}
http.get('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/:z/:y/:x', tile),
// Local tile-stub aliases (e2e parity)
http.get('http://tile-stub:8082/:z/:x/:y.png', tile),
http.get('http://tile-stub:8082/sat/:z/:y/:x', tile),
http.get('/tiles/:z/:x/:y.png', tile),
]
+57
View File
@@ -0,0 +1,57 @@
import { HttpResponse, delay } from 'msw'
/** Small, opinionated wrappers over MSW's response helpers used across handlers + tests. */
export function jsonResponse<T>(body: T, init?: ResponseInit) {
return HttpResponse.json(body as object, init)
}
export function errorResponse(status: number, message: string) {
return HttpResponse.json({ error: message, status }, { status })
}
export function noContent() {
return new HttpResponse(null, { status: 204 })
}
export function paginate<T>(items: T[], page = 1, pageSize = items.length) {
const start = (page - 1) * pageSize
const slice = items.slice(start, start + pageSize)
return { items: slice, totalCount: items.length, page, pageSize }
}
/** Inject latency into a handler. Use as: `await latency(50)` inside the resolver. */
export function latency(ms: number) {
return delay(ms)
}
/** Build a Server-Sent-Events (SSE) `text/event-stream` body from a sequence of payloads. */
export function sse(events: Array<{ event?: string; data: unknown; id?: string }>) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const e of events) {
const lines: string[] = []
if (e.id !== undefined) lines.push(`id: ${e.id}`)
if (e.event !== undefined) lines.push(`event: ${e.event}`)
const payload = typeof e.data === 'string' ? e.data : JSON.stringify(e.data)
for (const line of payload.split('\n')) lines.push(`data: ${line}`)
lines.push('', '')
controller.enqueue(encoder.encode(lines.join('\n')))
}
controller.close()
},
})
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
/** Drop the connection mid-flight (used by resilience tests). */
export function dropResponse() {
return HttpResponse.error()
}
+7
View File
@@ -0,0 +1,7 @@
import { setupServer } from 'msw/node'
import { defaultHandlers } from './handlers'
// Node-side MSW server shared by every fast-profile test. The Service-Worker
// runtime (`msw/browser`) is intentionally NOT imported anywhere under tests/;
// see AZ-456 Risk 1 — mixing the two silently bypasses MSW.
export const server = setupServer(...defaultHandlers)