dorfteich/apps/web/e2e/image.spec.ts
Claude Sonnet 5 c8be3cd85e
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m39s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Add image paste and insert in the editor (#28)
Paste and drag-and-drop of image files upload via the #27 API and insert
a real image node only once the upload succeeds; the in-flight state is
a ProseMirror decoration, not a document node, so a failed upload cannot
leave anything broken behind (it shows a transient inline error instead).
The toolbar's image button opens a native file picker into the same
upload path. Selecting an image reveals inline alt-text and width-preset
(small/medium/full) controls. Also fixes the image node's parseDOM,
which had no getAttrs and would drop the required fileId attribute on
internal copy/paste.

Closes #28
2026-07-08 11:30:25 +02:00

190 lines
7.1 KiB
TypeScript

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<ReturnType<typeof contextForUser>>,
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<void> {
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<void> {
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<void> {
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();
});