From 938263cd6a226e07626ae22cb54cb58935c05a83 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 28 Sep 2026 02:28:20 +0200 Subject: [PATCH] feat: image upload on articles --- features/editor_image_upload.feature | 136 ++++++ features/editor_image_upload.qa.md | 90 ++++ web/src/components/ArticleEditor.vue | 14 +- web/src/components/ArticleTable.vue | 2 +- web/src/components/ImageUploader.vue | 263 ++++++++++++ .../__tests__/ImageUploader.spec.ts | 397 ++++++++++++++++++ web/src/composables/useImageUpload.ts | 59 +++ web/src/lib/imageUpload.ts | 44 ++ web/src/views/EditorView.vue | 130 +++--- .../views/__tests__/EditorImageUpload.spec.ts | 257 ++++++++++++ web/visual/states.ts | 108 +++++ web/vite.config.ts | 1 + 12 files changed, 1436 insertions(+), 65 deletions(-) create mode 100644 features/editor_image_upload.feature create mode 100644 features/editor_image_upload.qa.md create mode 100644 web/src/components/ImageUploader.vue create mode 100644 web/src/components/__tests__/ImageUploader.spec.ts create mode 100644 web/src/composables/useImageUpload.ts create mode 100644 web/src/lib/imageUpload.ts create mode 100644 web/src/views/__tests__/EditorImageUpload.spec.ts diff --git a/features/editor_image_upload.feature b/features/editor_image_upload.feature new file mode 100644 index 0000000..74f3cbc --- /dev/null +++ b/features/editor_image_upload.feature @@ -0,0 +1,136 @@ +Feature: Wiring the editor's image upload flow + + As a verified RenewCMS owner writing an article + I want the editor's image button (and dropping a file onto the editor) to open an upload + modal that validates the file, shows progress, and inserts the result at my cursor + So that I can add images to an article's body without leaving the editor, with clear + feedback when a file is rejected or the upload fails + + Background: + Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens + ImageUploader (a UModal titled "Add an image") with the current article's id + And dropping a file directly onto the editor surface also opens ImageUploader, with that + file already provided to it instead of an empty idle state + And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME + type and its file extension against png/jpeg/webp/svg, and that its size is at most + 10 × 1024 × 1024 bytes, before sending anything + And a file that passes validation is uploaded via a new useImageUpload composable, using + XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with + the file under the field name "file", to POST /v1/article/{articleId}/image, per + features/upload_article_image.feature's already-implemented and already-mounted contract + And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the + current cursor position via an exposed insertImage(src, alt) method, with alt ""; this + moves useArticleDraft's state to "dirty" the same way any other body edit does + And closing the modal while an upload is in flight aborts the XMLHttpRequest + And a 401 response is handled entirely by useApi's global handling + (features/api_client_global_error_handling.feature) and is out of scope for new scenarios + here; the same is true of a 404 (the article was deleted elsewhere), which the editor's + existing not-found handling already covers (features/editor_core.feature) + + Scenario: Opening the uploader from the toolbar's image button shows the idle state + Given the user is on /article/12/edit with the editor focused + When the user activates the toolbar's "Image" button + Then ImageUploader opens titled "Add an image" + And it shows "Drop an image here", a "Choose file" action, and the help text + "PNG, JPEG, WEBP, SVG · max 10 MB" + And its cancel action reads "Cancel" + And no request has been sent + + Scenario: Dragging a file over the editor surface shows the dragging-over state + Given ImageUploader is open in the idle state + When the user drags a file over the drop zone + Then the drop zone reads "Drop to upload" + + Scenario: Dropping a valid file onto the editor opens the uploader already uploading it + Given the user is on /article/12/edit with the editor focused and ImageUploader is not open + When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto + the editor surface + Then ImageUploader opens titled "Add an image" + And POST /v1/article/12/image is sent with that file under the field name "file" + And the uploading state is shown, not the idle state + + Scenario Outline: Choosing a file the browser can reject never sends a request + Given ImageUploader is open in the idle state for article 12 + When the user chooses a file named "", content type "", size + Then no request is sent to POST /v1/article/12/image + And the rejected state shows the title "\"\" can't be used" + And its text reads "" + And its confirm action reads "Choose another file" and its cancel action reads "Cancel" + + Examples: + | filename | content_type | size | description | + | plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | + | disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | + | north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | + + Scenario: "Choose another file" from the rejected state returns to idle + Given ImageUploader is in the rejected state for a file named "plan.pdf" + When the user activates "Choose another file" + Then the idle state is shown again, with the file picker available + + Scenario: A valid upload shows progress and its cancel action reads "Cancel upload" + Given ImageUploader is open in the idle state for article 12 + When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB + Then POST /v1/article/12/image is sent with that file under the field name "file" + And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB" + And its cancel action reads "Cancel upload" + When the upload reports 2.4 MB sent of 3.9 MB + Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB" + + Scenario: Closing the dialog during an upload cancels it + Given ImageUploader is uploading a file named "north-wall.jpg" to article 12 + When the user activates "Cancel upload" (or otherwise closes the modal) + Then the in-flight XMLHttpRequest is aborted + And the modal closes + And the editor's body is unchanged and remains in its previous dirty/clean state + + Scenario: A successful upload inserts the image at the cursor and marks the draft dirty + Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a + file named "north-wall.jpg" to article 12 + When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"} + Then the uploading state briefly shows "Image added" + And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's + content at the cursor via insertImage + And SaveStatus reads "Unsaved changes" and the Save button becomes enabled + And a toast appears: color success, icon i-lucide-circle-check, title "Image added", + description "Save to keep it in the article." + And the modal closes 600 ms after the response resolves + + Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry + Given ImageUploader is uploading a file named "north-wall.jpg" to article 12 + When POST /v1/article/12/image + Then the failed state shows the title "Upload failed" + And its text reads "The server didn't accept the file. Your article hasn't changed." + And its confirm action reads "Try again" + And the editor's body is unchanged, and no image is inserted + + Examples: + | failure | + | resolves with a 500 response | + | never reaches the server | + + Scenario: "Try again" re-attempts the upload with the same file + Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12, + after a 500 response + When the user activates "Try again" + Then POST /v1/article/12/image is sent again with that same file under the field name "file" + And the uploading state is shown + + Scenario: A server 400 for a type the client should have caught shows the same rejected copy + Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that + defensively passed the browser's own check, e.g. a MIME type the browser misreported) + When POST /v1/article/12/image resolves 400 + Then the rejected state shows the title "\"notes.pdf\" can't be used" + And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB." + And its confirm action reads "Choose another file" + And the editor's body is unchanged, and no image is inserted + + Scenario: A server 400 for a size the client should have caught shows the same size-named copy + Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that + defensively passed the browser's own size check somehow, e.g. its reported size changed + between selection and send) + When POST /v1/article/12/image resolves 400 + Then the rejected state shows the title "\"north-wall.tif\" can't be used" + And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB." + And its confirm action reads "Choose another file" + And the editor's body is unchanged, and no image is inserted diff --git a/features/editor_image_upload.qa.md b/features/editor_image_upload.qa.md new file mode 100644 index 0000000..9531f3d --- /dev/null +++ b/features/editor_image_upload.qa.md @@ -0,0 +1,90 @@ +# QA: Wiring the editor's image upload flow + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing +`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not +involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the +editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online +false), GET already resolved 200. + +1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert + ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file", + "PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created. +2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on + the drop zone; assert the text changes to "Drop to upload". +3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader + closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor + surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that + file, and the uploading state (not idle) is shown. +4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf` + (application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif` + (image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the + rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples. +5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another + file"; assert the idle state re-renders. +6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg, + 3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads + "Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 * + 1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB". +7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel + upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body + and dirty state are unchanged from before the upload started. +8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve + the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`; + assert `insertImage` (or the editor's exposed method) was called with that location; assert + SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color + success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in + the article."; advance fake timers 600 ms and assert the modal has closed. +9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples- + driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert + the failed state shows title "Upload failed", text "The server didn't accept the file. Your + article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's + body/dirty state is unchanged. +10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click + "Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and + the uploading state is shown again. +11. **A server 400 for a type the client should have caught shows the rejected copy** — from an + uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected + state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image, + up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call. +12. **A server 400 for a size the client should have caught shows the size-named rejected copy** + — from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert + the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is + 14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage` + call. + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress", +"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back- +office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock +setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title +"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data +from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded. + +- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel". +- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading + 2.4 of 3.9 MB", cancel "Cancel upload". +- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the + board's script, cancel "Cancel", confirm "Choose another file". +- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status + "draft", save "dirty". +- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor + modals (leading arrow-left button, trailing ellipsis in the header behind it). +- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus + moves into the modal on open and is trapped there; Esc during "Upload: in progress" + behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` / + `aria-valuenow` updates are announced without spamming on every progress tick. + - [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms + auto-close is not visually checked as a capture (it's a timing behavior, not a static + state) — cover only via the Vitest fake-timer assertion above. + +## Manual verification + +- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`; + confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the + real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s + httptest suite covers the handler in isolation but never through this SPA flow. +- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a + real OS-level drag of an image file onto the editor in a real browser. diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue index 9b3d063..d4c70c3 100644 --- a/web/src/components/ArticleEditor.vue +++ b/web/src/components/ArticleEditor.vue @@ -42,9 +42,7 @@ function currentBlockLabel(editor: Editor) { } function isDefaultAlignment(editor: Editor) { - return !['center', 'right', 'justify'].some((align) => - editor.isActive({ textAlign: align }), - ) + return !['center', 'right', 'justify'].some((align) => editor.isActive({ textAlign: align })) } function buildItems(editor: Editor): EditorToolbarItem[][] { @@ -72,8 +70,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] { { icon: 'i-lucide-pilcrow', label: 'Paragraph', - onSelect: () => - editor.chain().focus().setParagraph().run(), + onSelect: () => editor.chain().focus().setParagraph().run(), }, { kind: 'heading', @@ -201,6 +198,9 @@ defineExpose({ focus() { editorInstance.value?.commands.focus() }, + insertImage(src: string, alt: string) { + editorInstance.value?.commands.setImage({ src, alt }) + }, }) @@ -226,9 +226,7 @@ defineExpose({
- -
+
@@ -324,9 +341,7 @@ onBeforeRouteLeave(async () => { aria-label="Back to Articles" @click="goBack" /> -
+

{

- - - + + +
-
+ + +
void>> = {} + private uploadListeners: Record void> = {} + + upload = { + addEventListener: ( + type: string, + cb: (event: { loaded: number; total: number }) => void, + ) => { + this.uploadListeners[type] = cb + }, + } + + constructor() { + FakeXMLHttpRequest.instances.push(this) + } + + open(method: string, url: string) { + this.method = method + this.url = url + } + + send(body: FormData) { + this.sentBody = body + } + + abort() { + this.dispatch('abort') + } + + addEventListener(type: string, cb: (event?: unknown) => void) { + this.listeners[type] = this.listeners[type] || [] + this.listeners[type].push(cb) + } + + dispatch(type: string, event?: unknown) { + ;(this.listeners[type] || []).forEach((cb) => cb(event)) + } + + resolve(status: number, body: string) { + this.status = status + this.responseText = body + this.dispatch('load') + } +} + +function latestXHR() { + return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1] +} + +function mib(value: number) { + return Math.round(value * 1024 * 1024) +} + +function fileFixture(name: string, type: string, sizeMB: number) { + const file = new File(['x'], name, { type }) + Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true }) + return file +} + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function routeFor(url: string, init?: RequestInit) { + const method = init?.method ?? 'GET' + return `${method} ${url}` +} + +function fetchRouter(handlers: Record Response | Promise>) { + return vi.fn((url: string, init?: RequestInit) => { + const key = routeFor(url, init) + const handler = handlers[key] + if (!handler) { + throw new Error(`Unexpected fetch call: ${key}`) + } + return handler() + }) +} + +function articleFixture(overrides: Partial
= {}): Article { + return { + id: 12, + title: 'How we price a bathroom renovation', + body: '

a

', + images: [], + is_online: false, + created_at: '2026-03-12T09:14:00Z', + updated_at: FIXED_NOW, + ...overrides, + } +} + +let toastAdd: ReturnType + +vi.mock('@nuxt/ui/composables', () => ({ + useOverlay: () => ({ create: vi.fn() }), + useToast: () => ({ add: toastAdd }), +})) + +let currentWrapper: Awaited> | null = null + +async function mountEditor( + handlers: Record Response | Promise>, + path = '/article/12/edit', +) { + setActivePinia(createPinia()) + toastAdd = vi.fn() + const session = useSessionStore() + session.session = { verified: true, username: 'marta' } + session.status = 'ready' + global.fetch = fetchRouter(handlers) as unknown as typeof fetch + await router.push(path) + const wrapper = mount(RootComponent, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return wrapper +} + +function saveButton(wrapper: Awaited>) { + return wrapper.find('[data-testid="save-button"]') +} + +function findButton(text: string) { + return Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === text, + ) +} + +function toolbarImageButton(wrapper: Awaited>) { + return wrapper.findAll('button').find((button) => button.text() === 'Image') +} + +const RealXMLHttpRequest = global.XMLHttpRequest + +beforeEach(() => { + FakeXMLHttpRequest.instances = [] + global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest +}) + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + sessionStorage.clear() + global.XMLHttpRequest = RealXMLHttpRequest + await router.push('/login') +}) + +describe("Opening the uploader from the toolbar's image button shows the idle state", () => { + it('opens ImageUploader idle and sends no request', async () => { + const wrapper = await mountEditor({ + 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + }) + + await toolbarImageButton(wrapper)?.trigger('click') + await flushPromises() + + expect(document.body.textContent).toContain('Add an image') + expect(document.body.textContent).toContain('Drop an image here') + expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB') + expect(findButton('Choose file')).toBeTruthy() + expect(findButton('Cancel')).toBeTruthy() + expect(FakeXMLHttpRequest.instances).toHaveLength(0) + }) +}) + +describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => { + it('opens uploading, not idle, and sends the file', async () => { + const wrapper = await mountEditor({ + 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + }) + + const file = fileFixture('before.png', 'image/png', 3.9) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: { 0: file, length: 1 } }, + }) + wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent) + await flushPromises() + + expect(document.body.textContent).toContain('Add an image') + expect(document.body.textContent).not.toContain('Drop an image here') + expect(FakeXMLHttpRequest.instances).toHaveLength(1) + expect(latestXHR().url).toBe('/v1/article/12/image') + expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png') + }) +}) + +describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => { + it('inserts the image, marks the draft dirty and toasts', async () => { + const wrapper = await mountEditor({ + 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + }) + + await toolbarImageButton(wrapper)?.trigger('click') + await flushPromises() + + const fileInput = document.body.querySelector('input[type="file"]')! + const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9) + Object.defineProperty(fileInput, 'files', { + value: { 0: file, length: 1 }, + configurable: true, + }) + fileInput.dispatchEvent(new Event('change')) + await flushPromises() + + expect(FakeXMLHttpRequest.instances).toHaveLength(1) + latestXHR().resolve( + 201, + JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }), + ) + await flushPromises() + + const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue') + expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg') + expect(insertedBody).toContain('') + expect(wrapper.text()).toContain('Unsaved changes') + expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) + expect(toastAdd).toHaveBeenCalledWith({ + color: 'success', + icon: 'i-lucide-circle-check', + title: 'Image added', + description: 'Save to keep it in the article.', + }) + }) +}) diff --git a/web/visual/states.ts b/web/visual/states.ts index 4ab1d94..cda85c2 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -205,6 +205,63 @@ async function typeExactBodyLength(page: Page, totalLength: number) { await page.keyboard.insertText(inner) } +function mib(value: number) { + return Math.round(value * 1024 * 1024) +} + +async function openImageUploader(page: Page) { + await page.getByRole('button', { name: 'Image', exact: true }).first().click() + await page.getByRole('dialog').getByText('Add an image').waitFor() +} + +async function chooseImageFile(page: Page, filename: string, mimeType: string, size: number) { + await page.locator('input[type="file"]').setInputFiles({ + name: filename, + mimeType, + buffer: Buffer.alloc(size), + }) +} + +async function mockUploadedImage(page: Page) { + const isDark = await page.evaluate( + () => window.matchMedia('(prefers-color-scheme: dark)').matches, + ) + const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)' + const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)' + await page.route('**/static/uploadedImages/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + const svg = `${pathname}` + await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg }) + }) +} + +async function freezeUploadProgress(page: Page, loaded: number, total: number) { + await page.evaluate( + ({ loaded, total }) => { + const proto = XMLHttpRequest.prototype as unknown as Record + const originalOpen = proto.open as (...args: unknown[]) => void + proto.open = function (this: XMLHttpRequest, ...args: unknown[]) { + ;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1]) + return originalOpen.apply(this, args) + } + const originalSend = proto.send as (...args: unknown[]) => void + proto.send = function (this: XMLHttpRequest, ...args: unknown[]) { + const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? '' + if (url.includes('/image')) { + setTimeout(() => { + const event = new Event('progress') + Object.assign(event, { loaded, total, lengthComputable: true }) + this.upload.dispatchEvent(event) + }, 50) + return + } + return originalSend.apply(this, args) + } + }, + { loaded, total }, + ) +} + const newArticleTitle = 'Choosing tiles for a small bathroom' const newArticleBody = 'Small rooms show every grout line, so we start with the floor.' @@ -846,6 +903,57 @@ export const visualStates: Record> = { 'GET /v1/article/9999': { status: 404 }, }, }, + 'Upload: choose': { + path: '/article/12/edit', + now: editorNow, + api: editorApi(), + steps: async (page) => { + await markDirty(page) + await openImageUploader(page) + }, + }, + 'Upload: in progress': { + path: '/article/12/edit', + now: editorNow, + api: editorApi(), + steps: async (page) => { + await markDirty(page) + await openImageUploader(page) + await freezeUploadProgress(page, mib(2.4), mib(3.9)) + await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9)) + await page.getByText('north-wall.jpg 62%').waitFor() + }, + }, + 'Upload: rejected': { + path: '/article/12/edit', + now: editorNow, + api: editorApi(), + steps: async (page) => { + await markDirty(page) + await openImageUploader(page) + await chooseImageFile(page, 'north-wall.tif', 'image/png', mib(14.2)) + await page.getByText(`"north-wall.tif" can't be used`).waitFor() + }, + }, + 'Upload: inserted': { + path: '/article/12/edit', + now: editorNow, + api: editorApi({ + 'POST /v1/article/12/image': { + status: 201, + body: { location: '/static/uploadedImages/north-wall.jpg' }, + }, + }), + steps: async (page) => { + await mockUploadedImage(page) + await page.getByText('add a margin on materials').click() + await page.keyboard.press('Home') + await openImageUploader(page) + await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9)) + await page.getByText('Save to keep it in the article.').first().waitFor() + await page.waitForTimeout(650) + }, + }, }, 'new-article': { Default: { diff --git a/web/vite.config.ts b/web/vite.config.ts index f2db711..84c7ba9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ proxy: { '/v1': 'http://localhost:8080', '/healthz': 'http://localhost:8080', + '/static': 'http://localhost:8080', }, }, plugins: [