mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:01:10 +00:00
[AZ-461] [AZ-464] [AZ-470] [AZ-472] Batch 5 - detection/bulk-validate/panel-width/classes tests
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
- AZ-461 sync image detect URL canary (FT-P-11) PASS;
async-video QUARANTINE (FT-P-12) + X-Refresh-Token drift
(FT-P-13) recorded as it.fails() with controls.
- AZ-464 bulk-validate URL + UI sync (≤2 s) PASS;
body shape drift {annotationIds,status} vs contract
{ids,targetStatus:30} captured as it.fails().
- AZ-470 panel-width debounce + rehydration: entire task
is Phase-B target (useResizablePanel has no PUT writer
/ no rehydration); 3 ACs as it.fails() with controls.
- AZ-472 DetectionClasses load + click + fallback PASS;
hotkey arithmetic P=0 PASS, P=20/P=40 it.fails() for
classes[idx+P]-against-dense-array drift.
Code review: PASS (0 findings). Fast: 18/18 files,
102 passed / 13 skipped. Static: 21/21 PASS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user