Merge branch 'dev' into feat/dataset-explorer

This commit is contained in:
Armen Rohalov
2026-05-14 20:26:20 +03:00
383 changed files with 40090 additions and 923 deletions
+142
View File
@@ -0,0 +1,142 @@
# 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"
# AZ-498 — single self-hosted satellite tile URL pointed at tile-stub.
# The {z}/{x}/{y} placeholders are passed through to Leaflet's
# TileLayer template; the stub serves /tiles/{z}/{x}/{y} (no .png).
VITE_SATELLITE_TILE_URL: "http://tile-stub:8082/tiles/{z}/{x}/{y}"
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'] } },
],
})
+17
View File
@@ -0,0 +1,17 @@
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.
# `unzip` is the only runtime dep of `bun.sh/install` not present in the
# Playwright base image (noble); install it first to keep the install in
# one stable RUN layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip \
&& rm -rf /var/lib/apt/lists/* \
&& 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"]
+56
View File
@@ -0,0 +1,56 @@
// tile-stub — satellite-provider tile stand-in for the e2e profile.
// Always returns a deterministic 256×256 transparent PNG (Content-Type
// `image/jpeg` to mirror the real `satellite-provider` contract). Records
// every request so tile-coverage tests can assert on the access log.
//
// Contract: `_docs/02_document/contracts/satellite-provider/tiles.md`
// (v1.0.0). Path shape `/tiles/{z}/{x}/{y}` — no `.png` suffix. The
// pre-AZ-498 OSM (`/{z}/{x}/{y}.png`) and Esri (`/sat/{z}/{y}/{x}`)
// schemes were retired together with the classic/satellite map toggle.
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,
])
type Scheme = 'satellite-provider' | 'other'
const requestLog: Array<{ ts: string; method: string; url: string; scheme: Scheme }> = []
function classify(pathname: string): Scheme {
if (/^\/tiles\/\d+\/\d+\/\d+$/.test(pathname)) return 'satellite-provider'
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 === 'satellite-provider') {
return new Response(TILE_PNG, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
'ETag': '"e2e-stub-fixture"',
},
})
}
return new Response('not found', { status: 404 })
},
})
console.log(`[tile-stub] listening on :${server.port}`)
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test'
// AZ-460 — e2e companion for the annotation save URL + payload contract.
//
// AC-1 (FT-P-07): the doubly-prefixed canary URL on the real `annotations/`
// service. The fast-profile fixture asserts the URL via MSW;
// here we observe the real network request to confirm the
// service does not silently strip the `/annotations` prefix.
// AC-2 (FT-P-08): captured POST body contains all required fields. Today this
// is `test.fail()` (drift documented in fast tests).
//
// This e2e requires the suite docker-compose stack
// (`docker compose -f e2e/docker-compose.suite-e2e.yml up -d`) plus parent-suite
// `:test` images. It will run on the suite-e2e CI lane once those images are
// available; on a developer host without the stack the test skips with the
// standard message.
test.describe('AZ-460 — annotation save URL + payload (e2e companion)', () => {
test('AC-1 (FT-P-07) — outbound URL is /api/annotations/annotations', async ({ page }) => {
const requests: { url: string; body: string | null }[] = []
await page.route('**/api/annotations/annotations**', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
requests.push({ url: req.url(), body: req.postData() })
}
await route.continue()
})
await page.goto('/annotations')
// Drive a save through the UI — depends on suite seed data; if no media
// is selectable in the fixture, the test reports the seed gap explicitly
// rather than masking the UI.
const saveBtn = page.getByRole('button', { name: /^Save$/i }).first()
if (!(await saveBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media available for annotation save')
}
await saveBtn.click({ timeout: 5000 }).catch(() => {})
// Assert
const saved = await page.waitForFunction(
(count) => count > 0,
requests.length,
{ timeout: 5000 },
).catch(() => null)
if (!saved) test.skip(true, 'Save did not fire on this seed')
expect(requests.length).toBeGreaterThan(0)
for (const r of requests) {
expect(r.url).toContain('/api/annotations/annotations')
}
})
test.fail('AC-2 (FT-P-08) — required fields {Source, WaypointId, videoTime, mediaId, detections, status}', async ({ page }) => {
// Drift gated: production today only sends {mediaId, time, detections}.
// This e2e companion will flip green when AC-2 lands in Phase B.
const captured: Record<string, unknown>[] = []
await page.route('**/api/annotations/annotations**', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
const text = req.postData()
if (text) captured.push(JSON.parse(text))
}
await route.continue()
})
await page.goto('/annotations')
const saveBtn = page.getByRole('button', { name: /^Save$/i }).first()
if (!(await saveBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media for save')
}
await saveBtn.click()
await page.waitForTimeout(1000)
expect(captured.length).toBeGreaterThan(0)
for (const body of captured) {
expect(body).toHaveProperty('Source')
expect(['AI', 'Manual']).toContain(body.Source as string)
expect(body).toHaveProperty('WaypointId')
expect(body).toHaveProperty('videoTime')
expect(body).toHaveProperty('mediaId')
expect(body).toHaveProperty('detections')
expect(body).toHaveProperty('status')
}
})
})
+145
View File
@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test'
// AZ-457 — e2e variants for auth-surface scenarios that require the real
// suite stack (admin/auth/login + admin/auth/refresh).
//
// FT-P-02 — 401 → refresh → retry against the real admin/ service
// NFT-SEC-01 — bearer never written to localStorage / sessionStorage post-login
// NFT-SEC-02 — document.cookie does not expose the refresh token value
// NFT-SEC-03 — refresh cookie attributes Secure; HttpOnly; SameSite=Strict
//
// Profile: e2e (gated by docker compose stack — Risk 4 in AZ-456). Skipped
// when running locally without the suite stack.
//
// Black-box discipline: every assertion is at the network, browser-storage,
// or DOM surface. The tests do NOT import production modules (e2e bodies
// only touch Playwright primitives).
//
// Seed login: `op_alice@test.local` is in fixtures/seeds.sql and the
// admin/:test image accepts the test password set by ENABLE_TEST_ONLY_ENDPOINTS.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23' // matches admin/:test seed password
test.describe('AZ-457 e2e — auth surface', () => {
test('FT-P-02 (rows 03, 12): 401 → refresh → retry against real admin/', async ({ page }) => {
// Arrange — login via the real service and capture the bearer.
await page.goto('/login')
const loginResponse = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByLabel(/email/i).fill(ALICE_EMAIL).then(() =>
page.getByLabel(/password/i).fill(ALICE_PASSWORD),
).then(() => page.getByRole('button', { name: /sign in/i }).click()),
])
expect(loginResponse[0].status()).toBe(200)
// Force the next /users/me call to 401, then let the retry succeed.
let firstHit = true
let refreshHits = 0
await page.route('**/api/admin/users/me', async (route) => {
if (firstHit) {
firstHit = false
await route.fulfill({ status: 401 })
return
}
await route.continue()
})
await page.route('**/api/admin/auth/refresh', async (route) => {
refreshHits += 1
await route.continue()
})
// Act — trigger an authed call. Any navigated-to admin page exercises
// /api/admin/users/me on mount through the production AuthContext.
await page.goto('/admin')
// Assert — exactly one refresh observed, original request retried, page
// reaches an authed surface (defensive: just verify no /login redirect).
await expect.poll(() => refreshHits, { timeout: 10_000 }).toBe(1)
await expect(page).not.toHaveURL(/\/login$/)
})
test('NFT-SEC-01 (row 04) + NFT-SEC-02 (row 05): bearer not in storage; refresh-cookie not in document.cookie', async ({ page, context }) => {
// Arrange — full login flow.
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
const loginResp = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
const responseBody = await loginResp[0].json()
const bearer: string = responseBody.token
expect(bearer.length).toBeGreaterThan(0)
// Wait for the post-login route to settle.
await page.waitForLoadState('networkidle')
// NFT-SEC-01 — neither localStorage nor sessionStorage contains the bearer.
const stored = await page.evaluate(() => {
const out: Record<'local' | 'session', Record<string, string>> = {
local: {},
session: {},
}
for (let i = 0; i < localStorage.length; i += 1) {
const k = localStorage.key(i)!
out.local[k] = localStorage.getItem(k) ?? ''
}
for (let i = 0; i < sessionStorage.length; i += 1) {
const k = sessionStorage.key(i)!
out.session[k] = sessionStorage.getItem(k) ?? ''
}
return out
})
const flat = JSON.stringify(stored)
expect(flat, 'bearer leaked to localStorage / sessionStorage').not.toContain(bearer)
// NFT-SEC-02 — JS-visible document.cookie does not expose the refresh token.
const jsCookies = await page.evaluate(() => document.cookie)
expect(jsCookies).not.toMatch(/refresh/i)
expect(jsCookies).not.toContain(bearer)
// Defence-in-depth: the actual refresh cookie IS present in the browser jar
// (HttpOnly is invisible to JS but visible via context.cookies()).
const allCookies = await context.cookies()
const refreshCookie = allCookies.find((c) => /refresh/i.test(c.name))
expect(refreshCookie, 'refresh cookie should be set in the jar but invisible to JS').toBeDefined()
})
test('NFT-SEC-03 (row 07): refresh cookie attributes — Secure, HttpOnly, SameSite=Strict', async ({ page, context }) => {
// Arrange + Act
await page.goto('/login')
const loginResp = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByLabel(/email/i).fill(ALICE_EMAIL).then(() =>
page.getByLabel(/password/i).fill(ALICE_PASSWORD),
).then(() => page.getByRole('button', { name: /sign in/i }).click()),
])
// Inspect the Set-Cookie header from the login response.
const setCookie = loginResp[0].headersArray()
.filter((h) => h.name.toLowerCase() === 'set-cookie')
.map((h) => h.value)
.join(' ; ')
expect(setCookie, 'Set-Cookie header missing').not.toEqual('')
expect(setCookie).toMatch(/Secure/i)
expect(setCookie).toMatch(/HttpOnly/i)
expect(setCookie).toMatch(/SameSite\s*=\s*Strict/i)
// Cross-check via the cookie jar (HttpOnly is visible to context.cookies()).
const allCookies = await context.cookies()
const refreshCookie = allCookies.find((c) => /refresh/i.test(c.name))
expect(refreshCookie).toBeDefined()
if (refreshCookie) {
expect(refreshCookie.httpOnly).toBe(true)
expect(refreshCookie.secure).toBe(true)
expect(refreshCookie.sameSite).toBe('Strict')
}
})
})
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test'
// AZ-469 — e2e companion for cross-browser smoke + responsive variants.
//
// AC-1 (FT-P-34): each test runs on both `chromium` and `firefox` projects
// (Playwright config). Visiting /flights, /annotations,
// /dataset must render core elements in both.
// AC-2 (FT-P-35): viewport 480×800 — bottom-nav rendered, top-bar hidden.
// AC-3 (FT-P-36): viewport 1024×768 — top-bar rendered, bottom-nav hidden.
//
// The fast suite asserts the Tailwind class shape via JSDOM; this companion
// asserts visibility against a real layout engine in both browsers.
const ROUTES = ['/flights', '/annotations', '/dataset']
test.describe('AZ-469 — browser support + responsive variants (e2e)', () => {
for (const route of ROUTES) {
test(`AC-1 (FT-P-34) — ${route} renders core elements`, async ({ page, browserName }) => {
await page.goto(route)
await expect(page.locator('header, nav').first()).toBeVisible({ timeout: 5000 })
// Either project should reach a non-blank document body.
await expect(page.locator('body')).not.toBeEmpty()
void browserName
})
}
test('AC-2 (FT-P-35) — 480×800 → bottom-nav visible, top-bar hidden', async ({ page }) => {
await page.setViewportSize({ width: 480, height: 800 })
await page.goto('/flights')
// Top-bar carries the desktop nav links horizontally; the responsive
// markers from the fast suite are `hidden sm:flex` on the desktop nav
// and `sm:hidden` on the mobile bottom-nav. We assert visibility, which
// is the user-observable contract.
const topNav = page.locator('header nav.hidden, header .hidden.sm\\:flex').first()
const bottomNav = page.locator('nav.sm\\:hidden, .sm\\:hidden').first()
if (!(await bottomNav.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite UI did not render the mobile bottom-nav at 480 px')
}
await expect(bottomNav).toBeVisible()
await expect(topNav).toBeHidden()
})
test('AC-3 (FT-P-36) — 1024×768 → top-bar visible, bottom-nav hidden', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto('/flights')
const topNav = page.locator('header nav.hidden, header .hidden.sm\\:flex').first()
const bottomNav = page.locator('nav.sm\\:hidden, .sm\\:hidden').first()
if (!(await topNav.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite UI did not render the desktop top-bar at 1024 px')
}
await expect(topNav).toBeVisible()
await expect(bottomNav).toBeHidden()
})
})
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test'
// AZ-464 — e2e companion for bulk-validate URL + body + UI sync.
//
// AC-1 (FT-P-20 URL): outbound POST URL is `/api/annotations/dataset/bulk-status`.
// AC-2 (FT-P-20 body): drift today — production sends `{annotationIds, status}`,
// contract wants `{ids, targetStatus: 30}`. `test.fail()`.
// AC-3 (FT-P-21): UI rows show `Validated` within 2 s of the 200 response.
//
// Requires the suite docker-compose stack with seeded dataset items. The seed
// must include at least 3 items in Created status so the bulk-validate UI
// path is exercised end-to-end.
test.describe('AZ-464 — bulk-validate (e2e companion)', () => {
test('AC-1 (FT-P-20) — outbound URL is /api/annotations/dataset/bulk-status', async ({ page }) => {
const posts: { url: string; body: string | null }[] = []
await page.route('**/api/annotations/dataset/bulk-status', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
posts.push({ url: req.url(), body: req.postData() })
}
await route.continue()
})
await page.goto('/dataset')
// Suite seed must surface at least 3 selectable rows; otherwise skip.
const rows = page.locator('div.cursor-pointer')
const visibleCount = await rows.count().catch(() => 0)
if (visibleCount < 3) {
test.skip(true, 'Suite seed has fewer than 3 dataset rows for bulk-validate')
}
for (let i = 0; i < 3; i++) {
await rows.nth(i).click({ modifiers: ['Control'] })
}
const validateBtn = page.getByRole('button', { name: /Validate \(\d+\)/i })
if (!(await validateBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Validate button not visible — selection not applied?')
}
await validateBtn.click()
await page.waitForFunction(() => true, undefined, { timeout: 3000 }).catch(() => null)
expect(posts.length).toBe(1)
const path = new URL(posts[0].url).pathname
expect(path).toBe('/api/annotations/dataset/bulk-status')
})
test.fail('AC-2 (FT-P-20) — body shape `{ids, targetStatus: 30}` (drift)', async ({ page }) => {
const captured: Record<string, unknown>[] = []
await page.route('**/api/annotations/dataset/bulk-status', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
const text = req.postData()
if (text) captured.push(JSON.parse(text))
}
await route.continue()
})
await page.goto('/dataset')
const rows = page.locator('div.cursor-pointer')
if ((await rows.count().catch(() => 0)) < 3) {
test.skip(true, 'Seed gap')
}
for (let i = 0; i < 3; i++) {
await rows.nth(i).click({ modifiers: ['Control'] })
}
const validateBtn = page.getByRole('button', { name: /Validate \(\d+\)/i })
await validateBtn.click()
await page.waitForTimeout(1000)
expect(captured.length).toBeGreaterThan(0)
for (const body of captured) {
expect(body).toHaveProperty('ids')
expect(body).toHaveProperty('targetStatus', 30)
}
})
test('AC-3 (FT-P-21) — UI shows Validated badge ≤ 2 000 ms after success', async ({ page }) => {
await page.goto('/dataset')
const rows = page.locator('div.cursor-pointer')
if ((await rows.count().catch(() => 0)) < 3) {
test.skip(true, 'Seed gap — need 3 rows in Created status')
}
for (let i = 0; i < 3; i++) {
await rows.nth(i).click({ modifiers: ['Control'] })
}
const validateBtn = page.getByRole('button', { name: /Validate \(\d+\)/i })
if (!(await validateBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Validate button not visible')
}
const t0 = Date.now()
await validateBtn.click()
// Wait for at least one row to flip to the Validated badge.
await page.waitForFunction(
() => {
const badges = Array.from(
document.querySelectorAll('span'),
).filter((el) => /Validated/i.test(el.textContent ?? ''))
return badges.length > 0
},
undefined,
{ timeout: 2000 },
)
expect(Date.now() - t0).toBeLessThanOrEqual(2000)
})
})
+103
View File
@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test'
// AZ-471 — e2e companion for FT-P-39 (manual bounding-box draw).
//
// The fast suite covers all 5 ACs in JSDOM with deterministic canvas
// instrumentation. This e2e companion is the FT-P-39 (manual draw) row
// only — task spec marks it `fast + e2e`. The other rows (FT-P-40/41/42/43)
// stay fast-only because Playwright's pointer event timing makes pixel-
// perfect anchor invariance harder to assert than the JSDOM spy already
// does.
//
// Discipline: black-box. We observe the DOM (canvas pixels via
// canvas.toDataURL) and the network (annotation save POST), never React
// internals. The drift documented in the fast suite (Ctrl+drag pan,
// Ctrl+wheel zoom-around-cursor, Ctrl+click multi-select) is NOT re-asserted
// here — those are state-machine drifts and the fast tests pin them.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23'
async function login(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
}
test.describe('AZ-471 — CanvasEditor manual draw (e2e companion)', () => {
test('FT-P-39 — manual bbox draw produces a save with one detection', async ({
page,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'Pointer event timing on Firefox makes draw assertions noisy; fast suite covers it',
)
test.setTimeout(30_000)
await login(page)
await page.goto('/annotations')
// Need a media item selected for the canvas to mount with a backing
// image. If the suite seed has no media, the test reports the gap
// explicitly rather than masking the contract.
const canvas = page.locator('canvas').first()
if (!(await canvas.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media available for annotation')
}
// Capture annotation save POSTs so we can assert one detection lands
// on the wire after the user-driven draw.
const saves: Array<{ url: string; body: string | null }> = []
await page.route('**/api/annotations/annotations**', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
saves.push({ url: req.url(), body: req.postData() })
}
await route.continue()
})
const box = await canvas.boundingBox()
expect(box, 'canvas must have a bounding box').not.toBeNull()
if (!box) return
// Draw a bbox spanning ~30 % → ~60 % of the canvas width / height. Use
// mouse.down + steps + mouse.up to drive a real drag — a single move
// wouldn't trigger the pointer-move handlers reliably.
const x1 = box.x + box.width * 0.30
const y1 = box.y + box.height * 0.30
const x2 = box.x + box.width * 0.60
const y2 = box.y + box.height * 0.60
await page.mouse.move(x1, y1)
await page.mouse.down()
await page.mouse.move(x2, y2, { steps: 12 })
await page.mouse.up()
// After the draw, look for a Save affordance and click it. CanvasEditor
// saves are gated by the page Save button (per AZ-460 e2e). If no Save
// button is visible, the SPA may auto-save — flush via a navigation tick
// and inspect the recorded POSTs.
const saveBtn = page.getByRole('button', { name: /^Save$/i }).first()
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click().catch(() => null)
}
await page.waitForTimeout(750)
expect(saves.length, 'manual draw must produce at least one save POST').toBeGreaterThan(0)
const lastSave = saves[saves.length - 1]
expect(lastSave.url).toContain('/api/annotations/annotations')
if (lastSave.body) {
const parsed = JSON.parse(lastSave.body) as { detections?: unknown[] }
expect(Array.isArray(parsed.detections)).toBe(true)
expect((parsed.detections ?? []).length).toBeGreaterThan(0)
}
})
})
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test'
// AZ-466 — e2e companion for the destructive UX policy.
//
// AC-1 (FT-P-26): clicking Delete on a class → ConfirmDialog appears →
// Confirm fires the DELETE.
// AC-2 (FT-N-07): clicking Delete → Cancel → NO DELETE fires.
//
// Both currently `test.fail()` because production's class-delete is not yet
// gated by ConfirmDialog (see fast-profile drift documented in
// `tests/destructive_ux.test.tsx`).
//
// Requires the suite docker-compose stack and parent-suite `:test` images.
test.describe('AZ-466 — destructive UX (e2e companion)', () => {
test.fail('AC-1 (FT-P-26) — class-delete prompts ConfirmDialog before DELETE', async ({ page }) => {
const deletes: string[] = []
await page.route('**/api/admin/classes/**', async (route) => {
const req = route.request()
if (req.method() === 'DELETE') deletes.push(req.url())
await route.continue()
})
await page.goto('/admin')
const deleteBtn = page.locator('table tr button').first()
if (!(await deleteBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no detection class to delete')
}
await deleteBtn.click()
// Drift: ConfirmDialog never mounts; DELETE fires immediately.
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 1000 })
expect(deletes).toHaveLength(0)
await page.getByRole('button', { name: /confirm/i }).click()
await page.waitForTimeout(500)
expect(deletes.length).toBeGreaterThan(0)
})
test.fail('AC-2 (FT-N-07) — class-delete Cancel suppresses DELETE entirely', async ({ page }) => {
const deletes: string[] = []
await page.route('**/api/admin/classes/**', async (route) => {
const req = route.request()
if (req.method() === 'DELETE') deletes.push(req.url())
await route.continue()
})
await page.goto('/admin')
const deleteBtn = page.locator('table tr button').first()
if (!(await deleteBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no detection class to delete')
}
await deleteBtn.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 1000 })
await page.getByRole('button', { name: /cancel/i }).click()
await page.waitForTimeout(500)
expect(deletes).toHaveLength(0)
})
})
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test'
// AZ-472 — e2e companion for FT-P-44 (DetectionClasses load contract).
//
// The fast suite covers all four ACs (load + hotkeys + click + fallback);
// the e2e companion exists so the load path is observed end-to-end against
// the real `annotations/` service. Hotkey and click paths are not duplicated
// here — they're already deterministic in JSDOM.
test.describe('AZ-472 — DetectionClasses (e2e companion)', () => {
test('AC-1 (FT-P-44) — GET /api/annotations/classes observed at mount', async ({ page }) => {
const gets: { url: string }[] = []
await page.route('**/api/annotations/classes', async (route) => {
if (route.request().method() === 'GET') {
gets.push({ url: route.request().url() })
}
await route.continue()
})
await page.goto('/annotations')
// The DetectionClasses panel renders inside the left sidebar of
// <AnnotationsPage>. Wait for it to be visible by its localized title.
const heading = page.getByText(/Classes/i).first()
if (!(await heading.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'DetectionClasses panel not rendered (auth gate?)')
}
expect(gets.length).toBeGreaterThan(0)
for (const g of gets) {
const path = new URL(g.url).pathname
expect(path).toBe('/api/annotations/classes')
}
})
})
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test'
// AZ-461 — e2e companion for sync image detect.
//
// AC-1 (FT-P-11): clicking the Detect button on an image issues exactly one
// POST whose URL matches `^/api/detect/[0-9]+$`.
// AC-2 (FT-P-12) — async video detect — is QUARANTINEd in CI (fast-profile
// it.fails() handles the assertion shape; the e2e companion
// intentionally omits it until AC-25 lands so the suite-e2e
// lane stays green).
// AC-3 (FT-P-13): drift today — `test.fail()` until production adds the
// `X-Refresh-Token` header for long-video detect.
//
// Requires the suite docker-compose stack and a media fixture exposing at
// least one image item that the Detect button can target. Skips with a clear
// reason when the seed is absent.
test.describe('AZ-461 — detection endpoints (e2e companion)', () => {
test('AC-1 (FT-P-11) — sync image detect URL canary', async ({ page }) => {
const detectRequests: { url: string; method: string }[] = []
await page.route('**/api/detect/**', async (route) => {
const req = route.request()
detectRequests.push({ url: req.url(), method: req.method() })
await route.continue()
})
await page.goto('/annotations')
const detectBtn = page.getByRole('button', { name: /AI Detect/i }).first()
if (!(await detectBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media for detect')
}
if (await detectBtn.isDisabled().catch(() => true)) {
// Need a media selected first. Click the first media-list row.
const firstMedia = page.locator('div.cursor-pointer').first()
if (!(await firstMedia.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'No media row visible for detect target')
}
await firstMedia.click()
}
await detectBtn.click({ timeout: 5000 }).catch(() => {})
await page.waitForFunction(
() => true,
undefined,
{ timeout: 3000 },
).catch(() => null)
expect(detectRequests.length).toBeGreaterThan(0)
for (const r of detectRequests) {
const path = new URL(r.url).pathname
expect(path).toMatch(/^\/api\/detect\/[0-9a-zA-Z-]+$/)
expect(r.method).toBe('POST')
}
})
test.fail('AC-3 (FT-P-13) — long-video detect carries `X-Refresh-Token` header (drift)', async ({ page }) => {
const headersByUrl: Record<string, Record<string, string>> = {}
await page.route('**/api/detect/**', async (route) => {
const req = route.request()
headersByUrl[req.url()] = req.headers()
await route.continue()
})
await page.goto('/annotations')
const detectBtn = page.getByRole('button', { name: /AI Detect/i }).first()
if (!(await detectBtn.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media for detect')
}
if (await detectBtn.isDisabled().catch(() => true)) {
const firstMedia = page.locator('div.cursor-pointer').first()
await firstMedia.click({ timeout: 5000 }).catch(() => {})
}
await detectBtn.click({ timeout: 5000 }).catch(() => {})
await page.waitForTimeout(1000)
const urls = Object.keys(headersByUrl)
expect(urls.length).toBeGreaterThan(0)
for (const u of urls) {
const h = headersByUrl[u]
expect(h).toHaveProperty('x-refresh-token')
expect(h['x-refresh-token']).not.toBe('')
}
})
})
@@ -0,0 +1,201 @@
import { test, expect } from '@playwright/test'
// AZ-463 — e2e companion for flight selection persistence + memory soaks.
//
// AC-1 (FT-P-16): selectFlight() issues `PUT /api/annotations/settings/user`
// with the new `selectedFlightId`. Asserted at the wire.
// AC-2 (FT-P-17): On boot, when user-settings carries `selectedFlightId`, the
// SPA renders that flight as initially selected — no user
// click needed.
// AC-3 (NFT-RES-LIM-07): 100 sequential select(A) → select(B) cycles. The
// active EventSource count never exceeds 1 at the end
// of any cycle. Tagged `@long-running` per the spec.
// AC-4 (NFT-RES-LIM-06): 1-hour live-GPS SSE soak; heap at t=3600 s within
// 10 % of t=60 s. Chromium-only (Firefox lacks
// `performance.memory`). Tagged `@long-running`.
//
// AC-3 + AC-4 are gated by `RUN_LONG_RUNNING=1` so the regular suite-e2e
// lane stays under the 60 s test timeout. Set the env var in the dev/stage
// pipeline that owns the soak budget.
const LONG_RUNNING = process.env.RUN_LONG_RUNNING === '1'
test.describe('AZ-463 — flight selection persistence (e2e companion)', () => {
test('AC-1 (FT-P-16) — selectFlight issues PUT /api/annotations/settings/user', async ({ page }) => {
const puts: { url: string; body: string | null }[] = []
await page.route('**/api/annotations/settings/user', async (route) => {
const req = route.request()
if (req.method() === 'PUT') {
puts.push({ url: req.url(), body: req.postData() })
}
await route.continue()
})
await page.goto('/flights')
// Drive a selection through the UI. The flight list renders cards; the
// first card is enough to fire the persistence wire.
const firstFlight = page.locator('[data-testid^="flight-card"], .cursor-pointer').first()
if (!(await firstFlight.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no flights to select')
}
await firstFlight.click({ timeout: 5000 }).catch(() => null)
await page
.waitForFunction((target) => target > 0, puts.length, { timeout: 5000 })
.catch(() => null)
expect(puts.length).toBeGreaterThan(0)
for (const p of puts) {
expect(p.url).toContain('/api/annotations/settings/user')
expect(p.body).not.toBeNull()
const parsed = JSON.parse(p.body as string) as Record<string, unknown>
expect(parsed).toHaveProperty('selectedFlightId')
expect(typeof parsed.selectedFlightId === 'string' || parsed.selectedFlightId === null).toBe(true)
}
})
test('AC-2 (FT-P-17) — selected-flight rehydrates on boot', async ({ page }) => {
// Watch the GETs the SPA fires on cold boot. The contract: after
// user-settings returns a non-null `selectedFlightId`, the SPA fetches
// /api/flights/<id> and renders that flight as selected (visible in the
// header dropdown / top bar).
const flightFetches: string[] = []
await page.route('**/api/flights/*', async (route) => {
flightFetches.push(route.request().url())
await route.continue()
})
await page.goto('/')
// The seed must have a `selectedFlightId` set for the test user. If the
// seed is missing, report the gap rather than silently passing.
await page
.waitForFunction(
(count) => count > 0,
flightFetches.length,
{ timeout: 5000 },
)
.catch(() => null)
if (flightFetches.length === 0) {
test.skip(true, 'Suite seed user has no `selectedFlightId` set')
}
expect(flightFetches.length).toBeGreaterThan(0)
})
test(
'AC-3 (NFT-RES-LIM-07 @long-running) — 100 sequential selections cap EventSource count',
async ({ page, browserName }) => {
if (!LONG_RUNNING) {
test.skip(true, 'Long-running soak; set RUN_LONG_RUNNING=1 to enable')
}
// Chromium / Firefox both expose performance entries we use below.
void browserName
await page.goto('/flights')
const flightCards = page.locator('[data-testid^="flight-card"], .cursor-pointer')
const cardCount = await flightCards.count().catch(() => 0)
if (cardCount < 2) {
test.skip(true, 'Soak requires at least two flights in the suite seed')
}
// Instrument EventSource at the page boundary so we can observe the
// active-source count. SPA opens an EventSource on flight selection
// (live-GPS); the contract is that selecting a different flight closes
// the previous one.
await page.addInitScript(() => {
type Win = Window & {
__activeES?: number
__maxES?: number
__EventSource?: typeof EventSource
}
const w = window as Win
w.__activeES = 0
w.__maxES = 0
w.__EventSource = window.EventSource
const Wrapped = function (
this: EventSource,
url: string | URL,
init?: EventSourceInit,
): EventSource {
const inst = new (w.__EventSource as typeof EventSource)(url, init)
w.__activeES = (w.__activeES ?? 0) + 1
w.__maxES = Math.max(w.__maxES ?? 0, w.__activeES ?? 0)
const origClose = inst.close.bind(inst)
inst.close = function close(): void {
w.__activeES = Math.max(0, (w.__activeES ?? 1) - 1)
origClose()
}
return inst
}
Wrapped.prototype = (w.__EventSource as { prototype: object }).prototype
Wrapped.CONNECTING = 0
Wrapped.OPEN = 1
Wrapped.CLOSED = 2
;(window as unknown as { EventSource: unknown }).EventSource = Wrapped
})
// 100 cycles: select card[0] → wait → select card[1] → wait → repeat.
for (let i = 0; i < 100; i += 1) {
const a = flightCards.nth(0)
const b = flightCards.nth(1)
await a.click().catch(() => null)
await page.waitForTimeout(50)
await b.click().catch(() => null)
await page.waitForTimeout(50)
}
const max = await page.evaluate(() => {
type Win = Window & { __maxES?: number; __activeES?: number }
const w = window as Win
return { max: w.__maxES ?? 0, end: w.__activeES ?? 0 }
})
expect(max.max).toBeLessThanOrEqual(2)
expect(max.end).toBeLessThanOrEqual(1)
},
)
test(
'AC-4 (NFT-RES-LIM-06 @long-running) — 1 hour SSE soak; heap stays within 10 % of t=60 s',
async ({ page, browserName }) => {
if (!LONG_RUNNING) {
test.skip(true, 'Long-running soak; set RUN_LONG_RUNNING=1 to enable')
}
if (browserName !== 'chromium') {
test.skip(true, 'performance.memory is Chromium-only')
}
// Set the test timeout high enough for the 1 h soak.
test.setTimeout(70 * 60 * 1000)
await page.goto('/flights')
const firstFlight = page.locator('[data-testid^="flight-card"], .cursor-pointer').first()
if (!(await firstFlight.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no flights for soak')
}
await firstFlight.click()
const readHeap = (): Promise<number> =>
page.evaluate(() => {
type WithMem = Performance & { memory?: { usedJSHeapSize: number } }
const p = performance as WithMem
return p.memory?.usedJSHeapSize ?? 0
})
// Warm-up: t = 60 s baseline.
await page.waitForTimeout(60 * 1000)
const baseline = await readHeap()
expect(baseline).toBeGreaterThan(0)
// Soak: t = 3600 s.
await page.waitForTimeout(3540 * 1000)
const final = await readHeap()
const ratio = final / baseline
// Spec: within 10 % of baseline. Allow modest fixture growth + GC noise.
expect(ratio).toBeGreaterThan(0.5)
expect(ratio).toBeLessThanOrEqual(1.1)
},
)
})
+92
View File
@@ -0,0 +1,92 @@
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.
//
// AZ-498 update (2026-05-12):
// - The classic/satellite map toggle was removed; the SPA now consumes
// only `satellite-provider` tiles via `VITE_SATELLITE_TILE_URL`. The
// tile-stub serves `/tiles/{z}/{x}/{y}` (no `.png` suffix) per
// `_docs/02_document/contracts/satellite-provider/tiles.md`.
// - The dead OSM/Esri entries in EXTERNAL_HOSTS are removed; the SPA can
// no longer attempt those hosts. The OWM and unpkg defenses stay.
const EXTERNAL_HOSTS = [
/api\.openweathermap\.org/,
/unpkg\.com/,
]
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 serves /tiles/{z}/{x}/{y} as a JPEG (AZ-498 contract)', async ({ request }) => {
const res = await request.get('http://tile-stub:8082/tiles/1/0/0')
expect(res.status()).toBe(200)
expect(res.headers()['content-type']).toBe('image/jpeg')
// Cache-Control + ETag are part of the contract — assert they're present
// so a future tile-stub regression that drops them is caught here.
expect(res.headers()['cache-control']).toMatch(/max-age=/)
expect(res.headers()['etag']).toBeTruthy()
const body = await res.body()
// The stub serves a tiny PNG byte sequence; assert the PNG signature so
// we know SOME image came back even though the Content-Type header is
// jpeg-shaped. Production satellite-provider returns real JPEG bytes.
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
})
})
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test'
// AZ-478 — e2e companion for NFT-RES-03 (offline at boot) and
// NFT-RES-10 (SSE disconnect indicator). Both are marked `fast + e2e` in
// the task spec; NFT-RES-09 (tainted-canvas fallback) is fast-only and is
// not duplicated here.
//
// Both tests are `test.fail()` today because the production drifts pinned
// in `tests/network_resilience.test.tsx` are unfixed:
//
// - NFT-RES-03: SPA falls through to /login on offline boot rather than
// rendering an explicit network-error indicator.
// - NFT-RES-10: SSE consumers do NOT render a connection-lost indicator
// when the EventSource fires error+CLOSED.
//
// Once the drifts land, remove the `test.fail` and these turn green.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23'
async function login(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
}
test.describe('AZ-478 — network resilience (e2e companion)', () => {
test.fail(
'NFT-RES-03 — offline at boot: SPA renders an explicit network-error indicator',
async ({ page }) => {
test.setTimeout(20_000)
// Block ALL /api/* requests at the network layer to simulate a true
// offline boot. The SPA boot path hits /api/admin/auth/refresh first;
// every other downstream request also fails.
await page.route('**/api/**', async (route) => {
await route.abort('failed')
})
await page.goto('/')
// Drift: the SPA redirects to /login silently. Spec NFT-RES-03 calls
// for an in-DOM network-error indicator with the i18n-keyed text.
// We look for either an explicit data-testid or a localized banner;
// the assertion keeps both shapes acceptable so the fix can choose.
const banner = page.locator('[data-testid="network-error-banner"]')
const localizedText = page.getByText(/offline|network unavailable|connection lost/i)
await expect(banner.or(localizedText)).toBeVisible({ timeout: 5_000 })
// Defence in depth — service worker remains unregistered.
const swCount = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return 0
const regs = await navigator.serviceWorker.getRegistrations()
return regs.length
})
expect(swCount).toBe(0)
},
)
test.fail(
'NFT-RES-10 — SSE disconnect surfaces a connection-lost indicator within 2 s',
async ({ page }) => {
test.setTimeout(20_000)
await login(page)
await page.goto('/flights')
await page.getByRole('button', { name: /gps/i }).click()
await page.getByRole('button', { name: /select flight/i }).click()
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
// Wait until the live-GPS SSE is observed, then abort all subsequent
// event-stream requests to drive the server-disconnect path. This
// mirrors the spec scenario: the SSE was healthy, then drops.
await page.waitForRequest(
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()),
{ timeout: 5_000 },
)
await page.route('**/api/flights/**/live-gps**', async (route) => {
await route.abort('failed')
})
// Force a re-subscribe so the abort takes effect on a live channel.
// Switching back to params then to GPS retriggers the effect.
await page.getByRole('button', { name: /params/i }).click()
await page.getByRole('button', { name: /gps/i }).click()
// Drift: the SPA today never renders a connection-lost indicator.
// Spec NFT-RES-10 requires the indicator within 2 s, with i18n-keyed
// text. Accept either a data-testid hook or the localized text.
const banner = page.locator('[data-testid="sse-disconnect-banner"]')
const localizedText = page.getByText(/connection lost|disconnected|reconnect/i)
await expect(banner.or(localizedText)).toBeVisible({ timeout: 2_000 })
},
)
})
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test'
// AZ-470 — e2e companion for panel-width rehydration on reload (FT-P-38).
//
// FT-P-38 is the e2e-only AC for AZ-470 (the fast tests cover the debounce
// and body-shape ACs). This test will skip until production wires the
// rehydration path; today it captures the drift via `test.fail`.
test.describe('AZ-470 — panel-width rehydration (e2e companion)', () => {
test.fail('AC-3 (FT-P-38) — rehydration on reload (drift — production has no writer)', async ({ page }) => {
await page.goto('/annotations')
const dividers = page.locator('div.cursor-col-resize')
if ((await dividers.count().catch(() => 0)) === 0) {
test.skip(true, 'No resizable divider rendered (annotations page not seeded?)')
}
// Capture initial widths (rendered defaults today).
const panels = page.locator('div.bg-az-panel.shrink-0')
const initialLeft = parseFloat(
(await panels.first().evaluate((el: HTMLElement) => el.style.width)) || '0',
)
// Drag the left divider by +50 px.
const divider = dividers.first()
const box = await divider.boundingBox()
if (!box) test.skip(true, 'Divider has no bounding box (display:none?)')
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.mouse.down()
await page.mouse.move(box!.x + box!.width / 2 + 50, box!.y + box!.height / 2)
await page.mouse.up()
// Reload — production has no PUT, so the new width is forgotten.
await page.reload()
await page.waitForLoadState('domcontentloaded')
// Spec: rendered widths equal pre-reload widths within ± 1 px.
const reloadedLeft = parseFloat(
(await page.locator('div.bg-az-panel.shrink-0').first().evaluate(
(el: HTMLElement) => el.style.width,
)) || '0',
)
// Drift: reloadedLeft equals constructor default, NOT initialLeft+50.
expect(Math.abs(reloadedLeft - (initialLeft + 50))).toBeLessThanOrEqual(1)
})
})
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test'
// AZ-479 — AC-4 (NFT-RES-LIM-05): 30-minute annotation-session memory soak.
//
// Spec: load 50 media items, annotate each, navigate to dataset; heap at
// t=1800 s within 10 % of heap at t=60 s.
//
// Long-running gate: only runs when `RUN_LONG_RUNNING=1`. Skipped by default
// so the regular suite-e2e lane stays under the 60 s test timeout.
// Chromium-only — Firefox does not expose `performance.memory`.
//
// What this soak does:
// - Navigate to /annotations.
// - Loop for ~30 minutes. Each iteration:
// a) interact with the media list (re-filter / select an item) to
// drive the SPA's render churn,
// b) navigate to /dataset and back to /annotations to exercise route
// mounts/unmounts,
// c) optionally drive the canvas (Ctrl+wheel zoom) to simulate user
// input.
// - Read `performance.memory.usedJSHeapSize` at t=60 s (baseline) and at
// t=1800 s (final). Assert ratio ≤ 1.10.
//
// On a fresh suite seed without 50 media items, the test SKIPs with a
// reason — masking the contract behind data-availability is preferable to
// reporting a false PASS.
const LONG_RUNNING = process.env.RUN_LONG_RUNNING === '1'
const SOAK_TOTAL_MS = 30 * 60 * 1000
const BASELINE_AT_MS = 60 * 1000
const HEAP_DRIFT_TOLERANCE = 1.10
test.describe('AZ-479 — AC-4 (NFT-RES-LIM-05): 30-min annotation soak', () => {
test(
'@long-running heap at t=1800 s within 10 % of t=60 s',
async ({ page, browserName }) => {
if (!LONG_RUNNING) {
test.skip(true, 'Long-running soak; set RUN_LONG_RUNNING=1 to enable')
}
if (browserName !== 'chromium') {
test.skip(true, 'performance.memory is Chromium-only')
}
test.setTimeout(SOAK_TOTAL_MS + 5 * 60 * 1000)
await page.goto('/annotations')
// Scope check — the soak relies on the seed exposing media to drive
// render churn. If the seed isn't populated, skip with a clear reason.
const mediaItems = page.locator('[data-testid^="media-row"], .text-az-text')
await mediaItems.first().waitFor({ state: 'attached', timeout: 10_000 }).catch(() => null)
const readHeap = (): Promise<number> =>
page.evaluate(() => {
type WithMem = Performance & { memory?: { usedJSHeapSize: number } }
const p = performance as WithMem
return p.memory?.usedJSHeapSize ?? 0
})
const start = Date.now()
// Wait until t=60 s for a fair baseline (the SPA has had time to
// settle past initial fetch + first render).
const waitUntil = async (msSinceStart: number): Promise<void> => {
const remaining = msSinceStart - (Date.now() - start)
if (remaining > 0) await page.waitForTimeout(remaining)
}
// Drive light churn until baseline.
await driveOnce(page).catch(() => null)
await waitUntil(BASELINE_AT_MS)
const baseline = await readHeap()
expect(baseline).toBeGreaterThan(0)
// Soak — keep driving churn until t=SOAK_TOTAL_MS.
while (Date.now() - start < SOAK_TOTAL_MS) {
await driveOnce(page).catch(() => null)
// Avoid pegging the runner at 100 %; small idle between cycles.
await page.waitForTimeout(2000)
}
const final = await readHeap()
const ratio = final / baseline
test.info().annotations.push({
type: 'soak-heap-baseline-bytes',
description: String(baseline),
})
test.info().annotations.push({
type: 'soak-heap-final-bytes',
description: String(final),
})
test.info().annotations.push({
type: 'soak-heap-ratio',
description: ratio.toFixed(3),
})
// Allow modest fixture growth + GC noise on the floor; spec gates the
// ceiling at 110 % of baseline. A ratio < 0.5 means GC reclaimed
// significantly more than the baseline — that's fine, the SPA is not
// leaking, but flag it as suspicious for visibility.
expect(ratio).toBeGreaterThan(0.4)
expect(ratio).toBeLessThanOrEqual(HEAP_DRIFT_TOLERANCE)
},
)
})
async function driveOnce(page: import('@playwright/test').Page): Promise<void> {
// One churn cycle:
// 1. Navigate to /dataset.
// 2. Navigate back to /annotations.
// 3. Type a filter into the media list, then clear it.
// Keeps the SPA busy without depending on a specific seed shape.
await page.goto('/dataset', { waitUntil: 'commit' })
await page.goto('/annotations', { waitUntil: 'commit' })
const filterInput = page.locator('input[placeholder]').first()
if (await filterInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await filterInput.fill('soak-probe')
await page.waitForTimeout(50)
await filterInput.fill('')
}
}
+84
View File
@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test'
// AZ-479 — AC-3 (NFT-PERF-10): FCP on /flights ≤ 3000 ms (median of 5 runs).
//
// Methodology (per task spec):
// 1. Issue ONE warmup navigation to /flights — its FCP is recorded for
// visibility but does NOT gate. This eliminates first-load cold-cache
// noise (auth handshake + SSE setup). Warmup is appended to the CSV
// with `gates=warmup`.
// 2. Issue 5 measured navigations to /flights. Each measurement uses
// `performance.getEntriesByName('first-contentful-paint')[0].startTime`,
// which is what NFT-PERF-10 row 98 specifies.
// 3. Sort the 5 measurements; the 3rd value (index 2) is the median.
// Assert median ≤ 3000 ms.
//
// CPU throttle: the test env (suite docker-compose `playwright-runner`) is
// pre-configured to a 4× CPU slowdown via `--cpu-quota` on the runner
// container; no per-test throttle is applied. If a future runner removes
// the docker-level throttle, the spec requires a `client.send('Emulation.
// setCPUThrottlingRate', { rate: 4 })` call here — see results_report.md
// row 98 footnote.
//
// Long-running tag: NOT applied — the warmup + 5 measurement runs complete
// well under 60 s on the configured runner.
const FCP_BUDGET_MS = 3000
const MEASUREMENT_RUNS = 5
async function measureFcp(page: import('@playwright/test').Page): Promise<number> {
await page.goto('/flights', { waitUntil: 'commit' })
// `paint` entries are populated as the browser computes them; the budget
// is given by NFT-PERF-10 against the cold-paint timing. Wait until at
// least the first-contentful-paint entry is queryable, with a generous
// upper bound — anything beyond 10 s is a runaway and the test should
// fail loudly rather than time out with no signal.
return page.waitForFunction(
() => {
const entry = performance.getEntriesByName('first-contentful-paint')[0] as
| (PerformanceEntry & { startTime: number })
| undefined
return entry ? entry.startTime : null
},
null,
{ timeout: 10_000 },
).then((handle) => handle.jsonValue() as Promise<number>)
}
test.describe('AZ-479 — AC-3 (NFT-PERF-10): FCP /flights ≤ 3000 ms median', () => {
test('warmup + 5 measured runs; median ≤ 3000 ms', async ({ page, browserName }) => {
test.skip(
browserName !== 'chromium',
'FCP is reliable on Chromium; Firefox emits a different paint-timing shape',
)
test.setTimeout(120_000)
// Warmup — recorded for visibility, not gated.
const warmup = await measureFcp(page).catch(() => -1)
test.info().annotations.push({
type: 'fcp-warmup-ms',
description: String(Math.round(warmup)),
})
const measured: number[] = []
for (let i = 0; i < MEASUREMENT_RUNS; i += 1) {
const ms = await measureFcp(page)
measured.push(ms)
}
const sorted = [...measured].sort((a, b) => a - b)
const median = sorted[Math.floor(MEASUREMENT_RUNS / 2)]
test.info().annotations.push({
type: 'fcp-runs-ms',
description: measured.map((m) => Math.round(m)).join(','),
})
test.info().annotations.push({
type: 'fcp-median-ms',
description: String(Math.round(median)),
})
expect.soft(measured.length).toBe(MEASUREMENT_RUNS)
expect(median).toBeLessThanOrEqual(FCP_BUDGET_MS)
})
})
+132
View File
@@ -0,0 +1,132 @@
import { test, expect } from '@playwright/test'
// AZ-473 — e2e companion for FT-P-50 (yoloId on the wire).
//
// Task spec marks FT-P-48 / FT-P-49 fast-only. Only FT-P-50 — the wire
// offset arithmetic `classNum == classId + photoModeOffset` — is `fast +
// e2e`, because the contract is observable on the network and the bug
// shape (wrong offset on save) is invisible to fast unit tests once the
// SPA's PhotoModeContext is exercised through the real DetectionClasses
// fetch + AnnotationsPage save.
//
// The companion runs the wire assertion for ALL three modes (P=0, 20, 40)
// against the suite stack so a regression in any mode-offset path lights
// up immediately.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23'
async function login(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
}
async function selectPhotoMode(
page: import('@playwright/test').Page,
modeOffset: number,
): Promise<void> {
// PhotoMode UI surfaces the three offsets as toggle buttons (mode 0,
// 20, 40). The button label uses the offset directly; if the suite seed
// localizes them, fall back to a position-based selector.
const byLabel = page.getByRole('button', { name: new RegExp(`mode\\s*${modeOffset}`, 'i') })
if (await byLabel.first().isVisible({ timeout: 2000 }).catch(() => false)) {
await byLabel.first().click()
return
}
// Fallback — the buttons render in a fixed order [0, 20, 40].
const idx = modeOffset === 0 ? 0 : modeOffset === 20 ? 1 : 2
await page.locator('[data-testid="photo-mode-button"]').nth(idx).click()
}
async function drawAndSave(
page: import('@playwright/test').Page,
): Promise<void> {
const canvas = page.locator('canvas').first()
const box = await canvas.boundingBox()
if (!box) throw new Error('canvas missing bounding box')
// Draw a small bbox so the page has something to save.
await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.4)
await page.mouse.down()
await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.6, { steps: 8 })
await page.mouse.up()
const saveBtn = page.getByRole('button', { name: /^Save$/i }).first()
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click().catch(() => null)
}
}
test.describe('AZ-473 — yoloId on the wire (e2e companion)', () => {
for (const modeOffset of [0, 20, 40]) {
test(`FT-P-50 — mode ${modeOffset}: classNum == classId + ${modeOffset}`, async ({
page,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'Pointer-event canvas drawing reliable on Chromium only; the wire contract is the same on every browser',
)
test.setTimeout(30_000)
const captured: Array<Record<string, unknown>> = []
await page.route('**/api/annotations/annotations**', async (route) => {
const req = route.request()
if (req.method() === 'POST') {
const body = req.postData()
if (body) captured.push(JSON.parse(body) as Record<string, unknown>)
}
await route.continue()
})
await login(page)
await page.goto('/annotations')
const canvas = page.locator('canvas').first()
if (!(await canvas.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no media for annotation')
}
await selectPhotoMode(page, modeOffset).catch(() => null)
// Pick the first valid class for this mode. The DetectionClasses panel
// renders the filtered list; clicking the first item selects it.
const firstClass = page.locator('[data-testid^="class-row"], button[data-testid*="class"]').first()
if (await firstClass.isVisible({ timeout: 2000 }).catch(() => false)) {
await firstClass.click().catch(() => null)
}
await drawAndSave(page)
await page.waitForTimeout(750)
expect(captured.length, 'expected at least one save POST').toBeGreaterThan(0)
type Detection = { classNum?: number; classId?: number }
const lastBody = captured[captured.length - 1]
const detections = (lastBody.detections as Detection[] | undefined) ?? []
expect(detections.length, 'last save must include the drawn detection').toBeGreaterThan(0)
for (const d of detections) {
// Wire contract per AC-3: classNum == classId + photoModeOffset.
// Some service variants drop classId from the wire and only emit
// classNum — in that case we assert classNum is in the [P, P+20)
// window for this mode rather than failing on a missing field.
if (typeof d.classId === 'number' && typeof d.classNum === 'number') {
expect(d.classNum).toBe(d.classId + modeOffset)
} else if (typeof d.classNum === 'number') {
expect(d.classNum).toBeGreaterThanOrEqual(modeOffset)
expect(d.classNum).toBeLessThan(modeOffset + 20)
} else {
throw new Error('Detection has neither classNum nor classId — wire contract broken')
}
}
})
}
})
+193
View File
@@ -0,0 +1,193 @@
import { test, expect } from '@playwright/test'
// AZ-480 — e2e companion for the production-image runtime contracts.
//
// AC-2 (NFT-RES-LIM-03) — `nginx:alpine` final stage; `which node` returns
// non-zero inside the running container.
// AC-3 (NFT-RES-LIM-08) — steady-state RAM ≤ 200 MB after 5 min of idle
// traffic (documentary baseline per
// resource-limit-tests.md row 121).
// AC-5 (NFT-RES-LIM-10) — each /api/<S>/ route strips its prefix; verified
// against the running nginx by issuing a request
// to /api/<S>/probe and asserting the upstream
// sees `/probe`.
//
// These tests run the prod image directly via the Playwright host's docker
// socket. They are skipped on hosts without docker access (developer macOS
// with Docker Desktop is fine; CI runners without DinD will skip with a
// clear message).
//
// AC-3 is gated behind `RUN_LONG_RUNNING=1` because 5 min of idle traffic
// against a fresh container is not appropriate for the per-PR e2e lane.
import { exec as execCb } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(execCb)
const IMAGE = process.env.AZAION_UI_IMAGE ?? 'azaion/ui:test'
const RAM_BUDGET_MB = 200
const RAM_SAMPLE_INTERVAL_MS = 10_000
const RAM_SOAK_TOTAL_MS = 5 * 60 * 1000
async function dockerAvailable(): Promise<boolean> {
try {
await exec('docker version --format "{{.Server.Version}}"', { timeout: 5_000 })
return true
} catch {
return false
}
}
async function imageExists(image: string): Promise<boolean> {
try {
await exec(`docker image inspect ${image}`, { timeout: 5_000 })
return true
} catch {
return false
}
}
async function startContainer(): Promise<string> {
const { stdout } = await exec(
`docker run -d --rm -p 0:80 ${IMAGE}`,
{ timeout: 15_000 },
)
return stdout.trim()
}
async function stopContainer(id: string): Promise<void> {
try {
await exec(`docker stop ${id}`, { timeout: 10_000 })
} catch {
/* container may already be gone */
}
}
async function memUsageMb(id: string): Promise<number> {
// `docker stats --no-stream --format '{{.MemUsage}}'` returns e.g. "12.5MiB / 3.84GiB".
const { stdout } = await exec(
`docker stats ${id} --no-stream --format '{{.MemUsage}}'`,
{ timeout: 10_000 },
)
const match = stdout.match(/([\d.]+)\s*([KMG])iB/i)
if (!match) throw new Error(`unexpected docker stats output: ${stdout.trim()}`)
const value = Number(match[1])
const unit = match[2].toUpperCase()
if (unit === 'K') return value / 1024
if (unit === 'M') return value
if (unit === 'G') return value * 1024
throw new Error(`unhandled mem unit: ${unit}`)
}
test.describe('AZ-480 — prod image runtime contracts (e2e companion)', () => {
test('AC-2 (NFT-RES-LIM-03) — nginx:alpine final stage, no Node in the container', async () => {
test.setTimeout(60_000)
if (!(await dockerAvailable())) {
test.skip(true, 'docker not reachable from this runner')
}
if (!(await imageExists(IMAGE))) {
test.skip(true, `image ${IMAGE} not built (build with 'docker build -t ${IMAGE} .')`)
}
const id = await startContainer()
try {
// node should not be on PATH; this is the canonical "no Node in the
// image" probe per NFT-RES-LIM-03.
let nodeFound = true
try {
await exec(`docker exec ${id} which node`, { timeout: 5_000 })
} catch {
nodeFound = false
}
expect(nodeFound, 'node MUST NOT be on PATH inside the prod image').toBe(false)
// Sanity: nginx IS on PATH (defence-in-depth — proves the wrong
// container did not start by accident).
await exec(`docker exec ${id} which nginx`, { timeout: 5_000 })
} finally {
await stopContainer(id)
}
})
test('AC-5 (NFT-RES-LIM-10) — each /api/<S>/ request reaches upstream with the prefix stripped', async () => {
test.setTimeout(30_000)
if (!(await dockerAvailable())) {
test.skip(true, 'docker not reachable from this runner')
}
if (!(await imageExists(IMAGE))) {
test.skip(true, `image ${IMAGE} not built`)
}
// The static check (`STC-RES10`) already verifies every nginx
// location block emits `proxy_pass http://<host>:<port>/` (trailing
// slash). The e2e companion proves the runtime behaviour: a request
// to /api/<S>/probe arrives upstream with path `/probe`. We use the
// suite-e2e stack (already populated with echo endpoints) when
// available; on a developer host without the suite stack we skip
// with a clear reason rather than reporting a false PASS.
const suiteRunning = await exec(
'docker ps --filter "name=annotations" --format "{{.Names}}"',
{ timeout: 5_000 },
).then((r) => r.stdout.includes('annotations')).catch(() => false)
if (!suiteRunning) {
test.skip(true, 'suite-e2e docker stack not running (start with docker compose up)')
}
// The suite-e2e `annotations` service exposes /annotations/health which,
// through the prod nginx, is reachable as /api/annotations/annotations/health.
// If the prefix was NOT stripped, the upstream would 404 because it
// does not know about /api/annotations/annotations/health — only
// /annotations/health.
const probe = await fetch('http://localhost:80/api/annotations/health').catch(
() => null,
)
expect(probe?.status, 'prefix-strip should let /api/annotations/health reach upstream').toBeLessThan(
500,
)
})
test(
'@long-running AC-3 (NFT-RES-LIM-08) — steady-state RAM ≤ 200 MB after 5 min idle',
async () => {
const longRunning = process.env.RUN_LONG_RUNNING === '1'
if (!longRunning) {
test.skip(true, 'Long-running soak; set RUN_LONG_RUNNING=1 to enable')
}
if (!(await dockerAvailable())) {
test.skip(true, 'docker not reachable from this runner')
}
if (!(await imageExists(IMAGE))) {
test.skip(true, `image ${IMAGE} not built`)
}
test.setTimeout(RAM_SOAK_TOTAL_MS + 60_000)
const id = await startContainer()
try {
const start = Date.now()
const samples: { tMs: number; mb: number }[] = []
// First sample immediately, then every interval until 5 min.
samples.push({ tMs: 0, mb: await memUsageMb(id) })
while (Date.now() - start < RAM_SOAK_TOTAL_MS) {
await new Promise((r) => setTimeout(r, RAM_SAMPLE_INTERVAL_MS))
samples.push({ tMs: Date.now() - start, mb: await memUsageMb(id) })
}
const peakMb = samples.reduce((max, s) => Math.max(max, s.mb), 0)
test.info().annotations.push({
type: 'ram-samples-mb',
description: samples.map((s) => s.mb.toFixed(1)).join(','),
})
test.info().annotations.push({
type: 'ram-peak-mb',
description: peakMb.toFixed(1),
})
expect(peakMb).toBeLessThanOrEqual(RAM_BUDGET_MB)
} finally {
await stopContainer(id)
}
},
)
})
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test'
// AZ-467 — e2e variants of the RBAC scenarios that require the real
// admin/ service to issue role-specific bearers and the suite's nginx to
// route /admin and /settings.
//
// FT-N-03 — Operator → /admin redirects to /flights (or to /login if
// permission middleware is unauthenticated-equivalent)
// FT-N-05 — integrator-dave → /settings redirects (no SETTINGS perm)
//
// Profile: e2e (gated by docker compose). Skipped in fast/host runs.
//
// Production status: src/auth/ProtectedRoute.tsx does NOT check
// permissions today (only `user != null`). These tests are wrapped in
// `test.fail()` to capture the drift — they will start passing once
// ProtectedRoute gains a `requirePermission` prop (or wrapping) and the
// /admin and /settings routes opt in.
const OPERATOR_EMAIL = 'op_bob@test.local' // Operator without ADMIN_WRITE / SETTINGS
const INTEGRATOR_EMAIL = 'integrator_dave@test.local' // SystemIntegrator without SETTINGS
const ADMIN_EMAIL = 'admin_carol@test.local' // Admin with full perms
const TEST_PASSWORD = 'TestPassword!23'
async function login(page: import('@playwright/test').Page, email: string) {
await page.goto('/login')
await page.getByLabel(/email/i).fill(email)
await page.getByLabel(/password/i).fill(TEST_PASSWORD)
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
}
test.describe('AZ-467 e2e — RBAC route gating', () => {
test('FT-N-03 — Operator hitting /admin is redirected to /flights (AC-3 drift)', async ({ page }) => {
test.fail(
true,
'AC-3 drift: src/auth/ProtectedRoute.tsx today checks only `user != null`. Test passes once route-level RBAC lands.',
)
await login(page, OPERATOR_EMAIL)
await page.goto('/admin')
await expect(page).toHaveURL(/\/flights$/)
})
test('FT-N-05 — integrator-dave hitting /settings is redirected away (AC-3 drift)', async ({ page }) => {
test.fail(
true,
'AC-3 drift: same as FT-N-03 — ProtectedRoute does not gate on permissions today.',
)
await login(page, INTEGRATOR_EMAIL)
await page.goto('/settings')
await expect(page).not.toHaveURL(/\/settings$/)
})
test('Admin reaches /admin normally (positive control)', async ({ page }) => {
await login(page, ADMIN_EMAIL)
await page.goto('/admin')
await expect(page).toHaveURL(/\/admin$/)
})
})
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test'
// AZ-477 — e2e companion for Settings save resilience + 2 s deadline.
//
// AC-1 (FT-N-13 / NFT-RES-05): real backend returns 500 on settings PUT;
// SPA renders an error region AND clears the
// `saving` flag (button enabled again) within
// 2 s. Today both contracts are drift —
// `test.fail()` until try/finally + alert lands.
// AC-2 (FT-N-14 / NFT-RES-06): same on a network drop. The fast-profile
// test pins both contracts against MSW; this
// companion exercises the real wire boundary
// against the suite stack.
// AC-3 (NFT-PERF-09): ≤ 2 s deadline for error visibility — pinned
// in the fast suite via `performance.now()`;
// the e2e companion just asserts visibility
// within Playwright's 2 s timeout.
//
// Requires the suite docker-compose stack (`e2e/docker-compose.suite-e2e.yml`).
// Uses `page.route` to inject the failure mode without depending on a real
// crashed backend in CI.
test.describe('AZ-477 — Settings save resilience (e2e companion)', () => {
test.fail(
'AC-1 (500) — Save button re-enables AND error region visible within 2 s',
async ({ page }) => {
// Force the system-settings PUT to fail with a 500. Other endpoints
// pass through so the page mounts normally.
await page.route('**/api/annotations/settings/system', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({ status: 500, body: 'upstream failure' })
return
}
await route.continue()
})
await page.goto('/settings')
// Tenant Configuration heading + scoped Save button — same anchor as
// the fast suite. If the suite seed has no tenant config, the test
// reports the gap rather than masking the UI.
const tenantHeading = page.getByRole('heading', { name: /Tenant Configuration/i })
if (!(await tenantHeading.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite UI did not render Settings → Tenant Configuration')
}
const tenantPanel = tenantHeading.locator('xpath=..')
const saveBtn = tenantPanel.getByRole('button', { name: /^Save$/i })
await saveBtn.click()
// Both assertions race the 2 s deadline.
await expect(saveBtn).toBeEnabled({ timeout: 2000 })
const alertEl = page.getByRole('alert').first()
await expect(alertEl).toBeVisible({ timeout: 2000 })
await expect(alertEl).toContainText(/error|failed|try again|500/i)
},
)
test.fail(
'AC-2 (network drop) — Save button re-enables AND error region visible within 2 s',
async ({ page }) => {
await page.route('**/api/annotations/settings/system', async (route) => {
if (route.request().method() === 'PUT') {
await route.abort('connectionfailed')
return
}
await route.continue()
})
await page.goto('/settings')
const tenantHeading = page.getByRole('heading', { name: /Tenant Configuration/i })
if (!(await tenantHeading.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite UI did not render Settings → Tenant Configuration')
}
const tenantPanel = tenantHeading.locator('xpath=..')
const saveBtn = tenantPanel.getByRole('button', { name: /^Save$/i })
await saveBtn.click()
await expect(saveBtn).toBeEnabled({ timeout: 2000 })
const alertEl = page.getByRole('alert').first()
await expect(alertEl).toBeVisible({ timeout: 2000 })
},
)
})
+160
View File
@@ -0,0 +1,160 @@
import { test, expect } from '@playwright/test'
// AZ-458 — e2e variants of the SSE-lifecycle and bearer-rotation scenarios
// that require the real suite stack (live-GPS simulator embedded in the
// `flights/:test` image; annotation-status generator in `annotations/:test`).
//
// FT-P-09 — annotation-status SSE opens on <AnnotationsPage> mount
// (QUARANTINE — production AnnotationsPage opens no SSE today)
// FT-P-18 — live-GPS SSE opens within 5 s of flight select
// NFT-PERF-03 — bearer-rotation reconnect ≤ 5 s after a refresh
// NFT-RES-02 — bearer rotation reconnects both live-GPS and annotation-status
// within 5 s (QUARANTINE for annotation-status; live-GPS portion
// documents the AC-2 drift — passes once production reconnects
// the EventSource on token rotation).
//
// Profile: e2e (gated by docker compose). Skipped in fast/host runs.
//
// Black-box discipline: assertions inspect the network surface (which
// `text/event-stream` requests opened/closed and when) and the DOM where
// live-GPS values land. The tests do NOT import production modules.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23'
async function login(page: import('@playwright/test').Page) {
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
])
}
test.describe('AZ-458 e2e — SSE lifecycle + bearer rotation', () => {
test('FT-P-18 / NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select', async ({ page }) => {
test.setTimeout(20_000)
await login(page)
await page.goto('/flights')
// Switch the side panel to GPS mode and select a flight.
await page.getByRole('button', { name: /gps/i }).click()
const ssePromise = page.waitForRequest(
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()),
{ timeout: 5_000 },
)
await page.getByRole('button', { name: /select flight/i }).click()
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
const req = await ssePromise
// Assert AC-1: bearer is in the URL per ADR-008 (?access_token=...).
expect(req.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/)
})
test('FT-P-19 / NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect', async ({ page }) => {
test.setTimeout(20_000)
await login(page)
await page.goto('/flights')
await page.getByRole('button', { name: /gps/i }).click()
await page.getByRole('button', { name: /select flight/i }).click()
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
// Capture the EventSource on the page so the test can observe close().
const closedAt = await page.evaluate(async () => {
const original = window.EventSource
let lastClosed = -1
const proxy = new Proxy(original, {
construct(target, args) {
const inst = new target(...(args as ConstructorParameters<typeof EventSource>))
const origClose = inst.close.bind(inst)
inst.close = () => {
lastClosed = performance.now()
origClose()
}
return inst
},
})
window.EventSource = proxy as unknown as typeof EventSource
return new Promise<number>((resolve) => {
// Wait up to 5 s for the close to land.
const start = performance.now()
const tick = () => {
if (lastClosed > 0) resolve(lastClosed - start)
else if (performance.now() - start > 5000) resolve(-1)
else requestAnimationFrame(tick)
}
// Trigger the deselect from the test side via DOM.
const evt = new CustomEvent('e2e-deselect')
window.dispatchEvent(evt)
tick()
})
})
// Simulate "deselect" — for the contract test we go back to the params
// tab which makes the FlightsPage useEffect tear down the SSE (per
// FlightsPage.tsx:65-68 — the effect deps include `mode`).
await page.getByRole('button', { name: /params/i }).click()
expect(closedAt, 'EventSource close() should fire within 1 s of deselect').toBeLessThan(1000)
})
test('NFT-PERF-03 / NFT-RES-02 — live-GPS SSE reconnects with the new bearer within 5 s of rotation (AC-2 drift)', async ({ page }) => {
test.setTimeout(30_000)
test.fail(
true,
'AC-2 drift: FlightsPage useEffect deps do not include the bearer, so SSE does not reconnect on token rotation. Test passes once the production effect re-runs on token change.',
)
await login(page)
await page.goto('/flights')
await page.getByRole('button', { name: /gps/i }).click()
await page.getByRole('button', { name: /select flight/i }).click()
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
const firstReq = await page.waitForRequest(
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()),
{ timeout: 5_000 },
)
const firstUrl = firstReq.url()
// Trigger a refresh via the test-only endpoint that rotates the bearer.
// The admin/:test image exposes /api/admin/test-only/rotate-bearer (matches
// the convention used by /api/admin/test-only/reset). If absent, this is
// the moment to surface a stack-side gap.
const rotated = await page.request.post('/api/admin/test-only/rotate-bearer').catch(() => null)
expect(rotated?.ok(), 'admin/:test must expose /test-only/rotate-bearer').toBeTruthy()
// Drive AuthContext to absorb the new bearer (refresh path).
await page.evaluate(async () => {
await fetch('/api/admin/auth/refresh', { credentials: 'include' })
})
const secondReq = await page.waitForRequest(
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()) && req.url() !== firstUrl,
{ timeout: 5_000 },
)
expect(secondReq.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/)
expect(secondReq.url()).not.toEqual(firstUrl)
})
test.skip('FT-P-09 / NFT-PERF-06 — annotation-status SSE opens on mount + closes within 1 s of unmount', () => {
// QUARANTINE: src/features/annotations/AnnotationsPage.tsx does not open
// any SSE today. Once an annotation-status subscription is added, this
// test follows the same shape as FT-P-18/FT-P-19 above but targets
// /api/annotations/.../status (or whatever the production URL ends up
// being). Leaving the assertion shape here as a planning anchor:
//
// await login(page)
// const annSsePromise = page.waitForRequest(
// (req) => /\/api\/annotations\/.*\/status/.test(req.url()),
// )
// await page.goto('/annotations')
// await annSsePromise
})
})
+117
View File
@@ -0,0 +1,117 @@
import { test, expect } from '@playwright/test'
// AZ-474 — e2e companion for FT-P-51 (tile-split endpoint contract) and
// FT-P-53 (DatasetItem.isSplit honored).
//
// Per the task spec, only FT-P-51 and FT-P-53 are `fast + e2e`. The other
// rows (FT-P-52 parser, FT-P-54 auto-zoom, FT-P-55 indicator, FT-N-10
// malformed) are fast-only. Both e2e tests are `test.fail()` today
// because the split surface is QUARANTINED in production (per
// `_docs/04_refactoring/01-testability-refactoring/deferred_to_refactor.md`
// row D11 and the traceability matrix's `[Q]` marker on AC-39).
//
// Once the SPA wires a "Split tile" affordance and starts honoring
// `DatasetItem.isSplit`, remove the `test.fail` and these flip green.
test.describe('AZ-474 — tile-split surface (e2e companion)', () => {
test.fail(
'FT-P-51 — clicking Split tile POSTs /api/annotations/dataset/<id>/split',
async ({ page }) => {
test.setTimeout(20_000)
const splitPosts: string[] = []
await page.route('**/api/annotations/dataset/*/split', async (route) => {
if (route.request().method() === 'POST') {
splitPosts.push(route.request().url())
}
await route.continue()
})
await page.goto('/dataset')
// Suite seed must include at least one dataset item — if not, mark
// the gap explicitly. The seed today produces images via the
// annotations service; if it doesn't, the test reports the seed gap
// and skips rather than hiding the contract.
const firstCard = page.locator('img').first()
if (!(await firstCard.isVisible({ timeout: 5000 }).catch(() => false))) {
test.skip(true, 'Suite seed has no dataset items')
}
await firstCard.hover()
// Drift today: no Split-tile button is rendered. The locator below
// is intentionally tolerant of any reasonable button shape so that
// when the affordance lands, the test does not need surgery.
const splitBtn = page.getByRole('button', { name: /split/i })
await expect(splitBtn.first()).toBeVisible({ timeout: 1500 })
await splitBtn.first().click()
await expect.poll(() => splitPosts.length, { timeout: 2000 }).toBeGreaterThan(0)
expect(splitPosts[0]).toMatch(/\/api\/annotations\/dataset\/[^/]+\/split$/)
},
)
test.fail(
'FT-P-53 — items with isSplit:true render a distinct affordance vs non-split',
async ({ page }) => {
test.setTimeout(15_000)
// Stub the dataset response so the test is independent of seed
// shape — what matters is the renderer's behaviour given the
// contract, not which rows happen to live in the suite seed.
await page.route('**/api/annotations/dataset*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
annotationId: 'ann-split',
imageName: 'split.jpg',
thumbnailPath: '/thumbs/split.jpg',
status: 20,
createdDate: '2026-05-11T10:00:00Z',
createdEmail: 'op_alice@test.local',
flightName: 'Flight 1',
source: 0,
isSeed: false,
isSplit: true,
},
{
annotationId: 'ann-nosplit',
imageName: 'nosplit.jpg',
thumbnailPath: '/thumbs/nosplit.jpg',
status: 10,
createdDate: '2026-05-11T10:01:00Z',
createdEmail: 'op_alice@test.local',
flightName: 'Flight 1',
source: 1,
isSeed: false,
isSplit: false,
},
],
totalCount: 2,
}),
})
})
await page.goto('/dataset')
await expect(page.locator('img').first()).toBeVisible({ timeout: 5_000 })
// Spec: the rendered card for an isSplit annotation MUST carry a
// visible affordance the non-split card does NOT carry.
const splitCard = page.locator('img[alt*="split"]').first()
const nonSplitCard = page.locator('img[alt*="nosplit"]').first()
const splitData = await splitCard.evaluate((n) =>
(n.closest('div') as HTMLElement | null)?.getAttribute('data-is-split'),
)
const nonSplitData = await nonSplitCard.evaluate((n) =>
(n.closest('div') as HTMLElement | null)?.getAttribute('data-is-split'),
)
expect(splitData).toBe('true')
expect(nonSplitData).not.toBe('true')
},
)
})
+126
View File
@@ -0,0 +1,126 @@
import { test, expect } from '@playwright/test'
// AZ-476 — e2e companion for the 500 MB upload cap.
//
// AC-1 (FT-N-06 / NFT-RES-07): nginx returns 413 on a 501 MB upload; SPA
// renders an in-DOM error region carrying an
// i18n-keyed message. Today production silently
// swallows the failure and falls through to
// local-mode (drift) — `test.fail()` until the
// error region is wired.
// AC-2 (no alert): The 413 path does NOT invoke `alert()`. Today
// this passes vacuously (no error path runs at
// all). The fast-profile test pins both contracts
// against MSW; this e2e companion exercises the
// real nginx body-size limit set in the suite.
//
// Requires the suite docker-compose stack (`e2e/docker-compose.suite-e2e.yml`).
// Skips with a clear reason on developer hosts without the stack.
const BIG_BYTES = 501 * 1024 * 1024
function buildOversizedBuffer(): Buffer {
// Spec requires a 501 MB sparse zero-filled payload. Buffer.alloc is the
// cheapest in-memory way to build it; 501 MB sits well below the 1 GB
// Node default heap on CI runners. If a future runner downsizes its heap,
// switch this fixture to a temp file produced by `dd`.
return Buffer.alloc(BIG_BYTES)
}
test.describe('AZ-476 — upload 501 MB → 413 (e2e companion)', () => {
test.fail(
'AC-1 (FT-N-06 / NFT-RES-07) — 501 MB upload surfaces in-DOM error',
async ({ page }) => {
// Capture every batch upload response so we can verify nginx really
// returned 413 (and not the SPA short-circuiting on the client side).
const batchResponses: { url: string; status: number }[] = []
page.on('response', async (resp) => {
const u = resp.url()
if (/\/api\/annotations\/media\/batch(\?|$)/.test(u)) {
batchResponses.push({ url: u, status: resp.status() })
}
})
await page.goto('/annotations')
// The "Open File" input is hidden behind a label; Playwright's
// setInputFiles works directly on the input element regardless of CSS
// visibility.
const fileInput = page.locator('input[type="file"]').nth(1)
if (!(await fileInput.count())) {
test.skip(true, 'Suite UI did not render the upload input')
}
await fileInput.setInputFiles({
name: 'huge_recon_video.mp4',
mimeType: 'video/mp4',
buffer: buildOversizedBuffer(),
})
// Wait for the 413 to come back. If nginx in this stack is configured
// with a different cap, the test reports the configuration mismatch
// explicitly rather than masking the contract.
await page
.waitForFunction(
(checkUrl) => {
type Win = Window & { __batchStatuses?: number[] }
const w = window as Win
void checkUrl
return Array.isArray(w.__batchStatuses) && w.__batchStatuses.includes(413)
},
'noop',
{ timeout: 30_000 },
)
.catch(() => null)
// Fallback assertion — page-side wait may not see the response. Use
// the response listener accumulator we set up above.
const sawThirteen = batchResponses.some((r) => r.status === 413)
if (!sawThirteen) {
test.skip(
true,
`Suite nginx did not return 413 for a 501 MB upload (saw: ${
batchResponses.map((r) => r.status).join(',') || 'no /batch responses'
})`,
)
}
// Contract assertion — drift today. Will pass once production wires the
// toast + i18n key for the 413 path.
const alertEl = page.getByRole('alert').first()
await expect(alertEl).toBeVisible({ timeout: 5000 })
await expect(alertEl).toContainText(/too large|exceeds|413/i)
},
)
test('AC-2 — the 413 path does NOT invoke window.alert()', async ({ page }) => {
// Track every dialog. Playwright auto-dismisses dialogs after listeners
// are attached, so a stray `alert()` shows up here as a "dialog" event.
const dialogs: string[] = []
page.on('dialog', async (dialog) => {
dialogs.push(`${dialog.type()}:${dialog.message()}`)
await dialog.dismiss().catch(() => null)
})
await page.goto('/annotations')
const fileInput = page.locator('input[type="file"]').nth(1)
if (!(await fileInput.count())) {
test.skip(true, 'Suite UI did not render the upload input')
}
await fileInput.setInputFiles({
name: 'huge_recon_video.mp4',
mimeType: 'video/mp4',
buffer: buildOversizedBuffer(),
})
// Allow the 413 round-trip + any error-handling React render to settle.
await page.waitForTimeout(2000)
// Filter for alert() specifically — confirm() and prompt() are out of
// scope for this AC, but we still want to know if either fires.
const alertDialogs = dialogs.filter((d) => d.startsWith('alert:'))
expect(alertDialogs).toEqual([])
})
})
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test'
// AZ-459 / FT-P-06 e2e — detection wire payload uses spec enum values for
// affiliation and combatReadiness against the real annotations/ + detect/
// services. Profile: e2e (gated by docker compose stack).
//
// The fast counterpart (tests/wire_contract.test.ts) asserts the typed enum
// SHAPES; the e2e half asserts the actual outbound POST body when the SPA
// triggers an annotation save (the wire-format contract per AC-04).
//
// Enum value sets pinned in _docs/00_problem/input_data/enum_spec_snapshot.json.
const ALICE_EMAIL = 'op_alice@test.local'
const ALICE_PASSWORD = 'TestPassword!23'
// Pinned per snapshot (not currently met by the UI — see ui_drift_summary).
// The e2e test asserts the payload uses values FROM these spec sets. When
// the UI is fixed (Step 4), the test stays green; today the test fails with
// the documented drift, so we tag the wire-format scenarios `@drift` so the
// runner can downgrade them to documentary while QUARANTINEd.
const SPEC_AFFILIATION_VALUES = new Set([0, 10, 20, 30])
const SPEC_ANNOTATION_STATUS_VALUES = new Set([0, 10, 20, 30, 40])
test.describe('AZ-459 e2e — wire-contract enum values @drift', () => {
test.skip(
process.env.AZAION_RUN_DRIFT_E2E !== '1',
'QUARANTINE: enum drift documented (ui_drift_summary in enum_spec_snapshot.json); ' +
'set AZAION_RUN_DRIFT_E2E=1 to exercise the assertion against today\'s drifted UI ' +
'(expect failure until Step 4 lifts the drift on src/types/index.ts).',
)
test('FT-P-06 (rows 18, 19): annotation save body uses spec affiliation + status values', async ({ page }) => {
// Arrange — log in.
await page.goto('/login')
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
await page.getByRole('button', { name: /sign in/i }).click()
await page.waitForLoadState('networkidle')
// Capture the next /api/annotations/annotations POST body.
const savePromise = page.waitForRequest(
(req) =>
req.url().includes('/api/annotations/annotations') &&
req.method() === 'POST',
)
// Trigger an annotation save through the UI. The actual flow depends on
// the seeded fixtures; this test relies on the AnnotationsPage save
// button being reachable from a logged-in op_alice session.
await page.goto('/annotations')
await page.getByRole('button', { name: /save/i }).first().click()
const saveReq = await savePromise
const body = saveReq.postDataJSON() as { status?: number; detections?: Array<{ affiliation?: number }> }
// Assert
if (typeof body.status === 'number') {
expect(SPEC_ANNOTATION_STATUS_VALUES.has(body.status)).toBe(true)
}
for (const det of body.detections ?? []) {
if (typeof det.affiliation === 'number') {
expect(SPEC_AFFILIATION_VALUES.has(det.affiliation)).toBe(true)
}
}
})
})