Skip to content

Commit dee9bca

Browse files
committed
Add editor file upload support
1 parent dd49aea commit dee9bca

10 files changed

Lines changed: 1515 additions & 123 deletions

File tree

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

0 commit comments

Comments
 (0)