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
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
190 lines
7.1 KiB
TypeScript
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();
|
|
});
|