[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
+139
View File
@@ -0,0 +1,139 @@
# Suite-level e2e harness for Azaion UI (AZ-456 / _docs/02_document/tests/environment.md).
#
# The parent suite repo publishes the four required service images under the
# `:test` tag (admin, flights, annotations, detect). The auxiliary services
# (loader, resource, gps-denied-*, autopilot) are wired here as best-effort
# soft dependencies — Playwright tests that exercise them will be skipped if
# the registry hasn't published a `:test` tag yet (Risk 4 in AZ-456).
#
# Network: `azaion-test-net` is isolated; the only outbound endpoints are the
# two stubs (`owm-stub`, `tile-stub`). External hosts are blocked at the
# Playwright route layer (AC-08).
services:
test-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: azaion
POSTGRES_PASSWORD: azaion
POSTGRES_DB: azaion
volumes:
- test-db-data:/var/lib/postgresql/data
- ./fixtures/seeds.sql:/docker-entrypoint-initdb.d/seeds.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U azaion -d azaion"]
interval: 5s
timeout: 3s
retries: 10
networks: [azaion-test-net]
admin:
image: azaion/admin:test
environment:
DB_CONNECTION: "Host=test-db;Database=azaion;Username=azaion;Password=azaion"
ENABLE_TEST_ONLY_ENDPOINTS: "true"
depends_on:
test-db:
condition: service_healthy
networks: [azaion-test-net]
flights:
image: azaion/flights:test
environment:
DB_CONNECTION: "Host=test-db;Database=azaion;Username=azaion;Password=azaion"
ENABLE_LIVE_GPS_SIMULATOR: "true"
depends_on:
test-db:
condition: service_healthy
networks: [azaion-test-net]
annotations:
image: azaion/annotations:test
environment:
DB_CONNECTION: "Host=test-db;Database=azaion;Username=azaion;Password=azaion"
ENABLE_STATUS_EVENT_GENERATOR: "true"
depends_on:
test-db:
condition: service_healthy
networks: [azaion-test-net]
detect:
image: azaion/detect:test
networks: [azaion-test-net]
loader:
image: azaion/loader:test
networks: [azaion-test-net]
resource:
image: azaion/resource:test
networks: [azaion-test-net]
owm-stub:
build: ./stubs/owm
ports:
- "8081"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/health"]
interval: 5s
timeout: 3s
retries: 5
networks: [azaion-test-net]
tile-stub:
build: ./stubs/tile
ports:
- "8082"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/health"]
interval: 5s
timeout: 3s
retries: 5
networks: [azaion-test-net]
azaion-ui:
build:
context: ..
dockerfile: Dockerfile
environment:
VITE_API_BASE_URL: "/api"
VITE_OWM_BASE_URL: "http://owm-stub:8081"
VITE_TILE_BASE_URL: "http://tile-stub:8082"
depends_on:
admin: { condition: service_started }
flights: { condition: service_started }
annotations: { condition: service_started }
detect: { condition: service_started }
owm-stub: { condition: service_healthy }
tile-stub: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 5s
timeout: 3s
retries: 10
networks: [azaion-test-net]
playwright-runner:
build: ./runner
depends_on:
azaion-ui:
condition: service_healthy
owm-stub:
condition: service_healthy
tile-stub:
condition: service_healthy
environment:
PLAYWRIGHT_BASE_URL: "http://azaion-ui:80"
PLAYWRIGHT_OUTPUT_DIR: "/output/e2e"
volumes:
- ../test-output:/output
- ..:/workspace:ro
working_dir: /workspace
networks: [azaion-test-net]
networks:
azaion-test-net:
driver: bridge
volumes:
test-db-data: {}
+93
View File
@@ -0,0 +1,93 @@
-- AZ-456 seed fixtures for the suite-e2e docker-compose stack.
--
-- The parent suite repo owns the canonical schema (../_docs/00_database_schema.md);
-- this file ONLY inserts seed rows the SPA tests need to read. Schema migrations
-- ship with each suite service's `:test` image and run before this script.
--
-- Layout mirrors `tests/fixtures/seed_*.ts` so fast and e2e profiles agree on
-- IDs / names / numeric enum values.
BEGIN;
-- Users (per seed_users.ts) -------------------------------------------------
INSERT INTO users (id, name, email, password_hash, role, is_active) VALUES
('user-alice', 'Alice Operator', 'op_alice@test.local', '$argon2id$v=19$m=65536,t=3,p=4$test$test', 'Operator', true),
('user-bob', 'Bob Operator', 'op_bob@test.local', '$argon2id$v=19$m=65536,t=3,p=4$test$test', 'Operator', true),
('user-carol', 'Carol Admin', 'admin_carol@test.local', '$argon2id$v=19$m=65536,t=3,p=4$test$test', 'Admin', true),
('user-dave', 'Dave Integrator', 'integrator_dave@test.local', '$argon2id$v=19$m=65536,t=3,p=4$test$test', 'SystemIntegrator', true)
ON CONFLICT (id) DO NOTHING;
-- Aircraft (per seed_aircraft.ts) -------------------------------------------
INSERT INTO aircraft (id, model, type, is_default) VALUES
('aircraft-1', 'Bayraktar TB2', 'Plane', true),
('aircraft-2', 'DJI Mavic 3', 'Copter', false),
('aircraft-3', 'Leleka-100', 'Plane', false)
ON CONFLICT (id) DO NOTHING;
-- Flights (per seed_flights.ts) ---------------------------------------------
INSERT INTO flights (id, name, created_date, aircraft_id) VALUES
('flight-1', 'Recon Alpha', '2026-05-01T10:00:00Z', 'aircraft-1'),
('flight-2', 'Recon Bravo', '2026-05-02T11:30:00Z', 'aircraft-1'),
('flight-3', 'Survey Charlie', '2026-05-03T14:15:00Z', 'aircraft-2'),
('flight-4', 'Patrol Delta', '2026-05-04T09:45:00Z', 'aircraft-3'),
('flight-5', 'Strike Echo', '2026-05-05T16:00:00Z', 'aircraft-1')
ON CONFLICT (id) DO NOTHING;
-- Detection classes (contract ordering [0..N-1, 20..20+N-1, 40..40+N-1], N=9)
INSERT INTO detection_classes (id, name, short_name, color, max_size_m, photo_mode) VALUES
(0, 'class-0', 'c0', '#e6194b', 5, 0),
(1, 'class-1', 'c1', '#3cb44b', 5, 0),
(2, 'class-2', 'c2', '#ffe119', 5, 0),
(3, 'class-3', 'c3', '#4363d8', 5, 0),
(4, 'class-4', 'c4', '#f58231', 5, 0),
(5, 'class-5', 'c5', '#911eb4', 5, 0),
(6, 'class-6', 'c6', '#46f0f0', 5, 0),
(7, 'class-7', 'c7', '#f032e6', 5, 0),
(8, 'class-8', 'c8', '#bcf60c', 5, 0),
(20, 'class-20', 'c20', '#e6194b', 5, 0),
(21, 'class-21', 'c21', '#3cb44b', 5, 0),
(22, 'class-22', 'c22', '#ffe119', 5, 0),
(23, 'class-23', 'c23', '#4363d8', 5, 0),
(24, 'class-24', 'c24', '#f58231', 5, 0),
(25, 'class-25', 'c25', '#911eb4', 5, 0),
(26, 'class-26', 'c26', '#46f0f0', 5, 0),
(27, 'class-27', 'c27', '#f032e6', 5, 0),
(28, 'class-28', 'c28', '#bcf60c', 5, 0),
(40, 'class-40', 'c40', '#e6194b', 5, 0),
(41, 'class-41', 'c41', '#3cb44b', 5, 0),
(42, 'class-42', 'c42', '#ffe119', 5, 0),
(43, 'class-43', 'c43', '#4363d8', 5, 0),
(44, 'class-44', 'c44', '#f58231', 5, 0),
(45, 'class-45', 'c45', '#911eb4', 5, 0),
(46, 'class-46', 'c46', '#46f0f0', 5, 0),
(47, 'class-47', 'c47', '#f032e6', 5, 0),
(48, 'class-48', 'c48', '#bcf60c', 5, 0)
ON CONFLICT (id) DO NOTHING;
-- Media (per seed_media.ts) -------------------------------------------------
-- mediaStatus values follow the UI's CURRENT 0..3 scheme; AC-04 (Step 4 fix)
-- will migrate the seed to the full 0..6 range. Test-data.md tracks this.
INSERT INTO media (id, name, path, media_type, media_status, duration, annotation_count, waypoint_id, user_id) VALUES
('media-1', 'sortie-1.jpg', '/media/sortie-1.jpg', 1, 1, NULL, 0, NULL, 'user-alice'),
('media-2', 'sortie-2.jpg', '/media/sortie-2.jpg', 1, 2, NULL, 0, 'wp-1', 'user-alice'),
('media-3', 'sortie-3.jpg', '/media/sortie-3.jpg', 1, 3, NULL, 4, 'wp-1', 'user-alice'),
('media-4', 'patrol-1.mp4', '/media/patrol-1.mp4', 2, 1, '00:01:30', 0, NULL, 'user-bob'),
('media-5', 'patrol-2.mp4', '/media/patrol-2.mp4', 2, 3, '00:02:15', 8, NULL, 'user-bob'),
('media-6', 'manual.jpg', '/media/manual.jpg', 1, 4, NULL, 1, NULL, 'user-alice')
ON CONFLICT (id) DO NOTHING;
-- Annotations (per seed_annotations.ts) -------------------------------------
INSERT INTO annotations (id, media_id, time, created_date, user_id, source, status, is_split, split_tile) VALUES
('ann-1', 'media-3', NULL, '2026-05-03T14:30:00Z', 'user-alice', 0, 10, false, NULL),
('ann-2', 'media-3', NULL, '2026-05-03T14:32:00Z', 'user-alice', 0, 20, true, '3 0.5 0.5 0.2 0.2'),
('ann-3', 'media-5', '00:01:00', '2026-05-04T10:15:00Z', 'user-bob', 1, 30, false, NULL),
('ann-4', 'media-5', '00:01:30', '2026-05-04T10:20:00Z', 'user-bob', 1, 20, true, 'garbage')
ON CONFLICT (id) DO NOTHING;
-- User settings (per seed_user_settings.ts) ---------------------------------
INSERT INTO user_settings (id, user_id, selected_flight_id, annotations_left_panel_width, annotations_right_panel_width, dataset_left_panel_width, dataset_right_panel_width) VALUES
('user-settings-alice', 'user-alice', 'flight-1', 280, 320, 240, 280),
('user-settings-bob', 'user-bob', 'flight-3', NULL, NULL, NULL, NULL)
ON CONFLICT (id) DO NOTHING;
COMMIT;
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test'
// Two browser projects per AC-18 (Chromium + Firefox). The runner runs from
// inside the suite-e2e docker-compose `playwright-runner` container; the
// `azaion-ui` service is reachable by container hostname.
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://azaion-ui:80'
const OUTPUT_DIR = process.env.PLAYWRIGHT_OUTPUT_DIR ?? './test-output/e2e'
export default defineConfig({
testDir: './tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
timeout: 60_000,
expect: { timeout: 5_000 },
reporter: [
['list'],
['junit', { outputFile: '../test-output/e2e-report.xml' }],
['html', { outputFolder: '../test-output/e2e-html', open: 'never' }],
],
outputDir: OUTPUT_DIR,
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
})
+11
View File
@@ -0,0 +1,11 @@
FROM mcr.microsoft.com/playwright:v1.49.1-noble
WORKDIR /workspace
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Bun is required by the project's package.json `packageManager` pin.
RUN curl -fsSL https://bun.sh/install | bash \
&& ln -s /root/.bun/bin/bun /usr/local/bin/bun
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Playwright runner entrypoint. Mounted at /workspace = repo root and writes
# every artifact under /output (mounted to ./test-output/ on the host).
set -euo pipefail
cd /workspace
mkdir -p /output/e2e /output
# Install dependencies (frozen lockfile when the lockfile is present).
if [ -f bun.lock ] || [ -f bun.lockb ]; then
bun install --frozen-lockfile
else
bun install
fi
# The bun script forwards to playwright with the project's e2e config; the
# config writes the JUnit XML and HTML report to /output via the relative
# paths it carries.
bun run test:e2e "$@"
+10
View File
@@ -0,0 +1,10 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY server.ts ./
# wget is used by the docker-compose healthcheck.
RUN apk add --no-cache wget
EXPOSE 8081
CMD ["bun", "run", "server.ts"]
+58
View File
@@ -0,0 +1,58 @@
// owm-stub — OpenWeatherMap stand-in for the e2e profile (AZ-456 AC-2).
// Returns canned `/data/2.5/weather` responses keyed by lat,lon. A request log
// is exposed at `/mock/log` for resilience tests; `/mock/config` swaps the
// canned set without restarting the container.
interface WindResponse {
wind: { speed: number; deg: number }
name: string
coord: { lat: number; lon: number }
}
const PORT = Number(process.env.PORT ?? 8081)
let cannedResponses: Record<string, WindResponse> = {
'0,0': { wind: { speed: 5.0, deg: 270 }, name: 'TestCity', coord: { lat: 0, lon: 0 } },
'50.45,30.52': { wind: { speed: 7.5, deg: 90 }, name: 'Kyiv', coord: { lat: 50.45, lon: 30.52 } },
}
const requestLog: Array<{ ts: string; method: string; url: string }> = []
function key(lat: string | null, lon: string | null): string {
return `${lat ?? '0'},${lon ?? '0'}`
}
const server = Bun.serve({
port: PORT,
fetch(req) {
const url = new URL(req.url)
requestLog.push({ ts: new Date().toISOString(), method: req.method, url: url.pathname + url.search })
if (url.pathname === '/health') {
return new Response('ok', { status: 200 })
}
if (url.pathname === '/mock/log') {
return Response.json(requestLog)
}
if (url.pathname === '/mock/config' && req.method === 'POST') {
return req.json().then((body) => {
cannedResponses = body as Record<string, WindResponse>
return new Response(null, { status: 204 })
})
}
if (url.pathname === '/data/2.5/weather') {
const lat = url.searchParams.get('lat')
const lon = url.searchParams.get('lon')
const k = key(lat, lon)
const payload = cannedResponses[k] ?? cannedResponses['0,0']
return Response.json(payload)
}
return new Response('not found', { status: 404 })
},
})
console.log(`[owm-stub] listening on :${server.port}`)
+9
View File
@@ -0,0 +1,9 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY server.ts ./
RUN apk add --no-cache wget
EXPOSE 8082
CMD ["bun", "run", "server.ts"]
+43
View File
@@ -0,0 +1,43 @@
// tile-stub — OSM + Esri tile stand-in for the e2e profile (AZ-456 AC-2).
// Always returns a deterministic 256×256 transparent PNG. Records every
// request so tile-coverage tests can assert on the access log.
const PORT = Number(process.env.PORT ?? 8082)
const TILE_PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x06, 0x00, 0x00, 0x00, 0x5c, 0x72, 0xa8,
0x66, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0xed, 0xc1, 0x01, 0x0d, 0x00,
0x00, 0x00, 0xc2, 0xa0, 0xf7, 0x4f, 0x6d, 0x0e, 0x37, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
])
const requestLog: Array<{ ts: string; method: string; url: string; scheme: 'osm' | 'esri' | 'other' }> = []
function classify(pathname: string): 'osm' | 'esri' | 'other' {
if (/^\/sat\//.test(pathname)) return 'esri'
if (/^\/\d+\/\d+\/\d+\.png$/.test(pathname)) return 'osm'
return 'other'
}
const server = Bun.serve({
port: PORT,
fetch(req) {
const url = new URL(req.url)
const scheme = classify(url.pathname)
requestLog.push({ ts: new Date().toISOString(), method: req.method, url: url.pathname, scheme })
if (url.pathname === '/health') {
return new Response('ok', { status: 200 })
}
if (url.pathname === '/mock/log') {
return Response.json(requestLog)
}
if (scheme === 'osm' || scheme === 'esri') {
return new Response(TILE_PNG, { headers: { 'Content-Type': 'image/png' } })
}
return new Response('not found', { status: 404 })
},
})
console.log(`[tile-stub] listening on :${server.port}`)
+79
View File
@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test'
// Smoke tests for AZ-456 e2e infrastructure (AC-1, AC-2, AC-5, AC-8).
// Every other test file under e2e/tests/ is owned by AZ-457..AZ-482; those
// tasks add the production-shaped assertions per group. This file MUST stay
// minimal so any flake here is unambiguously an infrastructure regression.
const EXTERNAL_HOSTS = [
/api\.openweathermap\.org/,
/unpkg\.com/,
/\.tile\.openstreetmap\.org$/,
/^tile\.openstreetmap\.org$/,
]
test.describe('AZ-456 e2e infrastructure', () => {
test.beforeEach(async ({ context }, testInfo) => {
// AC-8: external-host firewall. The compose network is already isolated
// from the internet; this route guard catches code paths that try to
// reach an external host directly (and lets resilience tests flip it).
const externalHits: string[] = []
await context.route(/.*/, async (route) => {
const url = route.request().url()
if (EXTERNAL_HOSTS.some((re) => re.test(new URL(url).hostname))) {
externalHits.push(url)
await route.abort()
return
}
await route.continue()
})
testInfo.attachments.push({ name: 'externalHits', contentType: 'application/json', body: Buffer.from('[]') })
;(testInfo as unknown as { __externalHits: string[] }).__externalHits = externalHits
})
test.afterEach(async ({}, testInfo) => {
const externalHits = (testInfo as unknown as { __externalHits?: string[] }).__externalHits ?? []
expect(externalHits, 'leaked external requests detected').toEqual([])
})
test('AC-1: SPA HTML is served from azaion-ui', async ({ page }) => {
const response = await page.goto('/')
expect(response?.ok()).toBeTruthy()
const html = await page.content()
expect(html).toMatch(/<html[\s>]/i)
})
test('AC-2: owm-stub returns the canned wind shape', async ({ request }) => {
const res = await request.get('http://owm-stub:8081/data/2.5/weather?lat=0&lon=0&appid=test')
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.wind).toEqual({ speed: 5.0, deg: 270 })
})
test('AC-2: tile-stub returns a 256x256 PNG', async ({ request }) => {
const res = await request.get('http://tile-stub:8082/1/0/0.png')
expect(res.status()).toBe(200)
expect(res.headers()['content-type']).toBe('image/png')
const body = await res.body()
expect(body.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
})
test('AC-5: Playwright runs under the configured browser project', async ({ browserName }) => {
expect(['chromium', 'firefox']).toContain(browserName)
})
test('AC-8: external host is blocked by the route guard', async ({ page }) => {
const externalHits = (test.info() as unknown as { __externalHits?: string[] }).__externalHits ?? []
const beforeCount = externalHits.length
await page.goto('/')
await page.evaluate(() =>
fetch('https://api.openweathermap.org/data/2.5/weather?appid=leak').catch(() => null),
)
// The guard converts the request into an abort, so the leak is recorded
// but no real request escapes. The afterEach assertion will fire next.
expect(externalHits.length).toBeGreaterThan(beforeCount)
// Reset so afterEach doesn't fail this specific test (the guard already
// proved the assertion).
externalHits.length = 0
})
})