dorfteich/apps/web/e2e/content.spec.ts
Claude Fable 5 58c19abfdd #169: Nicht-Text-Inhalte — Task-Checkboxen, Wissensgraph
Task-Checkboxen tragen in beiden Renderpfaden einen Namen: docToHtml
setzt aria-label aus dem Aufgabentext, die Editor-NodeView ebenso. Die
NodeView rendert ihr Host-Element jetzt selbst als li (ReactNodeView-
Renderer as/attrs) — TipTaps zusätzliches div-Host-Element zwischen ul
und li brach die Listensemantik; der Wrapper flacht per display:contents
ab, die #137-Pixel-Abstimmung bleibt erhalten (Selektor auf die neue
Tiefe nachgeführt, Ausrichtung nachgemessen: 1px-Versatz unverändert).
Der Wissensgraph-SVG bekommt ein beschreibendes aria-label inklusive
Verweis auf die Backlinks als gleichwertige Listenform. Der
Bild-Alt-Editor existierte bereits (Bild-Controls bei Auswahl) — kein
Änderungsbedarf. Hinweis: gecachte Seiten übernehmen das
Checkbox-Label wie bei jeder docToHtml-Änderung erst mit dem nächsten
Persist ihrer Inhalte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:35:16 +02:00

241 lines
10 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { contextForUser } from './helpers';
const here = dirname(fileURLToPath(import.meta.url));
/**
* Content regression pack (issue #32) — the one M2 e2e suite that runs in
* CI (job `auth-e2e`, `.gitea/workflows/ci.yml` — a second step there,
* reusing the same built+seeded stack rather than a separate job), against
* a local prod build seeded exactly like Test/Int. Covers page lifecycle, editor
* basics, image paste, Markdown round-trip, and trash: enough to catch a
* regression across the whole M2 content model without re-running every
* edge case already covered by the feature-specific packs (editor/image/
* link/markdown/trash/sidebar `.spec.ts`), which stay local-only.
*
* The Markdown round-trip test is the pack's actual regression pin: it
* compares the seeded "Every Element" fixture page's exported Markdown
* byte-for-byte against `content-page.md` (checked in next to the seed
* script, `apps/api/prisma/fixtures/`, regenerated via
* `pnpm --filter @dorfteich/api fixtures:regenerate`). The export endpoint
* serves the *cached* `page_content_cache.markdown` (refreshed by the seed
* script/state saves, not derived live on every request, #23/#30) — so a
* schema/serializer change only surfaces here once the seed has re-run
* against it, which is exactly what CI does on every run (build → migrate
* → seed → this pack). Verified during development: temporarily changed
* `docToMarkdown`'s heading serializer, rebuilt `packages/shared`, re-ran
* `db:seed`, and confirmed this assertion failed with the mutated output;
* reverted immediately after.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const CONTENT_FIXTURE_MARKDOWN = readFileSync(
join(here, '../../api/prisma/fixtures/content-page.md'),
'utf8',
);
async function personalPond(
context: Awaited<ReturnType<typeof contextForUser>>,
): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.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');
}
test('page lifecycle: create via the sidebar, rename, appears in the sidebar', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const title = `Content Pack Lifecycle ${Date.now()}`;
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
await page.getByRole('button', { name: /new page|neue seite/i }).click();
await page
.locator('.sidebar')
.getByLabel(/title|titel/i)
.fill(title);
await page.getByRole('button', { name: /create|erstellen/i }).click();
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
const renamed = `${title} (renamed)`;
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await page.locator('.editor-page__title').fill(renamed);
await page.locator('.editor-page__title').blur();
await page.reload();
await expect(page.locator('.sidebar__page--active')).toHaveText(renamed);
await context.close();
});
test('editor basics: typing autosaves and undo/redo work', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Content Pack Editor Basics ${Date.now()}` },
});
const { slug } = await created.json();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
await enterEditMode(page);
// Wait for the live connection before editing (persistence is over collab
// now, #36) so undo/redo runs against the synced document.
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
timeout: 10000,
});
const content = page.locator('.ProseMirror');
await content.click();
await page.keyboard.type('Hello content pack');
await expect(content).toContainText('Hello content pack');
await page.keyboard.press('ControlOrMeta+z');
await expect(content).not.toContainText('Hello content pack');
await page.keyboard.press('ControlOrMeta+y');
await expect(content).toContainText('Hello content pack');
await context.close();
});
test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots (#125)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Content Pack Shortcuts ${Date.now()}` },
});
const { id, slug } = (await created.json()) as { id: string; slug: string };
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
await expect(page.locator('.ProseMirror')).toBeVisible();
// Plain "e" flips reading → edit mode.
await page.keyboard.press('e');
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
// Ctrl/Cmd+S snapshots an unnamed manual version, confirms it with a
// toast (#130), and stays in edit mode.
await page.keyboard.press('ControlOrMeta+s');
// Assert the toast FIRST — it auto-dismisses after ~2.5s, so it must be
// caught before the version poll spends its budget.
await expect(page.locator('.toast')).toHaveText(/Version (gespeichert|saved)/);
await expect
.poll(async () => {
const res = await context.request.get(`/api/v1/pages/${id}/versions`);
const versions = (await res.json()) as { trigger: string; label: string | null }[];
return versions.filter((v) => v.trigger === 'manual').length;
})
.toBe(1);
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
// Ctrl/Cmd+Shift+S asks for a name and returns to reading mode; the toast
// names the saved version.
page.on('dialog', (dialog) => void dialog.accept('Meilenstein'));
await page.keyboard.press('ControlOrMeta+Shift+s');
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false');
await expect(page.locator('.toast').last()).toContainText('Meilenstein');
const res = await context.request.get(`/api/v1/pages/${id}/versions`);
const versions = (await res.json()) as { label: string | null }[];
expect(versions.some((v) => v.label === 'Meilenstein')).toBe(true);
await context.close();
});
test('image paste: uploads and renders at the cursor', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Content Pack Image ${Date.now()}` },
});
const { slug } = await created.json();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.evaluate(async () => {
const el = document.querySelector('.ProseMirror');
const base64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const response = await fetch(`data:image/png;base64,${base64}`);
const blob = await response.blob();
const file = new File([blob], 'content-pack.png', { type: 'image/png' });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({
timeout: 10000,
});
await context.close();
});
test('Markdown round-trip: the seeded fixture page exports byte-for-byte the checked-in fixture', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { slug: string }) => p.slug === 'content-fixtures');
expect(pond, 'content-fixtures fixture pond must be seeded').toBeTruthy();
const pages = await context.request.get(`/api/v1/ponds/${pond.id}/pages`);
const everyElement = (await pages.json()).find(
(p: { slug: string }) => p.slug === 'every-element',
);
expect(everyElement, 'every-element fixture page must be seeded').toBeTruthy();
const exported = await context.request.get(`/api/v1/pages/${everyElement.id}/export/markdown`);
expect(await exported.text()).toBe(CONTENT_FIXTURE_MARKDOWN);
await context.close();
});
test('trash: deleting hides a page from the sidebar; restoring brings it back', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const title = `Content Pack Trash ${Date.now()}`;
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title },
});
const { slug } = await created.json();
const page = await context.newPage();
page.on('dialog', (dialog) => void dialog.accept());
await page.goto(`/p/${pond.slug}/${slug}`);
await enterEditMode(page);
// Delete sits behind the TopBar overflow menu since #101.
await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click();
await page.getByRole('menuitem', { name: /move to trash|papierkorb verschieben/i }).click();
await page.goto(`/p/${pond.slug}`);
await expect(page.getByRole('link', { name: title })).toHaveCount(0);
await page.goto(`/p/${pond.slug}/trash`);
const item = page.locator('.trash-page__item').filter({ hasText: title });
await expect(item).toBeVisible();
await item.getByRole('button', { name: /restore|wiederherstellen/i }).click();
await expect(page.getByRole('link', { name: title })).toBeVisible();
await context.close();
});