Skip to content

Commit 783cc34

Browse files
authored
Add editor file upload support (#347)
* Add editor file upload support * Fix stale file upload finalization * Fix file upload e2e auth setup
1 parent dd49aea commit 783cc34

15 files changed

Lines changed: 1722 additions & 319 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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

Comments
 (0)