|
| 1 | +import { test, expect, Page, Request } from '@playwright/test'; |
| 2 | +import { signInAndWaitForApp } from '../../../support/auth-flow-helpers'; |
| 3 | +import { generateRandomEmail } from '../../../support/test-config'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Editor file-block / image-block popover upload regression tests. |
| 7 | + * |
| 8 | + * Covers the popover code paths in: |
| 9 | + * - src/components/editor/components/block-popover/FileBlockPopoverContent.tsx |
| 10 | + * - src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx |
| 11 | + * |
| 12 | + * The critical regression this guards against: after the refactor that |
| 13 | + * persists a local IndexedDB snapshot *before* kicking off the remote upload, |
| 14 | + * an IndexedDB write failure (private browsing, quota exceeded, etc.) must |
| 15 | + * not block the remote upload — the popover should still POST the file to |
| 16 | + * the server. |
| 17 | + * |
| 18 | + * We assert at the network layer rather than the rendered URL, because |
| 19 | + * a brand-new test user may not have permissions to fetch the resulting |
| 20 | + * file URL back from the storage endpoint, but the upload POST itself is |
| 21 | + * the regression signal we care about. |
| 22 | + */ |
| 23 | +test.describe('Feature: Editor block popover upload', () => { |
| 24 | + // Each test creates a new user via GoTrue, which can't handle parallel auth. |
| 25 | + test.describe.configure({ mode: 'serial' }); |
| 26 | + |
| 27 | + let page: Page; |
| 28 | + |
| 29 | + test.beforeEach(async ({ page: testPage, request }) => { |
| 30 | + page = testPage; |
| 31 | + await signInAndWaitForApp(page, request, generateRandomEmail()); |
| 32 | + await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 }); |
| 33 | + }); |
| 34 | + |
| 35 | + /** |
| 36 | + * Create a new doc page via the inline-add button. |
| 37 | + */ |
| 38 | + async function createNewDocPage(): Promise<void> { |
| 39 | + const addBtn = page.locator('[data-testid="inline-add-page"]').first(); |
| 40 | + await addBtn.click(); |
| 41 | + await page.waitForTimeout(500); |
| 42 | + await page.getByText('Document', { exact: true }).first().click(); |
| 43 | + await page.waitForTimeout(2000); |
| 44 | + } |
| 45 | + |
| 46 | + function getEditor() { |
| 47 | + return page.locator('[data-testid="editor-content"]').last(); |
| 48 | + } |
| 49 | + |
| 50 | + /** |
| 51 | + * Insert a block via the slash menu by key (e.g. 'file', 'image'). |
| 52 | + */ |
| 53 | + async function insertBlockViaSlash(slashKey: 'file' | 'image'): Promise<void> { |
| 54 | + const editor = getEditor(); |
| 55 | + await editor.click({ force: true, position: { x: 100, y: 10 } }); |
| 56 | + await page.waitForTimeout(300); |
| 57 | + await page.keyboard.type(`/${slashKey}`); |
| 58 | + await page.waitForTimeout(600); |
| 59 | + await page.locator(`[data-testid="slash-menu-${slashKey}"]`).click(); |
| 60 | + await page.waitForTimeout(600); |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * Start collecting any request whose URL contains `file_storage` (covers |
| 65 | + * single-shot uploads, presigned URL fetches, and multipart endpoints). |
| 66 | + * Returns an array reference that fills as requests arrive. |
| 67 | + */ |
| 68 | + function captureUploadRequests(): Request[] { |
| 69 | + const captured: Request[] = []; |
| 70 | + page.on('request', (req) => { |
| 71 | + if (req.url().includes('file_storage') || req.url().includes('/upload')) { |
| 72 | + captured.push(req); |
| 73 | + } |
| 74 | + }); |
| 75 | + return captured; |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * 1×1 transparent PNG (smallest valid PNG, ~70 bytes). |
| 80 | + */ |
| 81 | + const TINY_PNG = Buffer.from( |
| 82 | + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', |
| 83 | + 'base64' |
| 84 | + ); |
| 85 | + |
| 86 | + test('Given a File block popover, when user uploads a file, then the remote upload endpoint is hit', async () => { |
| 87 | + const uploadRequests = captureUploadRequests(); |
| 88 | + |
| 89 | + await createNewDocPage(); |
| 90 | + await insertBlockViaSlash('file'); |
| 91 | + |
| 92 | + const dropzone = page.getByTestId('file-dropzone'); |
| 93 | + await expect(dropzone).toBeVisible({ timeout: 10000 }); |
| 94 | + |
| 95 | + const fileInput = dropzone.locator('input[type="file"]'); |
| 96 | + await fileInput.setInputFiles({ |
| 97 | + name: 'regression.bin', |
| 98 | + mimeType: 'application/octet-stream', |
| 99 | + buffer: Buffer.from('hello world from regression test'), |
| 100 | + }); |
| 101 | + |
| 102 | + // The popover must hand the file off to the remote upload endpoint, not |
| 103 | + // just persist it locally. The local IndexedDB save and the remote upload |
| 104 | + // were re-ordered in a recent refactor; this catches a regression where |
| 105 | + // a missing/failed local save would short-circuit the remote upload. |
| 106 | + await expect |
| 107 | + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { |
| 108 | + timeout: 30000, |
| 109 | + message: 'no upload request fired for file block', |
| 110 | + }) |
| 111 | + .toBeGreaterThan(0); |
| 112 | + |
| 113 | + // The block also flips out of its empty state (the file name appears). |
| 114 | + await expect(getEditor()).toContainText('regression.bin', { timeout: 30000 }); |
| 115 | + }); |
| 116 | + |
| 117 | + test('Given an Image block popover, when user uploads an image, then the remote upload endpoint is hit', async () => { |
| 118 | + const uploadRequests = captureUploadRequests(); |
| 119 | + |
| 120 | + await createNewDocPage(); |
| 121 | + await insertBlockViaSlash('image'); |
| 122 | + |
| 123 | + const dropzone = page.getByTestId('file-dropzone'); |
| 124 | + await expect(dropzone).toBeVisible({ timeout: 10000 }); |
| 125 | + |
| 126 | + const fileInput = dropzone.locator('input[type="file"]'); |
| 127 | + await fileInput.setInputFiles({ |
| 128 | + name: 'regression.png', |
| 129 | + mimeType: 'image/png', |
| 130 | + buffer: TINY_PNG, |
| 131 | + }); |
| 132 | + |
| 133 | + await expect |
| 134 | + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { |
| 135 | + timeout: 30000, |
| 136 | + message: 'no upload request fired for image block', |
| 137 | + }) |
| 138 | + .toBeGreaterThan(0); |
| 139 | + |
| 140 | + // The block flips out of its empty state and renders an <img>. |
| 141 | + await expect(getEditor().locator('img').first()).toBeVisible({ timeout: 30000 }); |
| 142 | + }); |
| 143 | + |
| 144 | + test('Given the local FileStorage database is unavailable, when user uploads via the popover, then the remote upload still fires', async () => { |
| 145 | + // Make ONLY the FileStorage IndexedDB database (used by the popover's |
| 146 | + // local retry-snapshot) fail to open. The rest of the app — including |
| 147 | + // its own state databases — is left untouched, so we don't blow up the |
| 148 | + // editor itself. |
| 149 | + // |
| 150 | + // This simulates a private-browsing or quota-exhausted state where the |
| 151 | + // local retry snapshot cannot be persisted but the remote upload must |
| 152 | + // still proceed. |
| 153 | + await page.addInitScript(() => { |
| 154 | + const originalOpen = window.indexedDB.open.bind(window.indexedDB); |
| 155 | + |
| 156 | + // eslint-disable-next-line |
| 157 | + (window.indexedDB as any).open = function (name: string, version?: number): IDBOpenDBRequest { |
| 158 | + if (name === 'FileStorage') { |
| 159 | + const fakeRequest: Partial<IDBOpenDBRequest> & { |
| 160 | + onerror: ((ev: Event) => void) | null; |
| 161 | + onsuccess: ((ev: Event) => void) | null; |
| 162 | + onupgradeneeded: ((ev: Event) => void) | null; |
| 163 | + onblocked: ((ev: Event) => void) | null; |
| 164 | + error: DOMException | null; |
| 165 | + result: IDBDatabase | null; |
| 166 | + } = { |
| 167 | + onerror: null, |
| 168 | + onsuccess: null, |
| 169 | + onupgradeneeded: null, |
| 170 | + onblocked: null, |
| 171 | + error: new DOMException('FileStorage disabled for test', 'QuotaExceededError'), |
| 172 | + result: null, |
| 173 | + }; |
| 174 | + |
| 175 | + setTimeout(() => { |
| 176 | + if (typeof fakeRequest.onerror === 'function') { |
| 177 | + fakeRequest.onerror(new Event('error')); |
| 178 | + } |
| 179 | + }, 0); |
| 180 | + |
| 181 | + return fakeRequest as IDBOpenDBRequest; |
| 182 | + } |
| 183 | + |
| 184 | + return originalOpen(name, version); |
| 185 | + }; |
| 186 | + }); |
| 187 | + |
| 188 | + const uploadRequests = captureUploadRequests(); |
| 189 | + |
| 190 | + // Re-load the app under the patched environment. |
| 191 | + await page.goto('http://localhost:3000/app'); |
| 192 | + await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 }); |
| 193 | + |
| 194 | + await createNewDocPage(); |
| 195 | + await insertBlockViaSlash('file'); |
| 196 | + |
| 197 | + const dropzone = page.getByTestId('file-dropzone'); |
| 198 | + await expect(dropzone).toBeVisible({ timeout: 10000 }); |
| 199 | + |
| 200 | + const fileInput = dropzone.locator('input[type="file"]'); |
| 201 | + await fileInput.setInputFiles({ |
| 202 | + name: 'no-idb.bin', |
| 203 | + mimeType: 'application/octet-stream', |
| 204 | + buffer: Buffer.from('upload should still reach the server'), |
| 205 | + }); |
| 206 | + |
| 207 | + // The regression we're guarding against: when the local IndexedDB save |
| 208 | + // rejects, the popover code must NOT swallow that error and skip the |
| 209 | + // remote upload. A non-GET request to a file storage endpoint must still |
| 210 | + // fire. |
| 211 | + await expect |
| 212 | + .poll(() => uploadRequests.filter((r) => r.method() !== 'GET').length, { |
| 213 | + timeout: 30000, |
| 214 | + message: 'no upload request fired when IndexedDB was disabled', |
| 215 | + }) |
| 216 | + .toBeGreaterThan(0); |
| 217 | + }); |
| 218 | +}); |
0 commit comments