Files
ui/e2e/tests/photo_mode.e2e.ts
Oleksandr Bezdieniezhnykh cdebfccada
ci/woodpecker/push/build-arm Pipeline was successful
[AZ-471] [AZ-473] [AZ-478] [AZ-479] Batch 7 - canvas/photo-mode/network/perf tests
- AZ-471 CanvasEditor draw + 8-handle resize PASS (FT-P-39 fast +
  e2e + FT-P-40 8 sub-tests). Three drifts pinned via it.fails():
  Ctrl+click multi-select (FT-P-41), Ctrl+wheel zoom-around-cursor
  (FT-P-42), Ctrl+drag empty-canvas pan (FT-P-43) — all rooted in
  handleMouseDown's early Ctrl-gate and handleWheel's
  pan-not-adjusted bug.
- AZ-473 PhotoMode 3 ACs all PASS in fast + e2e (FT-P-48 switch
  filter, FT-P-49 auto-select, FT-P-50 yoloId wire across modes
  P=0/20/40 — outbound classNum == classId + photoModeOffset).
- AZ-478 fast 7 + e2e 2: AC-1 user-visible offline indicator,
  AC-2 tainted-canvas fallback, AC-3 SSE disconnect banner —
  all drift today (it.fails fast + test.fail e2e + control
  PASS for each). Service-worker negative check passes.
- AZ-479 AC-1 (bundle <= 2 MB gzipped) promoted from
  on-demand perf script to per-commit static profile via new
  STC-PERF01 row + static_check_bundle_size in run-tests.sh.
  AC-2 (mission-planner exclusion) already covered by STC-S5.
  AC-3 FCP /flights <= 3 s median (chromium suite-e2e) and
  AC-4 30-min annotation soak (RUN_LONG_RUNNING=1, chromium)
  scaffolded as e2e tests.

Code review: PASS (0 findings). Fast: 25/25 files, 150 passed
/ 13 skipped. Static: 25/25 PASS (incl. new STC-PERF01).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 05:58:55 +03:00

133 lines
5.3 KiB
TypeScript

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')
}
}
})
}
})