import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; import { contextForUser } from './helpers'; /** * Image paste/drop/insert pack (issue #28). Runs against the local dev * stack (api + web); no Mailpit needed. A tiny 1x1 PNG stands in for a * "screenshot" — only the magic bytes matter to the upload endpoint (#27). */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; async function createPage( context: Awaited>, title: string, ): Promise<{ pondSlug: string; pageSlug: string }> { const ponds = await context.request.get('/api/v1/ponds'); const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title }, }); const page = await created.json(); return { pondSlug: pond.slug, pageSlug: page.slug }; } async function enterEditMode(page: Page): Promise { await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); } /** Dispatches a real `ClipboardEvent` carrying an image `File`, built and * fired entirely inside the page — Playwright's `dispatchEvent` helper only * special-cases `dataTransfer` (for drag events), not `clipboardData`, so a * paste-with-files has to be constructed with the native constructor here * instead of via `evaluateHandle` + `locator.dispatchEvent`. */ async function pasteImage(page: Page, filename: string): Promise { await page.evaluate( async ({ base64, name }) => { const el = document.querySelector('.ProseMirror'); const response = await fetch(`data:image/png;base64,${base64}`); const blob = await response.blob(); const file = new File([blob], name, { type: 'image/png' }); const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); el!.dispatchEvent( new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true, }), ); }, { base64: PNG_BASE64, name: filename }, ); } /** Drop needs real viewport coordinates: ProseMirror resolves `posAtCoords` * from `event.clientX/Y` before ever calling `handleDrop`, and bails out * (never reaching our plugin) if they don't land inside the content. */ async function dropImage(page: Page, filename: string): Promise { const box = await page.locator('.ProseMirror').boundingBox(); if (!box) throw new Error('.ProseMirror has no bounding box'); const dataTransfer = await page.evaluateHandle( async ({ base64, name }) => { const response = await fetch(`data:image/png;base64,${base64}`); const blob = await response.blob(); const file = new File([blob], name, { type: 'image/png' }); const dt = new DataTransfer(); dt.items.add(file); return dt; }, { base64: PNG_BASE64, name: filename }, ); await page.locator('.ProseMirror').dispatchEvent('drop', { dataTransfer, clientX: box.x + box.width / 2, clientY: box.y + box.height / 2, }); } test('pasting an image inserts it at the cursor after upload', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Paste ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await enterEditMode(page); await page.locator('.ProseMirror').click(); await pasteImage(page, 'screenshot.png'); await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ timeout: 10000, }); await expect(page.locator('.editor-image-upload')).toHaveCount(0); await context.close(); }); test('toolbar file picker inserts an image', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Picker ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await enterEditMode(page); await page.getByRole('button', { name: /insert image|bild einfügen/i }).click(); await page.locator('input[type="file"]').setInputFiles({ name: 'picked.png', mimeType: 'image/png', buffer: Buffer.from(PNG_BASE64, 'base64'), }); await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ timeout: 10000, }); await context.close(); }); test('drag-and-drop of an image file inserts it', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Drop ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await enterEditMode(page); await page.locator('.ProseMirror').click(); await dropImage(page, 'dropped.png'); await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ timeout: 10000, }); await context.close(); }); test('alt text is editable and persists in the document', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Alt ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await enterEditMode(page); await page.locator('.ProseMirror').click(); await pasteImage(page, 'alt-test.png'); const image = page.locator('.ProseMirror img[src^="/api/v1/media/"]'); await expect(image).toBeVisible({ timeout: 10000 }); await image.click(); const altInput = page.locator('.editor-image__alt input'); await expect(altInput).toBeVisible(); await altInput.fill('A lovely test screenshot'); await expect(image).toHaveAttribute('alt', 'A lovely test screenshot'); await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); await page.reload(); await enterEditMode(page); await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toHaveAttribute( 'alt', 'A lovely test screenshot', ); await context.close(); }); test('a failed upload never leaves a broken node in the document', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Fail ${Date.now()}`); await context.route('**/api/v1/ponds/*/files', (route) => route.abort('failed')); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await enterEditMode(page); await page.locator('.ProseMirror').click(); await pasteImage(page, 'will-fail.png'); await expect(page.locator('.editor-image-upload--error')).toBeVisible(); await expect(page.locator('.editor-image-upload')).toHaveCount(0, { timeout: 10000 }); await expect(page.locator('.ProseMirror img')).toHaveCount(0); await context.close(); });