mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:31:11 +00:00
[AZ-463] [AZ-469] [AZ-476] [AZ-477] Batch 6 - flight/responsive/upload/settings tests
- AZ-463 flight selection persistence (FT-P-16) + rehydration
on boot (FT-P-17) PASS at the wire; 100-cycle leak guard
(NFT-RES-LIM-07) and 1h SSE soak (NFT-RES-LIM-06)
scaffolded as RUN_LONG_RUNNING-gated e2e companions.
- AZ-469 browser-support smoke (FT-P-34) runs in both
Chromium and Firefox via the existing playwright config;
responsive variants (FT-P-35 480px / FT-P-36 1024px) PASS
in fast (Tailwind class shape) and e2e (visibility).
- AZ-476 upload 501 MB -> 413: AC-1 user-visible error is
drift today (uploadFiles silently falls through to local
mode); it.fails() + control + e2e test.fail. AC-2 no-alert
PASS via dialog spy.
- AZ-477 settings save 500 / network drop: AC-1+AC-2+AC-3
all drift today (no try/finally, no error region, deadline
unmeasurable); 4 it.fails() + control pinning the stuck-
disabled drift; e2e companions test.fail mirror it.
- LESSONS.md seeded: vi.stubGlobal('URL', {...URL,...})
destroys the URL constructor and breaks new URL(...) in
MSW; patch the methods directly instead.
Code review: PASS (0 findings). Fast: 22/22 files, 120
passed / 13 skipped. Static: 24/24 PASS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user