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>, ): 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 { 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(); });