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([]) }) })