dorfteich/apps/web/e2e/markdown.spec.ts
Claude Sonnet 5 c9011cb44f
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m46s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Add Markdown copy, paste, and per-page export endpoint (#30)
Wires docToMarkdown/markdownToDoc into the editor clipboard: copying
selected content puts Markdown on text/plain alongside the browser's
own HTML (so pasting into a plain-text destination yields Markdown),
and pasting plain text that looks like a Markdown document converts it
to rich nodes; content with real HTML on the clipboard is left to
ProseMirror's normal HTML-based paste, and the heuristic requires two
or more distinct Markdown-shaped lines (or a fenced code block) so
ordinary prose is never mangled.

Both directions need the parsed/selected doc re-hydrated against
whichever schema instance is on the other side of the boundary: the
canonical editorSchema (packages/shared) for markdownToDoc's output
before inserting it into the live view, and the live view's schema
wrapped back into editorSchema before handing a slice to docToMarkdown
— they're structurally identical but not the same object, and
ProseMirror's content checks are identity-based.

Adds GET /pages/:id/export/markdown (downloads <slug>.md), serving the
already-derived page_content_cache.markdown (#23) rather than
re-decoding the Yjs state. "Copy as Markdown" and "Download as
Markdown" actions in the page header both read from that same
endpoint, so they always agree with each other and with the last saved
state.

Closes #30
2026-07-08 12:05:11 +02:00

128 lines
5.1 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Markdown copy/paste/export pack (issue #30). Runs against the local dev
* stack (api + web); no Mailpit needed.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
title: string,
): Promise<{ pondSlug: string; pageSlug: string; pageId: 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, pageId: page.id };
}
async function enterEditMode(page: Page): Promise<void> {
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
}
test('copying a heading+list selection yields Markdown on the clipboard', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Copy ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.keyboard.type('# Title');
await page.keyboard.press('Enter');
await page.keyboard.type('one');
await page.keyboard.press('Enter');
await page.keyboard.type('two');
// Turn the second/third lines into a bullet list, then copy everything.
await page.keyboard.press('Shift+Home');
await page.keyboard.press('Shift+ArrowUp');
await page.getByRole('button', { name: /bullet list|aufzählungsliste/i }).click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('ControlOrMeta+c');
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toContain('- one');
expect(clipboardText).toContain('- two');
await context.close();
});
test('pasting a Markdown document into an empty page recreates structure', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Paste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '# Welcome\n\n- alpha\n- beta\n\n1. first\n2. second\n');
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('h1')).toHaveText('Welcome');
await expect(content.locator('ul li')).toHaveCount(2);
await expect(content.locator('ol li')).toHaveCount(2);
await context.close();
});
test('a plain-text paste is not mangled into rich structure', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Plain ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
const plainText = "Just a *reminder* to buy milk - don't forget!";
await page.evaluate((text) => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', text);
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
}, plainText);
const content = page.locator('.ProseMirror');
await expect(content).toContainText(plainText);
await expect(content.locator('ul, ol, h1, h2, h3, h4')).toHaveCount(0);
await context.close();
});
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.keyboard.type('export me please');
await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 });
const exported = await context.request.get(`/api/v1/pages/${pageId}/export/markdown`);
expect(exported.headers()['content-disposition']).toContain(`${pageSlug}.md`);
expect(await exported.text()).toBe('export me please');
await context.close();
});